第 5 阶段:纠纷、通知与后台-4
This commit is contained in:
@@ -29,7 +29,7 @@ npm run dev
|
|||||||
- 短信验证码使用 mock 适配器,验证码会打印在后端日志中。
|
- 短信验证码使用 mock 适配器,验证码会打印在后端日志中。
|
||||||
- 实名认证使用 mock 适配器,登录后请求 `POST /api/realname/start`,提交合法姓名和 18 位身份证号会直接通过。
|
- 实名认证使用 mock 适配器,登录后请求 `POST /api/realname/start`,提交合法姓名和 18 位身份证号会直接通过。
|
||||||
- 实名状态可通过 `GET /api/realname/status` 查询。
|
- 实名状态可通过 `GET /api/realname/status` 查询。
|
||||||
- 租号发布需要登录并完成实名认证;开发态 `POST /api/listings/{id}/submit-review` 会自动审核通过并上架。
|
- 租号发布需要登录并完成实名认证;`POST /api/listings/{id}/submit-review` 会进入待审核,后台通过后才上架。
|
||||||
- 订单创建需要登录;开发态会直接锁定账号并进入待交接,暂不接真实支付和押金冻结。
|
- 订单创建需要登录;开发态会直接锁定账号并进入待交接,暂不接真实支付和押金冻结。
|
||||||
- 账号交接已支持号主提交说明、租客确认收号,确认后订单进入租赁中。
|
- 账号交接已支持号主提交说明、租客确认收号,确认后订单进入租赁中。
|
||||||
- 归还流程已支持租客提交归还、号主确认归还,完成后账号重新上架。
|
- 归还流程已支持租客提交归还、号主确认归还,完成后账号重新上架。
|
||||||
@@ -39,6 +39,7 @@ npm run dev
|
|||||||
- 系统配置已支持默认配置初始化和后台编辑,页面为 `http://localhost:5173/admin/system-configs`,更新会写入审计日志。
|
- 系统配置已支持默认配置初始化和后台编辑,页面为 `http://localhost:5173/admin/system-configs`,更新会写入审计日志。
|
||||||
- 后台已使用独立登录,页面为 `http://localhost:5173/admin/login`;开发态默认管理员为 `admin / admin123456`。
|
- 后台已使用独立登录,页面为 `http://localhost:5173/admin/login`;开发态默认管理员为 `admin / admin123456`。
|
||||||
- 后台仪表盘已接入真实统计数据,页面为 `http://localhost:5173/admin/dashboard`。
|
- 后台仪表盘已接入真实统计数据,页面为 `http://localhost:5173/admin/dashboard`。
|
||||||
|
- 商品审核后台已接入,页面为 `http://localhost:5173/admin/listings/review`。
|
||||||
|
|
||||||
## 文档
|
## 文档
|
||||||
|
|
||||||
|
|||||||
@@ -43,3 +43,7 @@ type CreateRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UpdateRequest = CreateRequest
|
type UpdateRequest = CreateRequest
|
||||||
|
|
||||||
|
type ReviewRequest struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -79,6 +79,46 @@ func (h *Handler) SubmitReview(c *gin.Context) {
|
|||||||
response.OK(c, item)
|
response.OK(c, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListPendingReview(c *gin.Context) {
|
||||||
|
items, err := h.service.ListPendingReview()
|
||||||
|
if err != nil {
|
||||||
|
writeListingError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Approve(c *gin.Context) {
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := h.service.Approve(id)
|
||||||
|
if err != nil {
|
||||||
|
writeListingError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Reject(c *gin.Context) {
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req ReviewRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "审核拒绝原因不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := h.service.Reject(id, req)
|
||||||
|
if err != nil {
|
||||||
|
writeListingError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, item)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) Offline(c *gin.Context) {
|
func (h *Handler) Offline(c *gin.Context) {
|
||||||
ownerID, ok := currentUserID(c)
|
ownerID, ok := currentUserID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
|
"hfb_sys/backend/internal/modules/notification"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Repository struct {
|
type Repository struct {
|
||||||
@@ -102,6 +104,49 @@ func (r *Repository) SubmitReview(ownerID uint64, listingID uint64) (*ListingDTO
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if listing.Status == "rented" {
|
||||||
|
return ErrListingLocked
|
||||||
|
}
|
||||||
|
listing.Status = "draft"
|
||||||
|
listing.ReviewStatus = "pending"
|
||||||
|
listing.ReviewReason = ""
|
||||||
|
listing.PublishedAt = nil
|
||||||
|
account.Status = "draft"
|
||||||
|
if err := tx.Save(account).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Save(listing).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dto = toDTO(*account, *listing)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return dto, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) ListPendingReview() ([]ListingDTO, error) {
|
||||||
|
var rows []listingRow
|
||||||
|
err := r.baseQuery().
|
||||||
|
Where("l.review_status = ?", "pending").
|
||||||
|
Order("l.updated_at ASC, l.id ASC").
|
||||||
|
Limit(200).
|
||||||
|
Scan(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rowsToDTO(rows), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) Approve(listingID uint64) (*ListingDTO, error) {
|
||||||
|
var dto *ListingDTO
|
||||||
|
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
listing, account, err := r.findForReviewUpdate(tx, listingID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if listing.Status == "rented" {
|
||||||
|
return ErrListingLocked
|
||||||
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
listing.Status = "published"
|
listing.Status = "published"
|
||||||
listing.ReviewStatus = "approved"
|
listing.ReviewStatus = "approved"
|
||||||
@@ -114,6 +159,55 @@ func (r *Repository) SubmitReview(ownerID uint64, listingID uint64) (*ListingDTO
|
|||||||
if err := tx.Save(listing).Error; err != nil {
|
if err := tx.Save(listing).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
listingID := listing.ID
|
||||||
|
if err := notification.Append(tx, notification.Entry{
|
||||||
|
UserID: listing.OwnerID,
|
||||||
|
Type: "listing_review",
|
||||||
|
Title: "发布审核通过",
|
||||||
|
Content: "你的租号发布已审核通过并上架。",
|
||||||
|
BizType: "listing",
|
||||||
|
BizID: &listingID,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dto = toDTO(*account, *listing)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return dto, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) Reject(listingID uint64, req ReviewRequest) (*ListingDTO, error) {
|
||||||
|
var dto *ListingDTO
|
||||||
|
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
listing, account, err := r.findForReviewUpdate(tx, listingID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if listing.Status == "rented" {
|
||||||
|
return ErrListingLocked
|
||||||
|
}
|
||||||
|
listing.Status = "draft"
|
||||||
|
listing.ReviewStatus = "rejected"
|
||||||
|
listing.ReviewReason = req.Reason
|
||||||
|
listing.PublishedAt = nil
|
||||||
|
account.Status = "draft"
|
||||||
|
if err := tx.Save(account).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Save(listing).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
listingID := listing.ID
|
||||||
|
if err := notification.Append(tx, notification.Entry{
|
||||||
|
UserID: listing.OwnerID,
|
||||||
|
Type: "listing_review",
|
||||||
|
Title: "发布审核未通过",
|
||||||
|
Content: "你的租号发布未通过审核,请根据原因修改后重新提交。",
|
||||||
|
BizType: "listing",
|
||||||
|
BizID: &listingID,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
dto = toDTO(*account, *listing)
|
dto = toDTO(*account, *listing)
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -140,9 +234,7 @@ func (r *Repository) Offline(ownerID uint64, listingID uint64) error {
|
|||||||
|
|
||||||
func (r *Repository) ListPublic() ([]ListingDTO, error) {
|
func (r *Repository) ListPublic() ([]ListingDTO, error) {
|
||||||
var rows []listingRow
|
var rows []listingRow
|
||||||
err := r.db.Table("rental_listings AS l").
|
err := r.baseQuery().
|
||||||
Select("l.*, a.title, a.description, a.game_name, a.server_region, a.login_platform, a.rank_level, a.haf_coin_amount").
|
|
||||||
Joins("JOIN game_accounts AS a ON a.id = l.account_id").
|
|
||||||
Where("l.status = ? AND l.review_status = ?", "published", "approved").
|
Where("l.status = ? AND l.review_status = ?", "published", "approved").
|
||||||
Order("l.published_at DESC, l.id DESC").
|
Order("l.published_at DESC, l.id DESC").
|
||||||
Limit(100).
|
Limit(100).
|
||||||
@@ -155,9 +247,7 @@ func (r *Repository) ListPublic() ([]ListingDTO, error) {
|
|||||||
|
|
||||||
func (r *Repository) ListMine(ownerID uint64) ([]ListingDTO, error) {
|
func (r *Repository) ListMine(ownerID uint64) ([]ListingDTO, error) {
|
||||||
var rows []listingRow
|
var rows []listingRow
|
||||||
err := r.db.Table("rental_listings AS l").
|
err := r.baseQuery().
|
||||||
Select("l.*, a.title, a.description, a.game_name, a.server_region, a.login_platform, a.rank_level, a.haf_coin_amount").
|
|
||||||
Joins("JOIN game_accounts AS a ON a.id = l.account_id").
|
|
||||||
Where("l.owner_id = ?", ownerID).
|
Where("l.owner_id = ?", ownerID).
|
||||||
Order("l.id DESC").
|
Order("l.id DESC").
|
||||||
Scan(&rows).Error
|
Scan(&rows).Error
|
||||||
@@ -189,9 +279,7 @@ func (r *Repository) findOwnedForUpdate(tx *gorm.DB, ownerID uint64, listingID u
|
|||||||
|
|
||||||
func (r *Repository) findDTO(where string, args ...any) (*ListingDTO, error) {
|
func (r *Repository) findDTO(where string, args ...any) (*ListingDTO, error) {
|
||||||
var row listingRow
|
var row listingRow
|
||||||
err := r.db.Table("rental_listings AS l").
|
err := r.baseQuery().
|
||||||
Select("l.*, a.title, a.description, a.game_name, a.server_region, a.login_platform, a.rank_level, a.haf_coin_amount").
|
|
||||||
Joins("JOIN game_accounts AS a ON a.id = l.account_id").
|
|
||||||
Where(where, args...).
|
Where(where, args...).
|
||||||
First(&row).Error
|
First(&row).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -201,6 +289,24 @@ func (r *Repository) findDTO(where string, args ...any) (*ListingDTO, error) {
|
|||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) baseQuery() *gorm.DB {
|
||||||
|
return r.db.Table("rental_listings AS l").
|
||||||
|
Select("l.*, a.title, a.description, a.game_name, a.server_region, a.login_platform, a.rank_level, a.haf_coin_amount").
|
||||||
|
Joins("JOIN game_accounts AS a ON a.id = l.account_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) findForReviewUpdate(tx *gorm.DB, listingID uint64) (*model.RentalListing, *model.GameAccount, error) {
|
||||||
|
var listing model.RentalListing
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, listingID).Error; err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
var account model.GameAccount
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, listing.AccountID).Error; err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return &listing, &account, nil
|
||||||
|
}
|
||||||
|
|
||||||
type listingRow struct {
|
type listingRow struct {
|
||||||
model.RentalListing
|
model.RentalListing
|
||||||
Title string
|
Title string
|
||||||
|
|||||||
@@ -45,6 +45,30 @@ func (s *Service) SubmitReview(ownerID uint64, id uint64) (*ListingDTO, error) {
|
|||||||
return s.repo.SubmitReview(ownerID, id)
|
return s.repo.SubmitReview(ownerID, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListPendingReview() ([]ListingDTO, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
return s.repo.ListPendingReview()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Approve(id uint64) (*ListingDTO, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
return s.repo.Approve(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Reject(id uint64, req ReviewRequest) (*ListingDTO, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
if req.Reason == "" {
|
||||||
|
return nil, ErrInvalidInput
|
||||||
|
}
|
||||||
|
return s.repo.Reject(id, req)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) Offline(ownerID uint64, id uint64) error {
|
func (s *Service) Offline(ownerID uint64, id uint64) error {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return ErrDependencyUnavailable
|
return ErrDependencyUnavailable
|
||||||
|
|||||||
@@ -176,6 +176,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
adminRoutes.GET("/me", adminAuthHandler.Me)
|
adminRoutes.GET("/me", adminAuthHandler.Me)
|
||||||
adminRoutes.POST("/auth/logout", adminAuthHandler.Logout)
|
adminRoutes.POST("/auth/logout", adminAuthHandler.Logout)
|
||||||
adminRoutes.GET("/dashboard", adminDashboardHandler.Summary)
|
adminRoutes.GET("/dashboard", adminDashboardHandler.Summary)
|
||||||
|
adminRoutes.GET("/listings/pending", listingHandler.ListPendingReview)
|
||||||
|
adminRoutes.POST("/listings/:id/approve", listingHandler.Approve)
|
||||||
|
adminRoutes.POST("/listings/:id/reject", listingHandler.Reject)
|
||||||
adminRoutes.GET("/disputes", disputeHandler.AdminList)
|
adminRoutes.GET("/disputes", disputeHandler.AdminList)
|
||||||
adminRoutes.POST("/disputes/:id/arbitrate", disputeHandler.AdminArbitrate)
|
adminRoutes.POST("/disputes/:id/arbitrate", disputeHandler.AdminArbitrate)
|
||||||
adminRoutes.GET("/system-configs", systemConfigHandler.List)
|
adminRoutes.GET("/system-configs", systemConfigHandler.List)
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ API 规划以 [项目计划](project-plan.md) 第 10 章为准。
|
|||||||
- `POST /api/admin/auth/logout`
|
- `POST /api/admin/auth/logout`
|
||||||
- `GET /api/admin/me`
|
- `GET /api/admin/me`
|
||||||
- `GET /api/admin/dashboard`
|
- `GET /api/admin/dashboard`
|
||||||
|
- `GET /api/admin/listings/pending`
|
||||||
|
- `POST /api/admin/listings/{id}/approve`
|
||||||
|
- `POST /api/admin/listings/{id}/reject`
|
||||||
- `GET /api/admin/disputes`
|
- `GET /api/admin/disputes`
|
||||||
- `POST /api/admin/disputes/{id}/arbitrate`
|
- `POST /api/admin/disputes/{id}/arbitrate`
|
||||||
- `GET /api/admin/system-configs`
|
- `GET /api/admin/system-configs`
|
||||||
|
|||||||
@@ -24,7 +24,9 @@
|
|||||||
- 发布租号前必须登录并完成实名认证。
|
- 发布租号前必须登录并完成实名认证。
|
||||||
- 一期创建发布时同时创建 `game_accounts` 和 `rental_listings`。
|
- 一期创建发布时同时创建 `game_accounts` 和 `rental_listings`。
|
||||||
- 哈夫币数量是号主手动填报值,不代表实时值。
|
- 哈夫币数量是号主手动填报值,不代表实时值。
|
||||||
- 当前提交审核会 mock 为自动通过并上架,后续再接后台人工审核。
|
- 当前提交审核会进入 `review_status = pending`,由后台商品审核通过后才上架。
|
||||||
|
- 后台审核通过后,发布状态变为 `published`,审核状态变为 `approved`,账号状态变为 `published`。
|
||||||
|
- 后台审核拒绝后,发布保留为草稿,审核状态变为 `rejected`,拒绝原因写入 `review_reason`。
|
||||||
- 公开列表只展示 `status = published` 且 `review_status = approved` 的发布。
|
- 公开列表只展示 `status = published` 且 `review_status = approved` 的发布。
|
||||||
|
|
||||||
## 开发态订单创建
|
## 开发态订单创建
|
||||||
|
|||||||
@@ -75,3 +75,18 @@ export async function offlineListing(id: number) {
|
|||||||
const { data } = await apiClient.delete<ApiResponse<{ offline: boolean }>>(`/listings/${id}`)
|
const { data } = await apiClient.delete<ApiResponse<{ offline: boolean }>>(`/listings/${id}`)
|
||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchPendingReviewListings() {
|
||||||
|
const { data } = await apiClient.get<ApiResponse<{ items: Listing[] }>>('/admin/listings/pending')
|
||||||
|
return data.data.items
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approveListing(id: number) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/approve`)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rejectListing(id: number, reason: string) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/reject`, { reason })
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Bell, House, Key, Operation, Phone, ScaleToOriginal, Shop, Tickets, UserFilled, Wallet } from '@element-plus/icons-vue'
|
import { Bell, House, Key, Operation, Phone, ScaleToOriginal, Shop, Tickets, UserFilled, Wallet, View } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ label: '首页', to: '/', icon: House },
|
{ label: '首页', to: '/', icon: House },
|
||||||
@@ -9,6 +9,7 @@ const navItems = [
|
|||||||
{ label: '通知', to: '/notifications', icon: Bell },
|
{ label: '通知', to: '/notifications', icon: Bell },
|
||||||
{ label: '实名', to: '/realname', icon: UserFilled },
|
{ label: '实名', to: '/realname', icon: UserFilled },
|
||||||
{ label: '后台登录', to: '/admin/login', icon: Key },
|
{ label: '后台登录', to: '/admin/login', icon: Key },
|
||||||
|
{ label: '审核', to: '/admin/listings/review', icon: View },
|
||||||
{ label: '仲裁', to: '/admin/disputes', icon: ScaleToOriginal },
|
{ label: '仲裁', to: '/admin/disputes', icon: ScaleToOriginal },
|
||||||
{ label: '配置', to: '/admin/system-configs', icon: Operation },
|
{ label: '配置', to: '/admin/system-configs', icon: Operation },
|
||||||
{ label: '登录', to: '/login', icon: Phone },
|
{ label: '登录', to: '/login', icon: Phone },
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const router = createRouter({
|
|||||||
{ path: '/seller/earnings', name: 'seller-earnings', component: () => import('@/views/seller/SellerEarningsView.vue') },
|
{ path: '/seller/earnings', name: 'seller-earnings', component: () => import('@/views/seller/SellerEarningsView.vue') },
|
||||||
{ path: '/admin/login', name: 'admin-login', component: () => import('@/views/admin/AdminLoginView.vue') },
|
{ path: '/admin/login', name: 'admin-login', component: () => import('@/views/admin/AdminLoginView.vue') },
|
||||||
{ path: '/admin/dashboard', name: 'admin-dashboard', component: () => import('@/views/admin/AdminDashboardView.vue') },
|
{ path: '/admin/dashboard', name: 'admin-dashboard', component: () => import('@/views/admin/AdminDashboardView.vue') },
|
||||||
|
{ path: '/admin/listings/review', name: 'admin-listing-review', component: () => import('@/views/admin/AdminListingReviewView.vue') },
|
||||||
{ path: '/admin/disputes', name: 'admin-disputes', component: () => import('@/views/admin/AdminDisputesView.vue') },
|
{ path: '/admin/disputes', name: 'admin-disputes', component: () => import('@/views/admin/AdminDisputesView.vue') },
|
||||||
{ path: '/admin/system-configs', name: 'admin-system-configs', component: () => import('@/views/admin/AdminSystemConfigsView.vue') },
|
{ path: '/admin/system-configs', name: 'admin-system-configs', component: () => import('@/views/admin/AdminSystemConfigsView.vue') },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -75,10 +75,10 @@ function money(value?: number) {
|
|||||||
<span>待仲裁申诉</span>
|
<span>待仲裁申诉</span>
|
||||||
<strong>{{ dashboard.pending.disputes }}</strong>
|
<strong>{{ dashboard.pending.disputes }}</strong>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<div class="pending-row">
|
<RouterLink class="pending-row" to="/admin/listings/review">
|
||||||
<span>待审核商品</span>
|
<span>待审核商品</span>
|
||||||
<strong>{{ dashboard.pending.listing_reviews }}</strong>
|
<strong>{{ dashboard.pending.listing_reviews }}</strong>
|
||||||
</div>
|
</RouterLink>
|
||||||
<div class="pending-row">
|
<div class="pending-row">
|
||||||
<span>待交接订单</span>
|
<span>待交接订单</span>
|
||||||
<strong>{{ dashboard.pending.pending_handoffs }}</strong>
|
<strong>{{ dashboard.pending.pending_handoffs }}</strong>
|
||||||
@@ -95,6 +95,10 @@ function money(value?: number) {
|
|||||||
<span>仲裁中心</span>
|
<span>仲裁中心</span>
|
||||||
<strong>进入</strong>
|
<strong>进入</strong>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<RouterLink class="pending-row" to="/admin/listings/review">
|
||||||
|
<span>商品审核</span>
|
||||||
|
<strong>进入</strong>
|
||||||
|
</RouterLink>
|
||||||
<RouterLink class="pending-row" to="/admin/system-configs">
|
<RouterLink class="pending-row" to="/admin/system-configs">
|
||||||
<span>系统配置</span>
|
<span>系统配置</span>
|
||||||
<strong>进入</strong>
|
<strong>进入</strong>
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import { approveListing, fetchPendingReviewListings, rejectListing, type Listing } from '@/api/listings'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const listings = ref<Listing[]>([])
|
||||||
|
const activeListing = ref<Listing | null>(null)
|
||||||
|
const rejectReason = ref('')
|
||||||
|
|
||||||
|
onMounted(loadListings)
|
||||||
|
|
||||||
|
async function loadListings() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
listings.value = await fetchPendingReviewListings()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleApprove(row: Listing) {
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await approveListing(row.id)
|
||||||
|
ElMessage.success('审核通过,商品已上架')
|
||||||
|
await loadListings()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '审核失败'))
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openReject(row: Listing) {
|
||||||
|
activeListing.value = row
|
||||||
|
rejectReason.value = row.review_reason || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReject() {
|
||||||
|
if (!activeListing.value) return
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await rejectListing(activeListing.value.id, rejectReason.value)
|
||||||
|
ElMessage.success('已拒绝发布并通知号主')
|
||||||
|
activeListing.value = null
|
||||||
|
rejectReason.value = ''
|
||||||
|
await loadListings()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '拒绝失败'))
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readError(error: unknown, fallback: string) {
|
||||||
|
if (typeof error === 'object' && error && 'response' in error) {
|
||||||
|
const response = (error as { response?: { data?: { message?: string } } }).response
|
||||||
|
return response?.data?.message || fallback
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page">
|
||||||
|
<div class="page-header-row">
|
||||||
|
<div class="page-header">
|
||||||
|
<p class="eyebrow">Review</p>
|
||||||
|
<h1>商品审核</h1>
|
||||||
|
<p>审核号主提交的账号资产、价格、押金和租期规则。</p>
|
||||||
|
</div>
|
||||||
|
<el-button @click="loadListings">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table v-loading="loading" class="table-panel" :data="listings">
|
||||||
|
<el-table-column prop="title" label="标题" min-width="180" />
|
||||||
|
<el-table-column prop="owner_id" label="号主" width="90" />
|
||||||
|
<el-table-column prop="server_region" label="区服" width="120" />
|
||||||
|
<el-table-column prop="login_platform" label="平台" width="120" />
|
||||||
|
<el-table-column prop="haf_coin_amount" label="哈夫币" width="110" />
|
||||||
|
<el-table-column prop="rank_level" label="段位" width="110" />
|
||||||
|
<el-table-column prop="price_hourly" label="时租" width="90" />
|
||||||
|
<el-table-column prop="deposit_amount" label="押金" width="90" />
|
||||||
|
<el-table-column prop="updated_at" label="提交时间" min-width="180" />
|
||||||
|
<el-table-column label="操作" width="170" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" type="primary" :loading="submitting" @click="handleApprove(row)">通过</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="openReject(row)">拒绝</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-dialog :model-value="!!activeListing" title="拒绝发布" width="560px" @update:model-value="activeListing = null">
|
||||||
|
<div v-if="activeListing" class="dialog-body">
|
||||||
|
<p><strong>{{ activeListing.title }}</strong></p>
|
||||||
|
<el-input v-model="rejectReason" type="textarea" :rows="4" placeholder="填写拒绝原因,号主会在通知中看到审核结果" />
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="activeListing = null">取消</el-button>
|
||||||
|
<el-button type="danger" :loading="submitting" @click="handleReject">确认拒绝</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -20,7 +20,7 @@ async function loadListings() {
|
|||||||
|
|
||||||
async function submitReview(id: number) {
|
async function submitReview(id: number) {
|
||||||
await submitListingReview(id)
|
await submitListingReview(id)
|
||||||
ElMessage.success('已提交审核并自动上架')
|
ElMessage.success('已提交审核,等待后台处理')
|
||||||
await loadListings()
|
await loadListings()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,10 +52,13 @@ async function offline(id: number) {
|
|||||||
<el-table-column prop="deposit_amount" label="押金" width="100" />
|
<el-table-column prop="deposit_amount" label="押金" width="100" />
|
||||||
<el-table-column prop="status" label="状态" width="110" />
|
<el-table-column prop="status" label="状态" width="110" />
|
||||||
<el-table-column prop="review_status" label="审核" width="110" />
|
<el-table-column prop="review_status" label="审核" width="110" />
|
||||||
<el-table-column label="操作" width="180">
|
<el-table-column prop="review_reason" label="审核原因" min-width="160" show-overflow-tooltip />
|
||||||
|
<el-table-column label="操作" width="190">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button size="small" @click="submitReview(row.id)">上架</el-button>
|
<el-button size="small" :disabled="row.review_status === 'pending' || row.status === 'rented'" @click="submitReview(row.id)">
|
||||||
<el-button size="small" type="danger" @click="offline(row.id)">下架</el-button>
|
提审
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" type="danger" :disabled="row.status === 'rented'" @click="offline(row.id)">下架</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|||||||
Reference in New Issue
Block a user