无法加载争议记录
diff --git a/frontend/components/layout/sidebar.tsx b/frontend/components/layout/sidebar.tsx
index 0d03ed99..aceb1151 100644
--- a/frontend/components/layout/sidebar.tsx
+++ b/frontend/components/layout/sidebar.tsx
@@ -60,6 +60,7 @@ import {
Layers,
Trophy,
ArrowUpRight,
+ ReceiptText,
} from "lucide-react"
import { useUser } from "@/contexts/user-context"
@@ -77,6 +78,7 @@ const data = {
{ title: "系统配置", url: "/admin/system", icon: ShieldCheck },
{ title: "积分配置", url: "/admin/credit", icon: Settings },
{ title: "用户管理", url: "/admin/users", icon: UserRound },
+ { title: "订单管理", url: "/admin/orders", icon: ReceiptText },
{ title: "任务管理", url: "/admin/tasks", icon: Layers },
],
document: [
diff --git a/frontend/lib/services/admin/admin.service.ts b/frontend/lib/services/admin/admin.service.ts
index d38b7a05..ed5904e5 100644
--- a/frontend/lib/services/admin/admin.service.ts
+++ b/frontend/lib/services/admin/admin.service.ts
@@ -12,6 +12,9 @@ import type {
ListUsersRequest,
ListUsersResponse,
UpdateUserStatusRequest,
+ ListAdminOrdersRequest,
+ ListAdminOrdersResponse,
+ RefundAdminOrderRequest,
} from './types';
export type { AdminUser } from './types';
@@ -369,4 +372,28 @@ export class AdminService extends BaseService {
): Promise
{
return this.put(`/users/${ id }/status`, request);
}
+
+ // ==================== 订单管理 ====================
+
+ /**
+ * 获取后台订单列表
+ * @param request - 查询参数
+ * @returns 订单列表及总数
+ */
+ static async listOrders(request: ListAdminOrdersRequest): Promise {
+ return this.post('/orders', request);
+ }
+
+ /**
+ * 管理员退款
+ * @param id - 订单 ID
+ * @param request - 退款备注
+ * @returns void
+ */
+ static async refundOrder(
+ id: string,
+ request: RefundAdminOrderRequest
+ ): Promise {
+ return this.post(`/orders/${ id }/refund`, request);
+ }
}
diff --git a/frontend/lib/services/admin/index.ts b/frontend/lib/services/admin/index.ts
index 8c2964c5..054f4c8c 100644
--- a/frontend/lib/services/admin/index.ts
+++ b/frontend/lib/services/admin/index.ts
@@ -41,5 +41,11 @@ export type {
ListUsersRequest,
ListUsersResponse,
UpdateUserStatusRequest,
+ AdminOrder,
+ AdminOrderType,
+ AdminOrderStatus,
+ AdminOrderTransferStatus,
+ ListAdminOrdersRequest,
+ ListAdminOrdersResponse,
+ RefundAdminOrderRequest,
} from './types';
-
diff --git a/frontend/lib/services/admin/types.ts b/frontend/lib/services/admin/types.ts
index d9518e64..a3e9a6f6 100644
--- a/frontend/lib/services/admin/types.ts
+++ b/frontend/lib/services/admin/types.ts
@@ -229,3 +229,91 @@ export interface UpdateUserStatusRequest {
/** 是否激活 */
is_active: boolean;
}
+
+// ==================== 订单管理 ====================
+
+/**
+ * 后台订单类型
+ */
+export type AdminOrderType = 'payment' | 'transfer' | 'community' | 'online' | 'test' | 'distribute' | 'red_envelope_send' | 'red_envelope_receive' | 'red_envelope_refund';
+
+/**
+ * 后台订单状态
+ */
+export type AdminOrderStatus = 'success' | 'pending' | 'failed' | 'expired' | 'disputing' | 'refund' | 'refused';
+
+/**
+ * 后台订单结算状态
+ */
+export type AdminOrderTransferStatus = 'pending' | 'completed';
+
+/**
+ * 后台订单信息
+ */
+export interface AdminOrder {
+ id: string;
+ order_no: string;
+ order_name: string;
+ merchant_order_no: string | null;
+ client_id: string;
+ payer_user_id: string;
+ payee_user_id: string;
+ payer_username: string;
+ payee_username: string;
+ payer_avatar_url?: string;
+ payee_avatar_url?: string;
+ amount: string;
+ status: AdminOrderStatus;
+ type: AdminOrderType;
+ remark: string;
+ payment_type: string;
+ app_name?: string;
+ app_homepage_url?: string;
+ app_description?: string;
+ dispute_id?: string;
+ dispute_status?: 'disputing' | 'refund' | 'closed';
+ dispute_reason?: string;
+ dispute_created_at?: string | null;
+ dispute_updated_at?: string | null;
+ payee_transfer_status: AdminOrderTransferStatus;
+ payee_transfer_at?: string | null;
+ trade_time: string;
+ expires_at: string;
+ created_at: string;
+ updated_at: string;
+}
+
+/**
+ * 后台订单列表查询请求
+ */
+export interface ListAdminOrdersRequest {
+ page: number;
+ page_size: number;
+ types?: AdminOrderType[];
+ statuses?: AdminOrderStatus[];
+ client_id?: string;
+ merchant_order_no?: string;
+ start_time?: string;
+ end_time?: string;
+ id?: string;
+ order_name?: string;
+ payer_username?: string;
+ payee_username?: string;
+}
+
+/**
+ * 后台订单列表响应
+ */
+export interface ListAdminOrdersResponse {
+ orders: AdminOrder[];
+ total: number;
+ page: number;
+ page_size: number;
+}
+
+/**
+ * 后台退款请求
+ */
+export interface RefundAdminOrderRequest {
+ remark?: string;
+}
diff --git a/frontend/lib/services/index.ts b/frontend/lib/services/index.ts
index 3ab73d67..d8c16a45 100644
--- a/frontend/lib/services/index.ts
+++ b/frontend/lib/services/index.ts
@@ -164,6 +164,13 @@ export type {
ListUsersRequest,
ListUsersResponse,
UpdateUserStatusRequest,
+ AdminOrder,
+ AdminOrderType,
+ AdminOrderStatus,
+ AdminOrderTransferStatus,
+ ListAdminOrdersRequest,
+ ListAdminOrdersResponse,
+ RefundAdminOrderRequest,
} from './admin';
// 用户服务
diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml
new file mode 100644
index 00000000..07399aa0
--- /dev/null
+++ b/frontend/pnpm-workspace.yaml
@@ -0,0 +1,4 @@
+allowBuilds:
+ core-js: true
+ sharp: true
+ unrs-resolver: true
diff --git a/internal/apps/admin/order/constants.go b/internal/apps/admin/order/constants.go
new file mode 100644
index 00000000..deb44753
--- /dev/null
+++ b/internal/apps/admin/order/constants.go
@@ -0,0 +1,24 @@
+/*
+Copyright 2025 linux.do
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package order
+
+const (
+ // orderRemarkMaxLength 对齐 orders.remark 字段长度,避免追加管理员备注后写库失败。
+ orderRemarkMaxLength = 255
+ // disputeReasonMaxLength 对齐 disputes.reason 字段长度,避免追加管理员备注后写库失败。
+ disputeReasonMaxLength = 500
+)
diff --git a/internal/apps/admin/order/errs.go b/internal/apps/admin/order/errs.go
new file mode 100644
index 00000000..da9b23e0
--- /dev/null
+++ b/internal/apps/admin/order/errs.go
@@ -0,0 +1,22 @@
+/*
+Copyright 2025 linux.do
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package order
+
+const (
+ orderNotRefundable = "订单不存在或状态不允许退款"
+ remarkTooLong = "管理员备注过长"
+)
diff --git a/internal/apps/admin/order/logics.go b/internal/apps/admin/order/logics.go
new file mode 100644
index 00000000..54b6123e
--- /dev/null
+++ b/internal/apps/admin/order/logics.go
@@ -0,0 +1,171 @@
+/*
+Copyright 2025 linux.do
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package order
+
+import (
+ "context"
+ "errors"
+
+ "github.com/linux-do/credit/internal/db"
+ "github.com/linux-do/credit/internal/model"
+ "github.com/linux-do/credit/internal/service"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+// listOrders 查询后台订单列表,并补充应用、争议、用户和延迟结算信息。
+func listOrders(ctx context.Context, req *listOrdersRequest) (*listOrdersResponse, error) {
+ query := db.DB(ctx).Model(&model.Order{})
+ if len(req.Types) > 0 {
+ query = query.Where("orders.type IN ?", req.Types)
+ }
+ if len(req.Statuses) > 0 {
+ query = query.Where("orders.status IN ?", req.Statuses)
+ }
+ if req.ClientID != "" {
+ query = query.Where("orders.client_id = ?", req.ClientID)
+ }
+ if req.MerchantOrderNo != "" {
+ query = query.Where("orders.merchant_order_no = ?", req.MerchantOrderNo)
+ }
+ if req.ID != nil {
+ query = query.Where("orders.id = ?", *req.ID)
+ }
+ if req.OrderName != "" {
+ query = query.Where("orders.order_name LIKE ?", req.OrderName+"%")
+ }
+ if req.StartTime != nil {
+ query = query.Where("orders.created_at >= ?", req.StartTime)
+ }
+ if req.EndTime != nil {
+ query = query.Where("orders.created_at <= ?", req.EndTime)
+ }
+
+ var err error
+ if query, err = applyOrderUsernameFilter(query, "orders.payer_user_id", req.PayerUsername); err != nil {
+ return nil, err
+ }
+ if query, err = applyOrderUsernameFilter(query, "orders.payee_user_id", req.PayeeUsername); err != nil {
+ return nil, err
+ }
+
+ var total int64
+ if err := query.Count(&total).Error; err != nil {
+ return nil, err
+ }
+
+ response := &listOrdersResponse{
+ Total: total,
+ Page: req.Page,
+ PageSize: req.PageSize,
+ }
+
+ offset := (req.Page - 1) * req.PageSize
+ if err := query.
+ Select("orders.*, merchant_api_keys.app_name, merchant_api_keys.app_homepage_url, merchant_api_keys.app_description, disputes.id as dispute_id, disputes.status as dispute_status, disputes.reason as dispute_reason, disputes.created_at as dispute_created_at, disputes.updated_at as dispute_updated_at, payer_user.username as payer_username, payee_user.username as payee_username, payer_user.avatar_url as payer_avatar_url, payee_user.avatar_url as payee_avatar_url, COALESCE(order_transfers.status, ?) as payee_transfer_status, order_transfers.transfer_at as payee_transfer_at", model.OrderTransferStatusCompleted).
+ Joins("LEFT JOIN merchant_api_keys ON orders.client_id = merchant_api_keys.client_id").
+ Joins("LEFT JOIN disputes ON orders.id = disputes.order_id").
+ Joins("LEFT JOIN users as payer_user ON orders.payer_user_id = payer_user.id").
+ Joins("LEFT JOIN users as payee_user ON orders.payee_user_id = payee_user.id").
+ Joins("LEFT JOIN order_transfers ON orders.id = order_transfers.order_id").
+ Order("orders.created_at DESC").
+ Offset(offset).
+ Limit(req.PageSize).
+ Find(&response.Orders).Error; err != nil {
+ return nil, err
+ }
+
+ return response, nil
+}
+
+// refundOrder 执行管理员退款;有争议时更新争议,无争议时只按需追加订单备注。
+func refundOrder(ctx context.Context, id uint64, req *refundOrderRequest, adminUserID uint64) error {
+ return db.DB(ctx).Transaction(func(tx *gorm.DB) error {
+ var order model.Order
+ if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
+ Where("id = ? AND status IN ? AND type IN ?", id, []model.OrderStatus{
+ model.OrderStatusSuccess,
+ model.OrderStatusDisputing,
+ model.OrderStatusRefused,
+ }, []model.OrderType{model.OrderTypePayment, model.OrderTypeOnline}).
+ First(&order).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return errors.New(orderNotRefundable)
+ }
+ return err
+ }
+
+ var merchantUser model.User
+ if err := merchantUser.GetByID(tx, order.PayeeUserID); err != nil {
+ return err
+ }
+
+ var merchantPayConfig model.UserPayConfig
+ if err := merchantPayConfig.GetByPayScore(tx, merchantUser.PayScore); err != nil {
+ return err
+ }
+
+ var dispute model.Dispute
+ hasDispute := false
+ // 存在争议时锁住争议记录,后续把管理员备注追加到争议原因;无争议才追加到订单备注。
+ if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
+ Where("order_id = ?", order.ID).First(&dispute).Error; err != nil {
+ if !errors.Is(err, gorm.ErrRecordNotFound) {
+ return err
+ }
+ } else {
+ hasDispute = true
+ if dispute.Status == model.DisputeStatusRefund {
+ return errors.New(orderNotRefundable)
+ }
+ }
+
+ if err := service.RefundOrder(tx, &order, &merchantPayConfig); err != nil {
+ return err
+ }
+
+ // 有争议时,管理员退款同时关闭争议;备注追加到争议原因,方便和双方争议对话一起查看。
+ if hasDispute {
+ updates := map[string]interface{}{
+ "status": model.DisputeStatusRefund,
+ "handler_user_id": adminUserID,
+ }
+ if req.Remark != "" {
+ reason, err := appendAdminRemark(dispute.Reason, req.Remark, disputeReasonMaxLength)
+ if err != nil {
+ return err
+ }
+ updates["reason"] = reason
+ }
+ return tx.Model(&model.Dispute{}).
+ Where("id = ?", dispute.ID).
+ Updates(updates).Error
+ }
+
+ // 普通订单没有争议记录,只有管理员填写备注时才落到订单备注。
+ if req.Remark == "" {
+ return nil
+ }
+ nextRemark, err := appendAdminRemark(order.Remark, req.Remark, orderRemarkMaxLength)
+ if err != nil {
+ return err
+ }
+ return tx.Model(&model.Order{}).
+ Where("id = ?", order.ID).
+ Update("remark", nextRemark).Error
+ })
+}
diff --git a/internal/apps/admin/order/routers.go b/internal/apps/admin/order/routers.go
new file mode 100644
index 00000000..b58a88e7
--- /dev/null
+++ b/internal/apps/admin/order/routers.go
@@ -0,0 +1,138 @@
+/*
+Copyright 2025 linux.do
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package order
+
+import (
+ "errors"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/linux-do/credit/internal/apps/oauth"
+ "github.com/linux-do/credit/internal/model"
+ "github.com/linux-do/credit/internal/service"
+ "github.com/linux-do/credit/internal/util"
+)
+
+// listOrdersRequest 后台订单列表查询请求。
+type listOrdersRequest struct {
+ Page int `json:"page" binding:"min=1"`
+ PageSize int `json:"page_size" binding:"min=1,max=100"`
+ Types []string `json:"types" binding:"omitempty,dive,oneof=payment transfer community online test distribute red_envelope_send red_envelope_receive red_envelope_refund"`
+ Statuses []string `json:"statuses" binding:"omitempty,dive,oneof=success pending failed expired disputing refund refused"`
+ ClientID string `json:"client_id" binding:"omitempty,max=64"`
+ MerchantOrderNo string `json:"merchant_order_no" binding:"omitempty,max=64"`
+ StartTime *time.Time `json:"start_time" binding:"omitempty"`
+ EndTime *time.Time `json:"end_time" binding:"omitempty,gtfield=StartTime"`
+ ID *uint64 `json:"id,string" binding:"omitempty"`
+ OrderName string `json:"order_name" binding:"omitempty,max=64"`
+ PayerUsername string `json:"payer_username" binding:"omitempty,max=255"`
+ PayeeUsername string `json:"payee_username" binding:"omitempty,max=255"`
+}
+
+// adminOrder 后台订单列表项,补充应用、争议、用户和延迟结算信息。
+type adminOrder struct {
+ model.Order
+ AppName string `json:"app_name"`
+ AppHomepageURL string `json:"app_homepage_url"`
+ AppDescription string `json:"app_description"`
+ DisputeID *uint64 `json:"dispute_id,string"`
+ DisputeStatus *model.DisputeStatus `json:"dispute_status"`
+ DisputeReason string `json:"dispute_reason"`
+ DisputeCreatedAt *time.Time `json:"dispute_created_at"`
+ DisputeUpdatedAt *time.Time `json:"dispute_updated_at"`
+ PayerUsername string `json:"payer_username"`
+ PayeeUsername string `json:"payee_username"`
+ PayerAvatarURL string `json:"payer_avatar_url"`
+ PayeeAvatarURL string `json:"payee_avatar_url"`
+ PayeeTransferStatus string `json:"payee_transfer_status"`
+ PayeeTransferAt *time.Time `json:"payee_transfer_at"`
+}
+
+// listOrdersResponse 后台订单列表响应。
+type listOrdersResponse struct {
+ Orders []adminOrder `json:"orders"`
+ Total int64 `json:"total"`
+ Page int `json:"page"`
+ PageSize int `json:"page_size"`
+}
+
+// refundOrderRequest 后台退款请求。
+type refundOrderRequest struct {
+ ID uint64 `uri:"id" json:"-" binding:"required,gt=0"`
+ Remark string `json:"remark" binding:"omitempty,max=100"`
+}
+
+// ListOrders 获取后台订单列表
+// @Tags admin
+// @Accept json
+// @Produce json
+// @Param request body listOrdersRequest true "request body"
+// @Success 200 {object} util.ResponseAny
+// @Router /api/v1/admin/orders [post]
+func ListOrders(c *gin.Context) {
+ var req listOrdersRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, util.Err(err.Error()))
+ return
+ }
+
+ response, err := listOrders(c.Request.Context(), &req)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
+ return
+ }
+
+ c.JSON(http.StatusOK, util.OK(response))
+}
+
+// RefundOrder 管理员退款
+// @Tags admin
+// @Accept json
+// @Produce json
+// @Param id path int true "订单ID"
+// @Param request body refundOrderRequest false "request body"
+// @Success 200 {object} util.ResponseAny
+// @Router /api/v1/admin/orders/{id}/refund [post]
+func RefundOrder(c *gin.Context) {
+ var req refundOrderRequest
+ if err := c.ShouldBindUri(&req); err != nil {
+ c.JSON(http.StatusBadRequest, util.Err(err.Error()))
+ return
+ }
+ if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
+ c.JSON(http.StatusBadRequest, util.Err(err.Error()))
+ return
+ }
+ req.Remark = strings.TrimSpace(req.Remark)
+
+ adminUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
+
+ if err := refundOrder(c.Request.Context(), req.ID, &req, adminUser.ID); err != nil {
+ switch err.Error() {
+ case orderNotRefundable, service.RefundOrderStatusInvalid, service.RefundOrderTypeInvalid, remarkTooLong:
+ c.JSON(http.StatusBadRequest, util.Err(err.Error()))
+ default:
+ c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
+ }
+ return
+ }
+
+ c.JSON(http.StatusOK, util.OKNil())
+}
diff --git a/internal/apps/admin/order/utils.go b/internal/apps/admin/order/utils.go
new file mode 100644
index 00000000..77be9971
--- /dev/null
+++ b/internal/apps/admin/order/utils.go
@@ -0,0 +1,61 @@
+/*
+Copyright 2025 linux.do
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package order
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+ "unicode/utf8"
+
+ "github.com/linux-do/credit/internal/model"
+ "gorm.io/gorm"
+)
+
+// applyOrderUsernameFilter 先按用户名前缀查用户 ID,再把 ID 条件追加回订单查询。
+func applyOrderUsernameFilter(query *gorm.DB, column string, username string) (*gorm.DB, error) {
+ username = strings.TrimSpace(username)
+ if username == "" {
+ return query, nil
+ }
+
+ var ids []uint64
+ if err := query.Session(&gorm.Session{NewDB: true}).
+ Model(&model.User{}).
+ Where("username LIKE ?", username+"%").
+ Pluck("id", &ids).Error; err != nil {
+ return nil, err
+ }
+ if len(ids) == 0 {
+ return query.Where("1 = 0"), nil
+ }
+ return query.Where(column+" IN ?", ids), nil
+}
+
+// appendAdminRemark 追加管理员备注并校验目标字段长度,避免覆盖原备注或争议原因。
+func appendAdminRemark(original string, remark string, maxLength int) (string, error) {
+ suffix := fmt.Sprintf("[管理员: %s]", remark)
+ if original != "" {
+ suffix = " " + suffix
+ }
+
+ next := original + suffix
+ if utf8.RuneCountInString(next) > maxLength {
+ return "", errors.New(remarkTooLong)
+ }
+ return next, nil
+}
diff --git a/internal/apps/dispute/routers.go b/internal/apps/dispute/routers.go
index 7339b43d..67efd895 100644
--- a/internal/apps/dispute/routers.go
+++ b/internal/apps/dispute/routers.go
@@ -26,6 +26,7 @@ import (
"github.com/linux-do/credit/internal/apps/oauth"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
+ "github.com/linux-do/credit/internal/service"
"github.com/linux-do/credit/internal/util"
"github.com/shopspring/decimal"
"gorm.io/gorm"
@@ -292,38 +293,12 @@ func RefundReview(c *gin.Context) {
}
if status == model.DisputeStatusRefund {
- var payerUser model.User
- if err := payerUser.GetByID(tx, order.PayerUserID); err != nil {
- return err
- }
-
// 获取商家的支付配置
var merchantPayConfig model.UserPayConfig
if err := merchantPayConfig.GetByPayScore(tx, merchantUser.PayScore); err != nil {
return err
}
- merchantScoreDecrease := order.Amount.Mul(merchantPayConfig.ScoreRate).Round(0).IntPart()
- if err := tx.Model(&model.User{}).
- Where("id = ?", merchantUser.ID).
- UpdateColumns(map[string]interface{}{
- "available_balance": gorm.Expr("available_balance - ?", order.Amount),
- "total_receive": gorm.Expr("total_receive - ?", order.Amount),
- "pay_score": gorm.Expr("pay_score - ?", merchantScoreDecrease),
- }).Error; err != nil {
- return err
- }
-
- if err := tx.Model(&model.User{}).
- Where("id = ?", payerUser.ID).
- UpdateColumns(map[string]interface{}{
- "available_balance": gorm.Expr("available_balance + ?", order.Amount),
- "total_payment": gorm.Expr("total_payment - ?", order.Amount),
- "pay_score": gorm.Expr("pay_score - ?", order.Amount.Round(0).IntPart()),
- }).Error; err != nil {
- return err
- }
-
if err := tx.Model(&model.Dispute{}).
Where("id = ?", dispute.ID).
Updates(map[string]interface{}{
@@ -333,9 +308,7 @@ func RefundReview(c *gin.Context) {
return err
}
- if err := tx.Model(&model.Order{}).
- Where("id = ?", order.ID).
- Update("status", model.OrderStatusRefund).Error; err != nil {
+ if err := service.RefundOrder(tx, &order, &merchantPayConfig); err != nil {
return err
}
} else if status == model.DisputeStatusClosed {
diff --git a/internal/apps/dispute/tasks.go b/internal/apps/dispute/tasks.go
index 9396cbcb..c45c1aef 100644
--- a/internal/apps/dispute/tasks.go
+++ b/internal/apps/dispute/tasks.go
@@ -28,6 +28,7 @@ import (
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/logger"
"github.com/linux-do/credit/internal/model"
+ "github.com/linux-do/credit/internal/service"
"github.com/linux-do/credit/internal/task"
"github.com/linux-do/credit/internal/task/scheduler"
"gorm.io/gorm"
@@ -129,11 +130,8 @@ func HandleAutoRefundSingleDispute(ctx context.Context, t *asynq.Task) error {
return err
}
- // 获取付款方和收款方用户
- var payerUser, payeeUser model.User
- if err := payerUser.GetByID(tx, order.PayerUserID); err != nil {
- return fmt.Errorf("查询付款方用户失败: %w", err)
- }
+ // 获取收款方用户
+ var payeeUser model.User
if err := payeeUser.GetByID(tx, order.PayeeUserID); err != nil {
return fmt.Errorf("查询收款方用户失败: %w", err)
}
@@ -144,31 +142,6 @@ func HandleAutoRefundSingleDispute(ctx context.Context, t *asynq.Task) error {
return fmt.Errorf("查询商家支付配置失败: %w", err)
}
- // 计算商家积分减少:订单金额 × 商家的 score_rate
- merchantScoreDecrease := order.Amount.Mul(merchantPayConfig.ScoreRate).Round(0).IntPart()
-
- // 商家(收款方)退款:扣除可用余额、总收款和积分
- if err := tx.Model(&model.User{}).
- Where("id = ?", payeeUser.ID).
- UpdateColumns(map[string]interface{}{
- "available_balance": gorm.Expr("available_balance - ?", order.Amount),
- "total_receive": gorm.Expr("total_receive - ?", order.Amount),
- "pay_score": gorm.Expr("pay_score - ?", merchantScoreDecrease),
- }).Error; err != nil {
- return fmt.Errorf("商家退款失败: %w", err)
- }
-
- // 付款方收到退款:增加可用余额,减少总支付和支付积分
- if err := tx.Model(&model.User{}).
- Where("id = ?", payerUser.ID).
- UpdateColumns(map[string]interface{}{
- "available_balance": gorm.Expr("available_balance + ?", order.Amount),
- "total_payment": gorm.Expr("total_payment - ?", order.Amount),
- "pay_score": gorm.Expr("pay_score - ?", order.Amount.Round(0).IntPart()),
- }).Error; err != nil {
- return fmt.Errorf("付款方退款失败: %w", err)
- }
-
// 更新争议状态为已退款,handler_user_id 设为 0(系统自动处理)
if err := tx.Model(&model.Dispute{}).
Where("id = ?", dispute.ID).
@@ -179,15 +152,12 @@ func HandleAutoRefundSingleDispute(ctx context.Context, t *asynq.Task) error {
return fmt.Errorf("更新争议状态失败: %w", err)
}
- // 更新订单状态为已退款
- if err := tx.Model(&model.Order{}).
- Where("id = ?", order.ID).
- Update("status", model.OrderStatusRefund).Error; err != nil {
- return fmt.Errorf("更新订单状态失败: %w", err)
+ if err := service.RefundOrder(tx, &order, &merchantPayConfig); err != nil {
+ return fmt.Errorf("订单退款失败: %w", err)
}
- logger.InfoF(ctx, "自动退款成功: 争议[ID:%d] 订单[ID:%d] 金额[%s] 付款方[%s] 商家[%s]",
- dispute.ID, order.ID, order.Amount.String(), payerUser.Username, payeeUser.Username)
+ logger.InfoF(ctx, "自动退款成功: 争议[ID:%d] 订单[ID:%d] 金额[%s] 付款方[ID:%d] 商家[%s]",
+ dispute.ID, order.ID, order.Amount.String(), order.PayerUserID, payeeUser.Username)
return nil
}); err != nil {
diff --git a/internal/apps/payment/routers.go b/internal/apps/payment/routers.go
index 20803003..b6d6fa4d 100644
--- a/internal/apps/payment/routers.go
+++ b/internal/apps/payment/routers.go
@@ -264,7 +264,7 @@ func RefundMerchantOrder(c *gin.Context) {
if err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
var order model.Order
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
- Where("id = ? AND client_id = ? AND status = ? AND amount = ? AND type IN ?", req.TradeNo, req.ClientID, model.OrderStatusSuccess, req.Amount, []model.OrderType{model.OrderTypePayment, model.OrderTypeOnline}).
+ Where("id = ? AND client_id = ? AND payee_user_id = ? AND status = ? AND amount = ? AND type IN ?", req.TradeNo, req.ClientID, apiKey.UserID, model.OrderStatusSuccess, req.Amount, []model.OrderType{model.OrderTypePayment, model.OrderTypeOnline}).
First(&order).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(OrderNotFound)
@@ -272,11 +272,6 @@ func RefundMerchantOrder(c *gin.Context) {
return err
}
- var payerUser model.User
- if err := payerUser.GetByID(tx, order.PayerUserID); err != nil {
- return err
- }
-
var merchantUser model.User
if err := tx.Where("id = ? AND is_active = ?", apiKey.UserID, true).First(&merchantUser).Error; err != nil {
return err
@@ -287,30 +282,7 @@ func RefundMerchantOrder(c *gin.Context) {
return err
}
- merchantScoreDecrease := order.Amount.Mul(merchantPayConfig.ScoreRate).Round(0).IntPart()
- if err := tx.Model(&model.User{}).
- Where("id = ?", merchantUser.ID).
- UpdateColumns(map[string]interface{}{
- "available_balance": gorm.Expr("available_balance - ?", order.Amount),
- "total_receive": gorm.Expr("total_receive - ?", order.Amount),
- "pay_score": gorm.Expr("pay_score - ?", merchantScoreDecrease),
- }).Error; err != nil {
- return err
- }
-
- if err := tx.Model(&model.User{}).
- Where("id = ?", payerUser.ID).
- UpdateColumns(map[string]interface{}{
- "available_balance": gorm.Expr("available_balance + ?", order.Amount),
- "total_payment": gorm.Expr("total_payment - ?", order.Amount),
- "pay_score": gorm.Expr("pay_score - ?", order.Amount.Round(0).IntPart()),
- }).Error; err != nil {
- return err
- }
-
- if err := tx.Model(&model.Order{}).
- Where("id = ?", order.ID).
- Update("status", model.OrderStatusRefund).Error; err != nil {
+ if err := service.RefundOrder(tx, &order, &merchantPayConfig); err != nil {
return err
}
diff --git a/internal/model/orders.go b/internal/model/orders.go
index 8de3e862..8cd064fc 100644
--- a/internal/model/orders.go
+++ b/internal/model/orders.go
@@ -60,7 +60,7 @@ type Order struct {
ID uint64 `json:"id,string" gorm:"primaryKey"`
OrderNo string `json:"order_no" gorm:"-"`
OrderName string `json:"order_name" gorm:"size:64;not null;index"`
- MerchantOrderNo *string `json:"merchant_order_no" gorm:"size:64;uniqueIndex:idx_orders_client_merchant_order,priority:2"`
+ MerchantOrderNo *string `json:"merchant_order_no" gorm:"size:64;index:idx_orders_merchant_order_no;uniqueIndex:idx_orders_client_merchant_order,priority:2"`
ClientID string `json:"client_id" gorm:"size:64;index:idx_orders_client_status_created,priority:1;index:idx_orders_client_payee,priority:1;index:idx_orders_client_payer,priority:1;uniqueIndex:idx_orders_client_merchant_order,priority:1"`
PayerUserID uint64 `json:"payer_user_id" gorm:"index:idx_orders_payer_status_type_created,priority:1;index:idx_orders_payer_status_type_trade,priority:1;index:idx_orders_client_payer,priority:2"`
PayeeUserID uint64 `json:"payee_user_id" gorm:"index:idx_orders_payee_status_type_created,priority:1;index:idx_orders_client_payee,priority:2"`
@@ -76,7 +76,7 @@ type Order struct {
PaymentLinkID *uint64 `json:"payment_link_id,string" gorm:"index:idx_orders_payment_link_status,priority:1"`
TradeTime time.Time `json:"trade_time" gorm:"index:idx_orders_payer_status_type_trade,priority:4"`
ExpiresAt time.Time `json:"expires_at" gorm:"not null;index:idx_orders_status_expires,priority:2"`
- CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index:idx_orders_payee_status_type_created,priority:4;index:idx_orders_payer_status_type_created,priority:4;index:idx_orders_client_status_created,priority:3"`
+ CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index:idx_orders_created;index:idx_orders_payee_status_type_created,priority:4;index:idx_orders_payer_status_type_created,priority:4;index:idx_orders_client_status_created,priority:3"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"`
}
diff --git a/internal/router/router.go b/internal/router/router.go
index 05cbc377..6ba5c0af 100644
--- a/internal/router/router.go
+++ b/internal/router/router.go
@@ -28,6 +28,7 @@ import (
"time"
"github.com/linux-do/credit/internal/apps/admin"
+ admin_order "github.com/linux-do/credit/internal/apps/admin/order"
admin_task "github.com/linux-do/credit/internal/apps/admin/task"
admin_user "github.com/linux-do/credit/internal/apps/admin/user"
publicconfig "github.com/linux-do/credit/internal/apps/config"
@@ -252,6 +253,10 @@ func Serve() {
adminRouter.GET("/users", admin_user.ListUsers)
adminRouter.PUT("/users/:id/status", admin_user.UpdateUserStatus)
+ // Orders
+ adminRouter.POST("/orders", admin_order.ListOrders)
+ adminRouter.POST("/orders/:id/refund", admin_order.RefundOrder)
+
// System Config
adminRouter.POST("/system-configs", system_config.CreateSystemConfig)
adminRouter.GET("/system-configs", system_config.ListSystemConfigs)
diff --git a/internal/service/payment.go b/internal/service/payment.go
index 85d8ff08..0b8178e6 100644
--- a/internal/service/payment.go
+++ b/internal/service/payment.go
@@ -39,6 +39,13 @@ const (
BalanceDeduct
)
+const (
+ RefundOrderStatusInvalid = "订单状态不允许退款"
+ RefundOrderTypeInvalid = "订单类型不允许退款"
+ RefundOrderPayerNotFound = "付款方不存在"
+ RefundOrderMerchantNotFound = "商家不存在"
+)
+
// BalanceUpdateOptions 余额更新选项
type BalanceUpdateOptions struct {
UserID uint64
@@ -161,6 +168,66 @@ func CalculateFee(amount decimal.Decimal, feeRate decimal.Decimal) (fee decimal.
return
}
+// RefundOrder 回滚订单资金并将订单置为已退款。调用方需要先在事务内锁定订单行。
+func RefundOrder(tx *gorm.DB, order *model.Order, merchantPayConfig *model.UserPayConfig) error {
+ if order.Type != model.OrderTypePayment && order.Type != model.OrderTypeOnline {
+ return errors.New(RefundOrderTypeInvalid)
+ }
+
+ switch order.Status {
+ case model.OrderStatusSuccess, model.OrderStatusDisputing, model.OrderStatusRefused:
+ default:
+ return errors.New(RefundOrderStatusInvalid)
+ }
+
+ payerResult := tx.Model(&model.User{}).
+ Where("id = ?", order.PayerUserID).
+ UpdateColumns(map[string]interface{}{
+ "available_balance": gorm.Expr("available_balance + ?", order.Amount),
+ "total_payment": gorm.Expr("total_payment - ?", order.Amount),
+ "pay_score": gorm.Expr("pay_score - ?", order.Amount.Round(0).IntPart()),
+ })
+ if payerResult.Error != nil {
+ return payerResult.Error
+ }
+ if payerResult.RowsAffected == 0 {
+ return errors.New(RefundOrderPayerNotFound)
+ }
+
+ merchantScoreDecrease := order.Amount.Mul(merchantPayConfig.ScoreRate).Round(0).IntPart()
+ merchantResult := tx.Model(&model.User{}).
+ Where("id = ?", order.PayeeUserID).
+ UpdateColumns(map[string]interface{}{
+ "available_balance": gorm.Expr("available_balance - ?", order.Amount),
+ "total_receive": gorm.Expr("total_receive - ?", order.Amount),
+ "pay_score": gorm.Expr("pay_score - ?", merchantScoreDecrease),
+ })
+ if merchantResult.Error != nil {
+ return merchantResult.Error
+ }
+ if merchantResult.RowsAffected == 0 {
+ return errors.New(RefundOrderMerchantNotFound)
+ }
+
+ // 更新订单状态
+ result := tx.Model(&model.Order{}).
+ Where("id = ? AND status IN ?", order.ID, []model.OrderStatus{
+ model.OrderStatusSuccess,
+ model.OrderStatusDisputing,
+ model.OrderStatusRefused,
+ }).
+ Update("status", model.OrderStatusRefund)
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected == 0 {
+ return errors.New(RefundOrderStatusInvalid)
+ }
+
+ order.Status = model.OrderStatusRefund
+ return nil
+}
+
// ValidateTestModePayment 验证测试模式下的支付权限
// 返回 error:nil 表示允许支付,非 nil 表示拒绝支付
func ValidateTestModePayment(currentUserID, merchantUserID uint64, isTestMode bool) error {
From 58fcae756160a05215d08c06afd672f1ff959067 Mon Sep 17 00:00:00 2001
From: yyg-max <175597134+yyg-max@users.noreply.github.com>
Date: Fri, 26 Jun 2026 17:36:14 +0800
Subject: [PATCH 2/4] fix(docs): regenerate swagger definitions
---
docs/docs.go | 13 -------------
docs/swagger.json | 13 -------------
docs/swagger.yaml | 13 -------------
3 files changed, 39 deletions(-)
diff --git a/docs/docs.go b/docs/docs.go
index 2d80e61c..cbb8e673 100644
--- a/docs/docs.go
+++ b/docs/docs.go
@@ -2217,63 +2217,51 @@ const docTemplate = `{
"type": "object",
"properties": {
"client_id": {
- "description": "ClientID 商户应用 Client ID。",
"type": "string",
"maxLength": 64
},
"end_time": {
- "description": "EndTime 创建时间终点。",
"type": "string"
},
"id": {
- "description": "ID 订单 ID。",
"type": "string",
"example": "0"
},
"merchant_order_no": {
- "description": "MerchantOrderNo 商户订单号。",
"type": "string",
"maxLength": 64
},
"order_name": {
- "description": "OrderName 订单名称前缀。",
"type": "string",
"maxLength": 64
},
"page": {
- "description": "Page 页码,从 1 开始。",
"type": "integer",
"minimum": 1
},
"page_size": {
- "description": "PageSize 每页数量。",
"type": "integer",
"maximum": 100,
"minimum": 1
},
"payee_username": {
- "description": "PayeeUsername 服务方用户名前缀。",
"type": "string",
"maxLength": 255
},
"payer_username": {
- "description": "PayerUsername 消费方用户名前缀。",
"type": "string",
"maxLength": 255
},
"start_time": {
- "description": "StartTime 创建时间起点。",
"type": "string"
},
"statuses": {
- "description": "Statuses 订单状态筛选。",
"type": "array",
"items": {
"type": "string"
}
},
"types": {
- "description": "Types 订单类型筛选。",
"type": "array",
"items": {
"type": "string"
@@ -2285,7 +2273,6 @@ const docTemplate = `{
"type": "object",
"properties": {
"remark": {
- "description": "Remark 管理员备注,可选;有争议时追加到争议原因,无争议时追加到订单备注。",
"type": "string",
"maxLength": 100
}
diff --git a/docs/swagger.json b/docs/swagger.json
index a962a214..003dc0fb 100644
--- a/docs/swagger.json
+++ b/docs/swagger.json
@@ -2208,63 +2208,51 @@
"type": "object",
"properties": {
"client_id": {
- "description": "ClientID 商户应用 Client ID。",
"type": "string",
"maxLength": 64
},
"end_time": {
- "description": "EndTime 创建时间终点。",
"type": "string"
},
"id": {
- "description": "ID 订单 ID。",
"type": "string",
"example": "0"
},
"merchant_order_no": {
- "description": "MerchantOrderNo 商户订单号。",
"type": "string",
"maxLength": 64
},
"order_name": {
- "description": "OrderName 订单名称前缀。",
"type": "string",
"maxLength": 64
},
"page": {
- "description": "Page 页码,从 1 开始。",
"type": "integer",
"minimum": 1
},
"page_size": {
- "description": "PageSize 每页数量。",
"type": "integer",
"maximum": 100,
"minimum": 1
},
"payee_username": {
- "description": "PayeeUsername 服务方用户名前缀。",
"type": "string",
"maxLength": 255
},
"payer_username": {
- "description": "PayerUsername 消费方用户名前缀。",
"type": "string",
"maxLength": 255
},
"start_time": {
- "description": "StartTime 创建时间起点。",
"type": "string"
},
"statuses": {
- "description": "Statuses 订单状态筛选。",
"type": "array",
"items": {
"type": "string"
}
},
"types": {
- "description": "Types 订单类型筛选。",
"type": "array",
"items": {
"type": "string"
@@ -2276,7 +2264,6 @@
"type": "object",
"properties": {
"remark": {
- "description": "Remark 管理员备注,可选;有争议时追加到争议原因,无争议时追加到订单备注。",
"type": "string",
"maxLength": 100
}
diff --git a/docs/swagger.yaml b/docs/swagger.yaml
index 58c404e2..f42a3d17 100644
--- a/docs/swagger.yaml
+++ b/docs/swagger.yaml
@@ -234,51 +234,39 @@ definitions:
order.listOrdersRequest:
properties:
client_id:
- description: ClientID 商户应用 Client ID。
maxLength: 64
type: string
end_time:
- description: EndTime 创建时间终点。
type: string
id:
- description: ID 订单 ID。
example: "0"
type: string
merchant_order_no:
- description: MerchantOrderNo 商户订单号。
maxLength: 64
type: string
order_name:
- description: OrderName 订单名称前缀。
maxLength: 64
type: string
page:
- description: Page 页码,从 1 开始。
minimum: 1
type: integer
page_size:
- description: PageSize 每页数量。
maximum: 100
minimum: 1
type: integer
payee_username:
- description: PayeeUsername 服务方用户名前缀。
maxLength: 255
type: string
payer_username:
- description: PayerUsername 消费方用户名前缀。
maxLength: 255
type: string
start_time:
- description: StartTime 创建时间起点。
type: string
statuses:
- description: Statuses 订单状态筛选。
items:
type: string
type: array
types:
- description: Types 订单类型筛选。
items:
type: string
type: array
@@ -286,7 +274,6 @@ definitions:
order.refundOrderRequest:
properties:
remark:
- description: Remark 管理员备注,可选;有争议时追加到争议原因,无争议时追加到订单备注。
maxLength: 100
type: string
type: object
From b0faf751a0088561a95f0299d01318c905916c51 Mon Sep 17 00:00:00 2001
From: yyg-max <175597134+yyg-max@users.noreply.github.com>
Date: Wed, 1 Jul 2026 17:48:50 +0800
Subject: [PATCH 3/4] chore(frontend): remove pnpm workspace config
---
frontend/pnpm-workspace.yaml | 4 ----
1 file changed, 4 deletions(-)
delete mode 100644 frontend/pnpm-workspace.yaml
diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml
deleted file mode 100644
index 07399aa0..00000000
--- a/frontend/pnpm-workspace.yaml
+++ /dev/null
@@ -1,4 +0,0 @@
-allowBuilds:
- core-js: true
- sharp: true
- unrs-resolver: true
From 437f782f22c97c8666ccd942cb43cff647484bfa Mon Sep 17 00:00:00 2001
From: Chenyme <118253778+chenyme@users.noreply.github.com>
Date: Wed, 1 Jul 2026 18:41:30 +0800
Subject: [PATCH 4/4] chore(frontend): update order management components and
add new data table features
---
.gitignore | 2 +
frontend/components/common/admin/orders.tsx | 418 ++++++++----------
.../common/general/dispute-dialog.tsx | 6 +-
.../components/common/general/table-data.tsx | 73 ++-
.../common/general/table-filter.tsx | 66 ++-
frontend/lib/services/admin/types.ts | 4 +-
6 files changed, 309 insertions(+), 260 deletions(-)
diff --git a/.gitignore b/.gitignore
index 03dd16dd..b2cd718b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,3 +43,5 @@ main
uploads/*
s3_cache
+
+.gocache
\ No newline at end of file
diff --git a/frontend/components/common/admin/orders.tsx b/frontend/components/common/admin/orders.tsx
index d1b39e5f..02d3f1cd 100644
--- a/frontend/components/common/admin/orders.tsx
+++ b/frontend/components/common/admin/orders.tsx
@@ -3,7 +3,7 @@
import * as React from "react"
import Link from "next/link"
import { toast } from "sonner"
-import { Eye, Layers, Loader2, ReceiptText, RotateCw, Search, Undo2, X } from "lucide-react"
+import { Eye, Layers, Loader2, ReceiptText, RotateCw, Search, Undo2 } from "lucide-react"
import { AdminService, type AdminOrder, type AdminOrderStatus, type AdminOrderType, type ListAdminOrdersRequest } from "@/lib/services"
import { Button } from "@/components/ui/button"
@@ -12,7 +12,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
-import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
+import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Textarea } from "@/components/ui/textarea"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
@@ -21,7 +21,8 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
import { ErrorInline } from "@/components/layout/error"
import { EmptyStateWithBorder } from "@/components/layout/empty"
import { LoadingStateWithBorder } from "@/components/layout/loading"
-import { FilterSelect, TablePagination, statusConfig } from "@/components/common/general/table-filter"
+import { FilterSelect, TableFilterToolbar, TimeRangeFilter, statusConfig } from "@/components/common/general/table-filter"
+import { DataTableActionCell, DataTableFrame } from "@/components/common/general/table-data"
import { DisputeHistoryTimeline } from "@/components/common/general/dispute-dialog"
import { cn, formatDateTime } from "@/lib/utils"
@@ -34,9 +35,11 @@ const ADMIN_TYPE_CONFIG: Record
const TRANSFER_STATUS_LABEL: Record = {
@@ -46,7 +49,7 @@ const TRANSFER_STATUS_LABEL: Record = {
const DISPUTE_STATUS_LABEL: Record = {
disputing: "处理中",
- refund: "已退款",
+ refund: "已退回",
closed: "已关闭",
}
@@ -68,10 +71,17 @@ const EMPTY_SEARCH: SearchValues = {
payee_username: "",
}
-function toISODateTime(value: string, endOfDay = false) {
- if (!value) return undefined
- const time = endOfDay ? "23:59:59" : "00:00:00"
- return new Date(`${ value }T${ time }`).toISOString()
+function startTimeFromRange(range: { from: Date; to: Date } | null) {
+ return range?.from.toISOString()
+}
+
+function endTimeFromRange(range: { from: Date; to: Date } | null, quickSelection: string | null) {
+ if (!range) return undefined
+ if (quickSelection) return range.to.toISOString()
+
+ const end = new Date(range.to)
+ end.setDate(end.getDate() + 1)
+ return end.toISOString()
}
function isRefundable(order: AdminOrder) {
@@ -92,12 +102,12 @@ export function OrdersManager() {
const [total, setTotal] = React.useState(0)
const [page, setPage] = React.useState(1)
const [pageSize, setPageSize] = React.useState(20)
- const [selectedTypes, setSelectedTypes] = React.useState([])
+ const [selectedTypes, setSelectedTypes] = React.useState(DEFAULT_SELECTED_TYPES)
const [selectedStatuses, setSelectedStatuses] = React.useState([])
const [searchValues, setSearchValues] = React.useState(EMPTY_SEARCH)
const [draftSearchValues, setDraftSearchValues] = React.useState(EMPTY_SEARCH)
- const [startDate, setStartDate] = React.useState("")
- const [endDate, setEndDate] = React.useState("")
+ const [selectedQuickSelection, setSelectedQuickSelection] = React.useState(null)
+ const [selectedTimeRange, setSelectedTimeRange] = React.useState<{ from: Date; to: Date } | null>(null)
const [loading, setLoading] = React.useState(false)
const [error, setError] = React.useState(null)
const [selectedOrder, setSelectedOrder] = React.useState(null)
@@ -108,22 +118,22 @@ export function OrdersManager() {
const totalPages = Math.max(1, Math.ceil(total / pageSize))
const hasSearchValues = Object.values(searchValues).some(Boolean)
- const hasDateFilter = Boolean(startDate || endDate)
+ const hasDateFilter = Boolean(selectedQuickSelection || selectedTimeRange)
const buildRequest = React.useCallback((targetPage: number): ListAdminOrdersRequest => ({
page: targetPage,
page_size: pageSize,
types: selectedTypes.length ? selectedTypes : undefined,
statuses: selectedStatuses.length ? selectedStatuses : undefined,
- start_time: toISODateTime(startDate),
- end_time: toISODateTime(endDate, true),
+ start_time: startTimeFromRange(selectedTimeRange),
+ end_time: endTimeFromRange(selectedTimeRange, selectedQuickSelection),
id: searchValues.id || undefined,
order_name: searchValues.order_name || undefined,
client_id: searchValues.client_id || undefined,
merchant_order_no: searchValues.merchant_order_no || undefined,
payer_username: searchValues.payer_username || undefined,
payee_username: searchValues.payee_username || undefined,
- }), [endDate, pageSize, searchValues, selectedStatuses, selectedTypes, startDate])
+ }), [pageSize, searchValues, selectedQuickSelection, selectedStatuses, selectedTimeRange, selectedTypes])
const fetchOrders = React.useCallback(async (targetPage: number) => {
try {
@@ -155,8 +165,8 @@ export function OrdersManager() {
setSelectedStatuses([])
setSearchValues(EMPTY_SEARCH)
setDraftSearchValues(EMPTY_SEARCH)
- setStartDate("")
- setEndDate("")
+ setSelectedQuickSelection(null)
+ setSelectedTimeRange(null)
setPage(1)
}
@@ -168,14 +178,14 @@ export function OrdersManager() {
await AdminService.refundOrder(refundOrder.id, {
remark: refundRemark.trim() || undefined,
})
- toast.success("退款成功", {
- description: `订单 ${ refundOrder.order_no } 已退款`,
+ toast.success("退回成功", {
+ description: `订单 ${ refundOrder.order_no } 已退回`,
})
setRefundOrder(null)
setRefundRemark("")
await fetchOrders(page)
} catch (err) {
- toast.error("退款失败", {
+ toast.error("退回失败", {
description: err instanceof Error ? err.message : "未知错误",
})
} finally {
@@ -191,84 +201,67 @@ export function OrdersManager() {
订单管理
-
-
- {
- setDraftSearchValues(EMPTY_SEARCH)
- setSearchValues(EMPTY_SEARCH)
- setPage(1)
- }}
- />
-
- label="类型"
- selectedValues={selectedTypes}
- options={ADMIN_TYPE_CONFIG}
- onToggleValue={(type) => {
- setPage(1)
- setSelectedTypes(prev => prev.includes(type) ? prev.filter(item => item !== type) : [...prev, type])
- }}
- />
-
- label="状态"
- selectedValues={selectedStatuses}
- options={ADMIN_STATUS_CONFIG}
- onToggleValue={(status) => {
- setPage(1)
- setSelectedStatuses(prev => prev.includes(status) ? prev.filter(item => item !== status) : [...prev, status])
- }}
- />
- {
- setStartDate(value)
- setPage(1)
- }}
- onEndChange={(value) => {
- setEndDate(value)
- setPage(1)
- }}
- />
- {activeFilter && (
- <>
-
-
- >
- )}
-
-
-
-
-
{
- setPage(targetPage)
- fetchOrders(targetPage)
+ {
+ setPage(targetPage)
+ fetchOrders(targetPage)
+ }}
+ onPageSizeChange={(size) => {
+ setPageSize(size)
+ setPage(1)
+ }}
+ onRefresh={() => fetchOrders(page)}
+ loading={loading}
+ >
+ {
+ setDraftSearchValues(EMPTY_SEARCH)
+ setSearchValues(EMPTY_SEARCH)
+ setPage(1)
+ }}
+ />
+
+ label="类型"
+ selectedValues={selectedTypes}
+ options={ADMIN_TYPE_CONFIG}
+ onToggleValue={(type) => {
+ setPage(1)
+ setSelectedTypes(prev => prev.includes(type) ? prev.filter(item => item !== type) : [...prev, type])
}}
- onPageSizeChange={(size) => {
- setPageSize(size)
+ />
+
+ label="状态"
+ selectedValues={selectedStatuses}
+ options={ADMIN_STATUS_CONFIG}
+ onToggleValue={(status) => {
setPage(1)
+ setSelectedStatuses(prev => prev.includes(status) ? prev.filter(item => item !== status) : [...prev, status])
}}
- onRefresh={() => fetchOrders(page)}
- loading={loading}
/>
-
+ {
+ setSelectedTimeRange(range)
+ setPage(1)
+ }}
+ onQuickSelectionChange={(selection) => {
+ setSelectedQuickSelection(selection)
+ setPage(1)
+ }}
+ />
+
{error ? (
@@ -368,47 +361,6 @@ function SearchInput({ label, value, onChange }: { label: string; value: string;
)
}
-function DateFilter({
- startDate,
- endDate,
- onStartChange,
- onEndChange,
-}: {
- startDate: string
- endDate: string
- onStartChange: (value: string) => void
- onEndChange: (value: string) => void
-}) {
- const active = Boolean(startDate || endDate)
-
- return (
-
-
-
-
-
-
-
-
- )
-}
-
function OrdersTable({
orders,
loading,
@@ -423,25 +375,23 @@ function OrdersTable({
onRefund: (order: AdminOrder) => void
}) {
return (
-
-
-
-
+
+
- 名称
- 积分
- 类型
- 状态
- 积分动向
- 应用名
- 编号
- 业务单号
- 结算
- 创建时间
- 操作
+ 名称
+ 积分
+ 类型
+ 状态
+ 积分动向
+ 应用名
+ 编号
+ 业务单号
+ 结算
+ 创建时间
+ 操作
-
+
{orders.map(order => {
const typeMeta = ADMIN_TYPE_CONFIG[order.type]
const statusMeta = ADMIN_STATUS_CONFIG[order.status]
@@ -454,15 +404,15 @@ function OrdersTable({
isDisputing && "bg-yellow-50 dark:bg-yellow-900/20 hover:bg-yellow-100/50 dark:hover:bg-yellow-900/30"
)}
>
- {order.order_name}
- {displayAmount(order.amount)}
-
+ {order.order_name}
+ {displayAmount(order.amount)}
+
{typeMeta.label}
-
+
{statusMeta.label}
-
+
@@ -483,20 +433,13 @@ function OrdersTable({
"-"
)}
- {order.order_no}
- {order.merchant_order_no || "-"}
- {TRANSFER_STATUS_LABEL[order.payee_transfer_status] || "-"}
- {formatDateTime(order.created_at)}
-
-
+
)
})}
-
-
-
+
)
}
@@ -572,49 +513,59 @@ function OrderFlow({ order }: { order: AdminOrder }) {
function OrderDetailSheet({ order, onOpenChange }: { order: AdminOrder | null; onOpenChange: (open: boolean) => void }) {
return (
-
-
+
+
订单详情
- {order?.order_no}
+ {order?.order_no}
{order && (
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
)}
@@ -622,11 +573,20 @@ function OrderDetailSheet({ order, onOpenChange }: { order: AdminOrder | null; o
)
}
+function DetailSection({ title, children }: { title: string; children: React.ReactNode }) {
+ return (
+
+
{title}
+ {children}
+
+ )
+}
+
function DetailGroup({ rows }: { rows: Array<[string, string]> }) {
return (
-
+
{rows.map(([label, value]) => (
-
+
{label}
{value}
@@ -647,13 +607,13 @@ function AdminDisputeDialog({ order, onOpenChange }: { order: AdminOrder | null;
return (