第 5 阶段:纠纷、通知与后台-1
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import { apiClient } from './client'
|
||||
|
||||
export interface Dispute {
|
||||
id: number
|
||||
order_id: number
|
||||
order_no: string
|
||||
title: string
|
||||
initiator_id: number
|
||||
target_user_id: number
|
||||
type: string
|
||||
status: string
|
||||
description: string
|
||||
evidence_urls?: string[]
|
||||
arbitration_result: string
|
||||
arbitration_remark: string
|
||||
handled_by?: number
|
||||
handled_at?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
code: string
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export async function createDispute(orderId: number, payload: { type: string; description: string; evidence_urls?: string[] }) {
|
||||
const { data } = await apiClient.post<ApiResponse<Dispute>>(`/orders/${orderId}/dispute`, payload)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchDisputes() {
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: Dispute[] }>>('/disputes')
|
||||
return data.data.items
|
||||
}
|
||||
|
||||
export async function fetchAdminDisputes() {
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: Dispute[] }>>('/admin/disputes')
|
||||
return data.data.items
|
||||
}
|
||||
|
||||
export async function arbitrateDispute(id: number, payload: { result: string; remark: string }) {
|
||||
const { data } = await apiClient.post<ApiResponse<Dispute>>(`/admin/disputes/${id}/arbitrate`, payload)
|
||||
return data.data
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { apiClient } from './client'
|
||||
|
||||
export interface NotificationItem {
|
||||
id: number
|
||||
user_id: number
|
||||
type: string
|
||||
title: string
|
||||
content: string
|
||||
biz_type: string
|
||||
biz_id?: number
|
||||
read_at?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
code: string
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export async function fetchNotifications() {
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: NotificationItem[] }>>('/notifications')
|
||||
return data.data.items
|
||||
}
|
||||
|
||||
export async function markNotificationRead(id: number) {
|
||||
const { data } = await apiClient.post<ApiResponse<{ read: boolean }>>(`/notifications/${id}/read`)
|
||||
return data.data
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { apiClient } from './client'
|
||||
|
||||
export interface SystemConfig {
|
||||
id: number
|
||||
key: string
|
||||
value: string
|
||||
description: string
|
||||
updated_by?: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
code: string
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export async function fetchSystemConfigs() {
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: SystemConfig[] }>>('/admin/system-configs')
|
||||
return data.data.items
|
||||
}
|
||||
|
||||
export async function updateSystemConfig(key: string, payload: { value: string; description?: string }) {
|
||||
const { data } = await apiClient.put<ApiResponse<SystemConfig>>(`/admin/system-configs/${key}`, payload)
|
||||
return data.data
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Bell, House, Phone, Shop, Tickets, UserFilled, Wallet } from '@element-plus/icons-vue'
|
||||
import { Bell, House, Operation, Phone, ScaleToOriginal, Shop, Tickets, UserFilled, Wallet } from '@element-plus/icons-vue'
|
||||
|
||||
const navItems = [
|
||||
{ label: '首页', to: '/', icon: House },
|
||||
@@ -8,6 +8,8 @@ const navItems = [
|
||||
{ label: '钱包', to: '/wallet', icon: Wallet },
|
||||
{ label: '通知', to: '/notifications', icon: Bell },
|
||||
{ label: '实名', to: '/realname', icon: UserFilled },
|
||||
{ label: '仲裁', to: '/admin/disputes', icon: ScaleToOriginal },
|
||||
{ label: '配置', to: '/admin/system-configs', icon: Operation },
|
||||
{ label: '登录', to: '/login', icon: Phone },
|
||||
]
|
||||
</script>
|
||||
|
||||
@@ -307,6 +307,69 @@ h1 {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.full-control {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dialog-body p {
|
||||
margin: 0;
|
||||
color: #52616f;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.notification-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.notification-item.unread {
|
||||
border-color: #0f766e;
|
||||
}
|
||||
|
||||
.notification-item span {
|
||||
color: #0f766e;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.notification-item h2 {
|
||||
margin: 6px 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.notification-item p {
|
||||
margin: 0 0 8px;
|
||||
color: #52616f;
|
||||
}
|
||||
|
||||
.notification-item small {
|
||||
color: #6b7785;
|
||||
}
|
||||
|
||||
.notification-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -1,3 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { fetchNotifications, markNotificationRead, type NotificationItem } from '@/api/notifications'
|
||||
|
||||
const loading = ref(false)
|
||||
const notifications = ref<NotificationItem[]>([])
|
||||
|
||||
onMounted(loadNotifications)
|
||||
|
||||
async function loadNotifications() {
|
||||
loading.value = true
|
||||
try {
|
||||
notifications.value = await fetchNotifications()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function markRead(id: number) {
|
||||
await markNotificationRead(id)
|
||||
ElMessage.success('已标记为已读')
|
||||
await loadNotifications()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<div class="page-header">
|
||||
@@ -5,5 +32,23 @@
|
||||
<h1>站内信</h1>
|
||||
<p>接收审核、交接、归还、申诉和仲裁结果通知。</p>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!loading && notifications.length === 0" description="暂无站内信" />
|
||||
<div v-else v-loading="loading" class="notification-list">
|
||||
<div v-for="item in notifications" :key="item.id" class="notification-item" :class="{ unread: !item.read_at }">
|
||||
<div>
|
||||
<span>{{ item.type }}</span>
|
||||
<h2>{{ item.title }}</h2>
|
||||
<p>{{ item.content }}</p>
|
||||
<small>{{ item.created_at }}</small>
|
||||
</div>
|
||||
<div class="notification-actions">
|
||||
<RouterLink v-if="item.biz_type === 'order' && item.biz_id" :to="`/orders/${item.biz_id}`">
|
||||
<el-button size="small">查看订单</el-button>
|
||||
</RouterLink>
|
||||
<el-button v-if="!item.read_at" size="small" type="primary" @click="markRead(item.id)">已读</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ElMessage } from 'element-plus'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { createDispute } from '@/api/disputes'
|
||||
import {
|
||||
cancelOrder,
|
||||
confirmReceive,
|
||||
@@ -25,13 +26,21 @@ const handoffing = ref(false)
|
||||
const confirming = ref(false)
|
||||
const returning = ref(false)
|
||||
const completing = ref(false)
|
||||
const disputing = ref(false)
|
||||
const order = ref<Order | null>(null)
|
||||
const handoffRecords = ref<HandoffRecord[]>([])
|
||||
const handoffContent = ref('')
|
||||
const returnContent = ref('')
|
||||
const disputeType = ref('cannot_login')
|
||||
const disputeDescription = ref('')
|
||||
const disputeEvidenceText = ref('')
|
||||
|
||||
const isOwner = computed(() => order.value?.owner_id === session.userId)
|
||||
const isRenter = computed(() => order.value?.renter_id === session.userId)
|
||||
const canOpenDispute = computed(() => {
|
||||
if (!order.value || (!isOwner.value && !isRenter.value)) return false
|
||||
return !['completed', 'cancelled', 'closed', 'disputing'].includes(order.value.status)
|
||||
})
|
||||
|
||||
onMounted(loadOrder)
|
||||
|
||||
@@ -117,6 +126,30 @@ async function handleConfirmReturn() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateDispute() {
|
||||
if (!order.value) return
|
||||
disputing.value = true
|
||||
try {
|
||||
const evidence_urls = disputeEvidenceText.value
|
||||
.split('\n')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
await createDispute(order.value.id, {
|
||||
type: disputeType.value,
|
||||
description: disputeDescription.value,
|
||||
evidence_urls,
|
||||
})
|
||||
disputeDescription.value = ''
|
||||
disputeEvidenceText.value = ''
|
||||
ElMessage.success('申诉已提交,订单进入仲裁处理')
|
||||
await loadOrder()
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '提交申诉失败'))
|
||||
} finally {
|
||||
disputing.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
|
||||
@@ -198,5 +231,33 @@ function readError(error: unknown, fallback: string) {
|
||||
<p>确认账号状态无误后,订单会完成,账号重新上架。</p>
|
||||
<el-button type="primary" :loading="completing" @click="handleConfirmReturn">确认归还并完成订单</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="order && canOpenDispute" class="order-panel">
|
||||
<h2>发起申诉</h2>
|
||||
<el-select v-model="disputeType" class="full-control" placeholder="选择申诉类型">
|
||||
<el-option label="无法登录" value="cannot_login" />
|
||||
<el-option label="虚假描述" value="false_description" />
|
||||
<el-option label="账号被封" value="account_banned" />
|
||||
<el-option label="资产损失" value="asset_loss" />
|
||||
<el-option label="哈夫币争议" value="haf_coin_dispute" />
|
||||
<el-option label="超时未交接" value="handoff_timeout" />
|
||||
<el-option label="超时未归还" value="return_timeout" />
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="disputeDescription"
|
||||
class="panel-action"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="说明争议经过、时间点和希望客服核查的证据"
|
||||
/>
|
||||
<el-input
|
||||
v-model="disputeEvidenceText"
|
||||
class="panel-action"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="证据链接,一行一个。开发阶段可先填截图地址或备注链接"
|
||||
/>
|
||||
<el-button class="panel-action" type="warning" :loading="disputing" @click="handleCreateDispute">提交申诉</el-button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,3 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { arbitrateDispute, fetchAdminDisputes, type Dispute } from '@/api/disputes'
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const disputes = ref<Dispute[]>([])
|
||||
const activeDispute = ref<Dispute | null>(null)
|
||||
const result = ref('release_deposit')
|
||||
const remark = ref('')
|
||||
|
||||
onMounted(loadDisputes)
|
||||
|
||||
async function loadDisputes() {
|
||||
loading.value = true
|
||||
try {
|
||||
disputes.value = await fetchAdminDisputes()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openArbitration(row: Dispute) {
|
||||
activeDispute.value = row
|
||||
result.value = row.arbitration_result || 'release_deposit'
|
||||
remark.value = row.arbitration_remark || ''
|
||||
}
|
||||
|
||||
async function handleArbitrate() {
|
||||
if (!activeDispute.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await arbitrateDispute(activeDispute.value.id, {
|
||||
result: result.value,
|
||||
remark: remark.value,
|
||||
})
|
||||
ElMessage.success('仲裁结果已保存,双方已收到通知')
|
||||
activeDispute.value = null
|
||||
remark.value = ''
|
||||
await loadDisputes()
|
||||
} 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">
|
||||
@@ -5,5 +63,39 @@
|
||||
<h1>仲裁中心</h1>
|
||||
<p>处理无法登录、资产损失、哈夫币争议和超时归还。</p>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" class="table-panel" :data="disputes">
|
||||
<el-table-column prop="order_no" label="订单号" min-width="210" />
|
||||
<el-table-column prop="title" label="账号" min-width="160" />
|
||||
<el-table-column prop="type" label="类型" width="150" />
|
||||
<el-table-column prop="status" label="状态" width="110" />
|
||||
<el-table-column prop="description" label="说明" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="arbitration_result" label="结果" width="150" />
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" :disabled="row.status === 'resolved'" @click="openArbitration(row)">仲裁</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog :model-value="!!activeDispute" title="申诉仲裁" width="560px" @update:model-value="activeDispute = null">
|
||||
<div v-if="activeDispute" class="dialog-body">
|
||||
<p><strong>{{ activeDispute.order_no }}</strong> · {{ activeDispute.title }}</p>
|
||||
<p>{{ activeDispute.description }}</p>
|
||||
<el-select v-model="result" class="full-control" placeholder="选择裁决结果">
|
||||
<el-option label="全额退款" value="full_refund" />
|
||||
<el-option label="部分退款" value="partial_refund" />
|
||||
<el-option label="扣押金" value="deduct_deposit" />
|
||||
<el-option label="释放押金" value="release_deposit" />
|
||||
<el-option label="赔付号主" value="compensate_owner" />
|
||||
<el-option label="关闭订单" value="order_close" />
|
||||
</el-select>
|
||||
<el-input v-model="remark" class="panel-action" type="textarea" :rows="4" placeholder="填写客服裁决说明" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="activeDispute = null">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleArbitrate">保存裁决</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,3 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { fetchSystemConfigs, updateSystemConfig, type SystemConfig } from '@/api/systemConfigs'
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const configs = ref<SystemConfig[]>([])
|
||||
const activeConfig = ref<SystemConfig | null>(null)
|
||||
const value = ref('')
|
||||
const description = ref('')
|
||||
|
||||
onMounted(loadConfigs)
|
||||
|
||||
async function loadConfigs() {
|
||||
loading.value = true
|
||||
try {
|
||||
configs.value = await fetchSystemConfigs()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(row: SystemConfig) {
|
||||
activeConfig.value = row
|
||||
value.value = row.value
|
||||
description.value = row.description
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!activeConfig.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await updateSystemConfig(activeConfig.value.key, {
|
||||
value: value.value,
|
||||
description: description.value,
|
||||
})
|
||||
ElMessage.success('配置已更新')
|
||||
activeConfig.value = null
|
||||
await loadConfigs()
|
||||
} 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">
|
||||
@@ -5,5 +62,30 @@
|
||||
<h1>系统配置</h1>
|
||||
<p>管理交接超时、归还超时、短信限流、最低押金和抽成比例。</p>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" class="table-panel" :data="configs">
|
||||
<el-table-column prop="key" label="配置项" min-width="260" />
|
||||
<el-table-column prop="value" label="当前值" width="150" />
|
||||
<el-table-column prop="description" label="说明" min-width="260" show-overflow-tooltip />
|
||||
<el-table-column prop="updated_by" label="更新人" width="100" />
|
||||
<el-table-column prop="updated_at" label="更新时间" min-width="180" />
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog :model-value="!!activeConfig" title="编辑系统配置" width="560px" @update:model-value="activeConfig = null">
|
||||
<div v-if="activeConfig" class="dialog-body">
|
||||
<p><strong>{{ activeConfig.key }}</strong></p>
|
||||
<el-input v-model="value" placeholder="配置值" />
|
||||
<el-input v-model="description" type="textarea" :rows="3" placeholder="配置说明" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="activeConfig = null">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user