feat: add admin risk actions and arbitration settlement

This commit is contained in:
yml
2026-05-22 18:11:01 +08:00
parent dfe686ce0e
commit 55477d0ac6
26 changed files with 888 additions and 23 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ export async function fetchAdminDisputes() {
return data.data.items
}
export async function arbitrateDispute(id: number, payload: { result: string; remark: string }) {
export async function arbitrateDispute(id: number, payload: { result: string; remark: string; amount?: number }) {
const { data } = await apiClient.post<ApiResponse<Dispute>>(`/admin/disputes/${id}/arbitrate`, payload)
return data.data
}
+15
View File
@@ -96,6 +96,21 @@ export async function fetchAdminListings(query: AdminListingQuery = {}) {
return data.data.items
}
export async function fetchAdminListing(id: string | number) {
const { data } = await apiClient.get<ApiResponse<Listing>>(`/admin/listings/${id}`)
return data.data
}
export async function adminOfflineListing(id: number, reason: string) {
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/offline`, { reason })
return data.data
}
export async function adminMarkListingAbnormal(id: number, reason: string) {
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/mark-abnormal`, { reason })
return data.data
}
export async function approveListing(id: number) {
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/approve`)
return data.data
+10
View File
@@ -92,6 +92,16 @@ export async function fetchAdminHandoffRecords(id: string | number) {
return data.data.items
}
export async function adminCloseOrder(id: number, reason: string) {
const { data } = await apiClient.post<ApiResponse<{ closed: boolean }>>(`/admin/orders/${id}/close`, { reason })
return data.data
}
export async function adminMarkOrderAbnormal(id: number, reason: string) {
const { data } = await apiClient.post<ApiResponse<{ abnormal: boolean }>>(`/admin/orders/${id}/mark-abnormal`, { reason })
return data.data
}
export async function confirmReceive(id: number) {
const { data } = await apiClient.post<ApiResponse<{ received: boolean }>>(`/orders/${id}/confirm-receive`)
return data.data
+6
View File
@@ -49,6 +49,12 @@ const router = createRouter({
component: () => import('@/views/admin/AdminListingsView.vue'),
meta: { layout: 'admin', requiresAdmin: true },
},
{
path: '/admin/listings/:id',
name: 'admin-listing-detail',
component: () => import('@/views/admin/AdminListingDetailView.vue'),
meta: { layout: 'admin', requiresAdmin: true },
},
{
path: '/admin/listings/review',
name: 'admin-listing-review',
@@ -89,6 +89,11 @@ function actionType(action: string) {
<el-select v-model="filters.action" clearable filterable placeholder="全部动作" class="full-control">
<el-option label="冻结用户" value="admin_user.freeze" />
<el-option label="解冻用户" value="admin_user.unfreeze" />
<el-option label="后台下架商品" value="listing.admin_offline" />
<el-option label="商品标记异常" value="listing.mark_abnormal" />
<el-option label="客服关闭订单" value="order.admin_close" />
<el-option label="订单标记异常" value="order.mark_abnormal" />
<el-option label="申诉仲裁" value="dispute.arbitrate" />
<el-option label="更新系统配置" value="system_config.update" />
<el-option label="创建系统配置" value="system_config.create" />
</el-select>
@@ -96,6 +101,9 @@ function actionType(action: string) {
<el-form-item label="业务类型">
<el-select v-model="filters.biz_type" clearable placeholder="全部业务" class="full-control">
<el-option label="用户" value="user" />
<el-option label="商品" value="listing" />
<el-option label="订单" value="order" />
<el-option label="申诉" value="dispute" />
<el-option label="系统配置" value="system_config" />
</el-select>
</el-form-item>
@@ -10,6 +10,7 @@ const disputes = ref<Dispute[]>([])
const activeDispute = ref<Dispute | null>(null)
const result = ref('release_deposit')
const remark = ref('')
const amount = ref<number | undefined>()
onMounted(loadDisputes)
@@ -26,6 +27,7 @@ function openArbitration(row: Dispute) {
activeDispute.value = row
result.value = row.arbitration_result || 'release_deposit'
remark.value = row.arbitration_remark || ''
amount.value = undefined
}
async function handleArbitrate() {
@@ -35,6 +37,7 @@ async function handleArbitrate() {
await arbitrateDispute(activeDispute.value.id, {
result: result.value,
remark: remark.value,
amount: amount.value,
})
ElMessage.success('仲裁结果已保存,双方已收到通知')
activeDispute.value = null
@@ -90,6 +93,17 @@ function readError(error: unknown, fallback: string) {
<el-option label="赔付号主" value="compensate_owner" />
<el-option label="关闭订单" value="order_close" />
</el-select>
<el-input-number
v-if="['partial_refund', 'deduct_deposit', 'compensate_owner'].includes(result)"
v-model="amount"
class="full-control panel-action"
:min="0"
:precision="2"
:step="10"
placeholder="裁决金额"
/>
<p v-if="result === 'partial_refund'">部分退款金额表示退给租客的金额剩余冻结金额结算给号主</p>
<p v-if="['deduct_deposit', 'compensate_owner'].includes(result)">金额表示从押金中赔付给号主的部分不填则默认处理全额押金</p>
<el-input v-model="remark" class="panel-action" type="textarea" :rows="4" placeholder="填写客服裁决说明" />
</div>
<template #footer>
@@ -0,0 +1,143 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { adminMarkListingAbnormal, adminOfflineListing, fetchAdminListing, type Listing } from '@/api/listings'
const route = useRoute()
const loading = ref(false)
const submitting = ref(false)
const listing = ref<Listing | null>(null)
const actionType = ref<'offline' | 'abnormal' | ''>('')
const reason = ref('')
const actionTitle = computed(() => (actionType.value === 'offline' ? '强制下架商品' : '标记商品异常'))
const canOperate = computed(() => !!listing.value && listing.value.status !== 'rented' && !['offline', 'abnormal'].includes(listing.value.status))
onMounted(loadListing)
async function loadListing() {
loading.value = true
try {
listing.value = await fetchAdminListing(String(route.params.id))
} finally {
loading.value = false
}
}
function openAction(type: 'offline' | 'abnormal') {
actionType.value = type
reason.value = ''
}
async function submitAction() {
if (!listing.value || !actionType.value) return
submitting.value = true
try {
if (actionType.value === 'offline') {
listing.value = await adminOfflineListing(listing.value.id, reason.value)
ElMessage.success('商品已强制下架')
} else {
listing.value = await adminMarkListingAbnormal(listing.value.id, reason.value)
ElMessage.success('商品已标记异常')
}
actionType.value = ''
} catch (error) {
ElMessage.error(readError(error, '操作失败'))
} finally {
submitting.value = false
}
}
function money(value: number) {
return `¥${Number(value || 0).toFixed(2)}`
}
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" v-loading="loading">
<div v-if="listing" class="page-header-row">
<div class="page-header">
<p class="eyebrow">Listing #{{ listing.id }}</p>
<h1>商品详情</h1>
<p>{{ listing.title }} · {{ listing.server_region }} / {{ listing.login_platform }}</p>
</div>
<div class="toolbar-actions">
<RouterLink to="/admin/listings">
<el-button>返回列表</el-button>
</RouterLink>
<el-button type="warning" :disabled="!canOperate" @click="openAction('offline')">强制下架</el-button>
<el-button type="danger" :disabled="!canOperate" @click="openAction('abnormal')">标记异常</el-button>
</div>
</div>
<div v-if="listing" class="metric-grid dashboard-metrics">
<div class="metric-card">
<span>商品状态</span>
<strong>{{ listing.status }}</strong>
</div>
<div class="metric-card">
<span>审核状态</span>
<strong>{{ listing.review_status }}</strong>
</div>
<div class="metric-card">
<span>时租</span>
<strong>{{ money(listing.price_hourly) }}</strong>
</div>
<div class="metric-card">
<span>押金</span>
<strong>{{ money(listing.deposit_amount) }}</strong>
</div>
</div>
<div v-if="listing" class="dashboard-panels">
<div class="order-panel dashboard-panel">
<h2>账号信息</h2>
<p>账号 ID{{ listing.account_id }}</p>
<p>游戏{{ listing.game_name }}</p>
<p>区服{{ listing.server_region }}</p>
<p>平台{{ listing.login_platform }}</p>
<p>段位{{ listing.rank_level || '-' }}</p>
<p>哈夫币{{ listing.haf_coin_amount }}</p>
</div>
<div class="order-panel dashboard-panel">
<h2>号主与租期</h2>
<p>号主{{ listing.owner_phone || listing.owner_nickname || listing.owner_id }}</p>
<p>号主 ID{{ listing.owner_id }}</p>
<p>最短租期{{ listing.min_rent_hours }} 小时</p>
<p>最长租期{{ listing.max_rent_hours }} 小时</p>
<p>日租{{ money(listing.price_daily) }}</p>
<p>周租{{ money(listing.price_weekly) }}</p>
</div>
</div>
<div v-if="listing" class="order-panel dashboard-panel">
<h2>说明与审核原因</h2>
<p>{{ listing.description || '暂无商品说明' }}</p>
<p>审核/后台原因{{ listing.review_reason || '-' }}</p>
<p>上架时间{{ listing.published_at || '-' }}</p>
<p>更新时间{{ listing.updated_at }}</p>
</div>
<el-dialog :model-value="!!actionType" :title="actionTitle" width="560px" @update:model-value="actionType = ''">
<div v-if="listing" class="dialog-body">
<p><strong>{{ listing.title }}</strong></p>
<el-input v-model="reason" type="textarea" :rows="4" placeholder="填写后台操作原因,会写入审计日志并通知号主" />
</div>
<template #footer>
<el-button @click="actionType = ''">取消</el-button>
<el-button type="danger" :loading="submitting" @click="submitAction">确认操作</el-button>
</template>
</el-dialog>
</section>
</template>
@@ -102,6 +102,7 @@ function reviewType(status: string) {
<el-option label="已上架" value="published" />
<el-option label="租赁中" value="rented" />
<el-option label="已下架" value="offline" />
<el-option label="异常" value="abnormal" />
</el-select>
</el-form-item>
<el-form-item label="审核状态">
@@ -155,10 +156,9 @@ function reviewType(status: string) {
<el-table-column prop="updated_at" label="更新时间" min-width="180" />
<el-table-column label="操作" width="110">
<template #default="{ row }">
<RouterLink v-if="row.status === 'published' && row.review_status === 'approved'" :to="`/listings/${row.id}`">
<el-button size="small">前台详情</el-button>
<RouterLink :to="`/admin/listings/${row.id}`">
<el-button size="small">详情</el-button>
</RouterLink>
<span v-else>-</span>
</template>
</el-table-column>
</el-table>
@@ -1,15 +1,21 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { fetchAdminHandoffRecords, fetchAdminOrder, type HandoffRecord, type Order } from '@/api/orders'
import { adminCloseOrder, adminMarkOrderAbnormal, fetchAdminHandoffRecords, fetchAdminOrder, type HandoffRecord, type Order } from '@/api/orders'
const route = useRoute()
const loading = ref(false)
const submitting = ref(false)
const order = ref<Order | null>(null)
const handoffRecords = ref<HandoffRecord[]>([])
const actionType = ref<'close' | 'abnormal' | ''>('')
const reason = ref('')
const snapshotText = computed(() => JSON.stringify(order.value?.account_snapshot || {}, null, 2))
const actionTitle = computed(() => (actionType.value === 'close' ? '客服关闭订单' : '标记订单异常'))
const canOperate = computed(() => !!order.value && !['completed', 'cancelled', 'closed'].includes(order.value.status))
onMounted(loadOrder)
@@ -22,6 +28,39 @@ async function loadOrder() {
loading.value = false
}
}
function openAction(type: 'close' | 'abnormal') {
actionType.value = type
reason.value = ''
}
async function submitAction() {
if (!order.value || !actionType.value) return
submitting.value = true
try {
if (actionType.value === 'close') {
await adminCloseOrder(order.value.id, reason.value)
ElMessage.success('订单已关闭')
} else {
await adminMarkOrderAbnormal(order.value.id, reason.value)
ElMessage.success('订单已标记异常')
}
actionType.value = ''
await loadOrder()
} 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>
@@ -32,9 +71,13 @@ async function loadOrder() {
<h1>订单详情</h1>
<p>{{ order.title }} · {{ order.server_region }} / {{ order.login_platform }}</p>
</div>
<RouterLink to="/admin/orders">
<el-button>返回列表</el-button>
</RouterLink>
<div class="toolbar-actions">
<RouterLink to="/admin/orders">
<el-button>返回列表</el-button>
</RouterLink>
<el-button type="warning" :disabled="!canOperate" @click="openAction('abnormal')">标记异常</el-button>
<el-button type="danger" :disabled="!canOperate" @click="openAction('close')">客服关闭</el-button>
</div>
</div>
<div v-if="order" class="metric-grid dashboard-metrics">
@@ -81,5 +124,16 @@ async function loadOrder() {
<h2>账号快照</h2>
<pre>{{ snapshotText }}</pre>
</div>
<el-dialog :model-value="!!actionType" :title="actionTitle" width="560px" @update:model-value="actionType = ''">
<div v-if="order" class="dialog-body">
<p><strong>{{ order.order_no }}</strong> · {{ order.title }}</p>
<el-input v-model="reason" type="textarea" :rows="4" placeholder="填写客服操作原因,会写入审计日志并通知双方" />
</div>
<template #footer>
<el-button @click="actionType = ''">取消</el-button>
<el-button type="danger" :loading="submitting" @click="submitAction">确认操作</el-button>
</template>
</el-dialog>
</section>
</template>
@@ -38,6 +38,7 @@ async function loadOrders() {
<el-option label="租赁中" value="renting" />
<el-option label="待归还确认" value="pending_return_confirm" />
<el-option label="申诉中" value="disputing" />
<el-option label="异常" value="abnormal" />
<el-option label="已完成" value="completed" />
<el-option label="已关闭" value="closed" />
<el-option label="已取消" value="cancelled" />