后台“资金流水
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import { apiClient } from './client'
|
||||
|
||||
export interface AdminUserItem {
|
||||
id: number
|
||||
phone: string
|
||||
nickname: string
|
||||
realname_status: string
|
||||
risk_status: string
|
||||
credit_score: number
|
||||
status: string
|
||||
order_count: number
|
||||
listing_count: number
|
||||
dispute_count: number
|
||||
last_login_at?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
code: string
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export async function fetchAdminUsers() {
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: AdminUserItem[] }>>('/admin/users')
|
||||
return data.data.items
|
||||
}
|
||||
|
||||
export async function freezeAdminUser(id: number, reason: string) {
|
||||
const { data } = await apiClient.post<ApiResponse<AdminUserItem>>(`/admin/users/${id}/freeze`, { reason })
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function unfreezeAdminUser(id: number) {
|
||||
const { data } = await apiClient.post<ApiResponse<AdminUserItem>>(`/admin/users/${id}/unfreeze`)
|
||||
return data.data
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { apiClient } from './client'
|
||||
|
||||
export interface AdminWalletLedger {
|
||||
id: number
|
||||
ledger_no: string
|
||||
user_id: number
|
||||
user_phone: string
|
||||
user_nickname: string
|
||||
order_id?: number
|
||||
order_no: string
|
||||
direction: string
|
||||
amount: number
|
||||
balance_after: number
|
||||
balance_type: string
|
||||
biz_type: string
|
||||
biz_no: string
|
||||
remark: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AdminWalletLedgerQuery {
|
||||
user_id?: string
|
||||
order_id?: string
|
||||
biz_type?: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
code: string
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export async function fetchAdminWalletLedger(query: AdminWalletLedgerQuery = {}) {
|
||||
const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== undefined))
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: AdminWalletLedger[] }>>('/admin/wallet/ledger', { params })
|
||||
return data.data.items
|
||||
}
|
||||
@@ -7,6 +7,8 @@ export interface Order {
|
||||
account_id: number
|
||||
owner_id: number
|
||||
renter_id: number
|
||||
owner_phone?: string
|
||||
renter_phone?: string
|
||||
title: string
|
||||
server_region: string
|
||||
login_platform: string
|
||||
@@ -55,11 +57,21 @@ export async function fetchOrders() {
|
||||
return data.data.items
|
||||
}
|
||||
|
||||
export async function fetchAdminOrders() {
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: Order[] }>>('/admin/orders')
|
||||
return data.data.items
|
||||
}
|
||||
|
||||
export async function fetchOrder(id: string | number) {
|
||||
const { data } = await apiClient.get<ApiResponse<Order>>(`/orders/${id}`)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchAdminOrder(id: string | number) {
|
||||
const { data } = await apiClient.get<ApiResponse<Order>>(`/admin/orders/${id}`)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function cancelOrder(id: number) {
|
||||
const { data } = await apiClient.post<ApiResponse<{ cancelled: boolean }>>(`/orders/${id}/cancel`)
|
||||
return data.data
|
||||
@@ -75,6 +87,11 @@ export async function fetchHandoffRecords(id: string | number) {
|
||||
return data.data.items
|
||||
}
|
||||
|
||||
export async function fetchAdminHandoffRecords(id: string | number) {
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: HandoffRecord[] }>>(`/admin/orders/${id}/handoff-records`)
|
||||
return data.data.items
|
||||
}
|
||||
|
||||
export async function confirmReceive(id: number) {
|
||||
const { data } = await apiClient.post<ApiResponse<{ received: boolean }>>(`/orders/${id}/confirm-receive`)
|
||||
return data.data
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { DataLine, DocumentChecked, Operation, ScaleToOriginal, SwitchButton } from '@element-plus/icons-vue'
|
||||
import { DataLine, DocumentChecked, Operation, ScaleToOriginal, SwitchButton, Tickets, User, Wallet } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
@@ -12,8 +12,11 @@ const adminSession = useAdminSessionStore()
|
||||
|
||||
const navItems = [
|
||||
{ label: '仪表盘', to: '/admin/dashboard', icon: DataLine },
|
||||
{ label: '用户管理', to: '/admin/users', icon: User },
|
||||
{ label: '订单管理', to: '/admin/orders', icon: Tickets },
|
||||
{ label: '商品审核', to: '/admin/listings/review', icon: DocumentChecked },
|
||||
{ label: '仲裁中心', to: '/admin/disputes', icon: ScaleToOriginal },
|
||||
{ label: '资金流水', to: '/admin/wallet-ledger', icon: Wallet },
|
||||
{ label: '系统配置', to: '/admin/system-configs', icon: Operation },
|
||||
]
|
||||
|
||||
|
||||
@@ -25,6 +25,24 @@ const router = createRouter({
|
||||
component: () => import('@/views/admin/AdminDashboardView.vue'),
|
||||
meta: { layout: 'admin', requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: '/admin/users',
|
||||
name: 'admin-users',
|
||||
component: () => import('@/views/admin/AdminUsersView.vue'),
|
||||
meta: { layout: 'admin', requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: '/admin/orders',
|
||||
name: 'admin-orders',
|
||||
component: () => import('@/views/admin/AdminOrdersView.vue'),
|
||||
meta: { layout: 'admin', requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: '/admin/orders/:id',
|
||||
name: 'admin-order-detail',
|
||||
component: () => import('@/views/admin/AdminOrderDetailView.vue'),
|
||||
meta: { layout: 'admin', requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: '/admin/listings/review',
|
||||
name: 'admin-listing-review',
|
||||
@@ -37,6 +55,12 @@ const router = createRouter({
|
||||
component: () => import('@/views/admin/AdminDisputesView.vue'),
|
||||
meta: { layout: 'admin', requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: '/admin/wallet-ledger',
|
||||
name: 'admin-wallet-ledger',
|
||||
component: () => import('@/views/admin/AdminWalletLedgerView.vue'),
|
||||
meta: { layout: 'admin', requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: '/admin/system-configs',
|
||||
name: 'admin-system-configs',
|
||||
|
||||
+154
-31
@@ -91,6 +91,7 @@ a {
|
||||
.admin-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
border-right: 1px solid #dfe5ec;
|
||||
background: #111827;
|
||||
padding: 20px 16px;
|
||||
@@ -130,6 +131,7 @@ a {
|
||||
|
||||
.admin-workspace {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-topbar {
|
||||
@@ -155,8 +157,13 @@ a {
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 28px;
|
||||
padding: 28px clamp(18px, 2.4vw, 36px);
|
||||
}
|
||||
|
||||
.admin-main .page {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.admin-login-shell {
|
||||
@@ -231,7 +238,8 @@ a {
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1120px;
|
||||
width: 100%;
|
||||
max-width: 1440px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
@@ -260,7 +268,7 @@ h1 {
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
@@ -285,12 +293,12 @@ h1 {
|
||||
}
|
||||
|
||||
.dashboard-metrics {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
||||
}
|
||||
|
||||
.dashboard-panels {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@@ -433,6 +441,15 @@ h1 {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.table-panel {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.table-panel .el-table__inner-wrapper {
|
||||
min-width: 760px;
|
||||
}
|
||||
|
||||
.listing-form {
|
||||
max-width: 860px;
|
||||
border: 1px solid #e4e7ed;
|
||||
@@ -448,7 +465,7 @@ h1 {
|
||||
}
|
||||
|
||||
.order-panel {
|
||||
max-width: 520px;
|
||||
max-width: min(100%, 560px);
|
||||
margin-top: 28px;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
@@ -503,6 +520,50 @@ h1 {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px 16px;
|
||||
margin-top: 24px;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.filter-panel .el-form-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.table-subtext {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: #6b7785;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.code-panel {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.code-panel pre {
|
||||
overflow-x: auto;
|
||||
margin: 0;
|
||||
border-radius: 6px;
|
||||
background: #111827;
|
||||
color: #dbe4ee;
|
||||
padding: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.notification-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
@@ -551,12 +612,64 @@ h1 {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.app-shell {
|
||||
@media (max-width: 1180px) {
|
||||
.admin-shell {
|
||||
grid-template-columns: 220px 1fr;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
padding: 18px 12px;
|
||||
}
|
||||
|
||||
.admin-nav-link {
|
||||
padding: 0 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.admin-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-shell {
|
||||
.admin-sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #1f2937;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.admin-brand {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.admin-nav {
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: max-content;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.admin-nav-link {
|
||||
min-height: 38px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-topbar {
|
||||
min-height: 58px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
padding: 22px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -574,32 +687,10 @@ h1 {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #1f2937;
|
||||
}
|
||||
|
||||
.admin-nav {
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: max-content;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.admin-topbar {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -615,4 +706,36 @@ h1 {
|
||||
.page-header-row {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.admin-topbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.admin-topbar .el-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dashboard-panels {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-captcha-row,
|
||||
.code-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.captcha-image-button,
|
||||
.captcha-image-button img {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,14 @@ function money(value?: number) {
|
||||
|
||||
<div class="order-panel dashboard-panel">
|
||||
<h2>后台入口</h2>
|
||||
<RouterLink class="pending-row" to="/admin/users">
|
||||
<span>用户管理</span>
|
||||
<strong>进入</strong>
|
||||
</RouterLink>
|
||||
<RouterLink class="pending-row" to="/admin/orders">
|
||||
<span>订单管理</span>
|
||||
<strong>进入</strong>
|
||||
</RouterLink>
|
||||
<RouterLink class="pending-row" to="/admin/disputes">
|
||||
<span>仲裁中心</span>
|
||||
<strong>进入</strong>
|
||||
|
||||
@@ -85,7 +85,7 @@ function readError(error: unknown, fallback: string) {
|
||||
<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">
|
||||
<el-table-column label="操作" width="170">
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { fetchAdminHandoffRecords, fetchAdminOrder, type HandoffRecord, type Order } from '@/api/orders'
|
||||
|
||||
const route = useRoute()
|
||||
const loading = ref(false)
|
||||
const order = ref<Order | null>(null)
|
||||
const handoffRecords = ref<HandoffRecord[]>([])
|
||||
|
||||
const snapshotText = computed(() => JSON.stringify(order.value?.account_snapshot || {}, null, 2))
|
||||
|
||||
onMounted(loadOrder)
|
||||
|
||||
async function loadOrder() {
|
||||
loading.value = true
|
||||
try {
|
||||
order.value = await fetchAdminOrder(String(route.params.id))
|
||||
handoffRecords.value = await fetchAdminHandoffRecords(String(route.params.id))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page" v-loading="loading">
|
||||
<div v-if="order" class="page-header-row">
|
||||
<div class="page-header">
|
||||
<p class="eyebrow">{{ order.order_no }}</p>
|
||||
<h1>订单详情</h1>
|
||||
<p>{{ order.title }} · {{ order.server_region }} / {{ order.login_platform }}</p>
|
||||
</div>
|
||||
<RouterLink to="/admin/orders">
|
||||
<el-button>返回列表</el-button>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div v-if="order" class="metric-grid dashboard-metrics">
|
||||
<div class="metric-card">
|
||||
<span>订单状态</span>
|
||||
<strong>{{ order.status }}</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>交接状态</span>
|
||||
<strong>{{ order.handoff_status }}</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>租金</span>
|
||||
<strong>¥{{ order.rent_amount }}</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>押金</span>
|
||||
<strong>¥{{ order.deposit_amount }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="order" class="dashboard-panels">
|
||||
<div class="order-panel dashboard-panel">
|
||||
<h2>用户信息</h2>
|
||||
<p>租客:{{ order.renter_phone || order.renter_id }}</p>
|
||||
<p>号主:{{ order.owner_phone || order.owner_id }}</p>
|
||||
<p>租期:{{ order.rent_hours }} 小时</p>
|
||||
<p>开始:{{ order.rent_start_at || '未开始' }}</p>
|
||||
<p>结束:{{ order.rent_end_at || '未设置' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="order-panel dashboard-panel">
|
||||
<h2>交接记录</h2>
|
||||
<el-empty v-if="handoffRecords.length === 0" description="暂无交接记录" />
|
||||
<div v-for="record in handoffRecords" :key="record.id" class="timeline-item">
|
||||
<strong>{{ record.type }}</strong>
|
||||
<p>{{ record.content }}</p>
|
||||
<span>{{ record.created_at }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="order" class="order-panel dashboard-panel code-panel">
|
||||
<h2>账号快照</h2>
|
||||
<pre>{{ snapshotText }}</pre>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import { fetchAdminOrders, type Order } from '@/api/orders'
|
||||
|
||||
const loading = ref(false)
|
||||
const orders = ref<Order[]>([])
|
||||
const status = ref('')
|
||||
|
||||
const filteredOrders = computed(() => {
|
||||
if (!status.value) return orders.value
|
||||
return orders.value.filter((item) => item.status === status.value)
|
||||
})
|
||||
|
||||
onMounted(loadOrders)
|
||||
|
||||
async function loadOrders() {
|
||||
loading.value = true
|
||||
try {
|
||||
orders.value = await fetchAdminOrders()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<div class="page-header-row">
|
||||
<div class="page-header">
|
||||
<p class="eyebrow">Orders</p>
|
||||
<h1>订单管理</h1>
|
||||
<p>查看全量订单、交接状态、金额和结算状态。</p>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-select v-model="status" clearable placeholder="订单状态" style="width: 180px">
|
||||
<el-option label="待交接" value="pending_handoff" />
|
||||
<el-option label="租赁中" value="renting" />
|
||||
<el-option label="待归还确认" value="pending_return_confirm" />
|
||||
<el-option label="申诉中" value="disputing" />
|
||||
<el-option label="已完成" value="completed" />
|
||||
<el-option label="已关闭" value="closed" />
|
||||
<el-option label="已取消" value="cancelled" />
|
||||
</el-select>
|
||||
<el-button @click="loadOrders">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" class="table-panel" :data="filteredOrders">
|
||||
<el-table-column prop="order_no" label="订单号" min-width="230" />
|
||||
<el-table-column prop="title" label="账号" min-width="170" />
|
||||
<el-table-column prop="renter_phone" label="租客" min-width="130" />
|
||||
<el-table-column prop="owner_phone" label="号主" min-width="130" />
|
||||
<el-table-column prop="status" label="状态" width="140" />
|
||||
<el-table-column prop="handoff_status" label="交接" width="180" />
|
||||
<el-table-column prop="settlement_status" label="结算" width="120" />
|
||||
<el-table-column prop="rent_amount" label="租金" width="100" />
|
||||
<el-table-column prop="deposit_amount" label="押金" width="100" />
|
||||
<el-table-column prop="created_at" label="创建时间" min-width="180" />
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<RouterLink :to="`/admin/orders/${row.id}`">
|
||||
<el-button size="small">详情</el-button>
|
||||
</RouterLink>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { fetchAdminUsers, freezeAdminUser, unfreezeAdminUser, type AdminUserItem } from '@/api/adminUsers'
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const users = ref<AdminUserItem[]>([])
|
||||
const activeUser = ref<AdminUserItem | null>(null)
|
||||
const freezeReason = ref('')
|
||||
|
||||
onMounted(loadUsers)
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
try {
|
||||
users.value = await fetchAdminUsers()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openFreeze(row: AdminUserItem) {
|
||||
activeUser.value = row
|
||||
freezeReason.value = ''
|
||||
}
|
||||
|
||||
async function handleFreeze() {
|
||||
if (!activeUser.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await freezeAdminUser(activeUser.value.id, freezeReason.value)
|
||||
ElMessage.success('用户已冻结')
|
||||
activeUser.value = null
|
||||
await loadUsers()
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '冻结失败'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnfreeze(row: AdminUserItem) {
|
||||
submitting.value = true
|
||||
try {
|
||||
await unfreezeAdminUser(row.id)
|
||||
ElMessage.success('用户已解冻')
|
||||
await loadUsers()
|
||||
} 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">Users</p>
|
||||
<h1>用户管理</h1>
|
||||
<p>查看用户实名、风险、信用和业务数量,处理冻结与解冻。</p>
|
||||
</div>
|
||||
<el-button @click="loadUsers">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" class="table-panel" :data="users">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="phone" label="手机号" min-width="130" />
|
||||
<el-table-column prop="nickname" label="昵称" min-width="130" />
|
||||
<el-table-column prop="realname_status" label="实名" width="110" />
|
||||
<el-table-column prop="risk_status" label="风险" width="110" />
|
||||
<el-table-column prop="credit_score" label="信用分" width="100" />
|
||||
<el-table-column prop="status" label="状态" width="110" />
|
||||
<el-table-column prop="order_count" label="订单" width="90" />
|
||||
<el-table-column prop="listing_count" label="发布" width="90" />
|
||||
<el-table-column prop="dispute_count" label="申诉" width="90" />
|
||||
<el-table-column prop="last_login_at" label="最近登录" min-width="180" />
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status === 'active'" size="small" type="danger" :loading="submitting" @click="openFreeze(row)">
|
||||
冻结
|
||||
</el-button>
|
||||
<el-button v-else size="small" type="primary" :loading="submitting" @click="handleUnfreeze(row)">解冻</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog :model-value="!!activeUser" title="冻结用户" width="560px" @update:model-value="activeUser = null">
|
||||
<div v-if="activeUser" class="dialog-body">
|
||||
<p><strong>{{ activeUser.phone }}</strong> · {{ activeUser.nickname }}</p>
|
||||
<el-input v-model="freezeReason" type="textarea" :rows="4" placeholder="填写冻结原因,便于审计追踪" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="activeUser = null">取消</el-button>
|
||||
<el-button type="danger" :loading="submitting" @click="handleFreeze">确认冻结</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
|
||||
import { fetchAdminWalletLedger, type AdminWalletLedger } from '@/api/adminWallet'
|
||||
|
||||
const loading = ref(false)
|
||||
const ledger = ref<AdminWalletLedger[]>([])
|
||||
const filters = reactive({
|
||||
user_id: '',
|
||||
order_id: '',
|
||||
biz_type: '',
|
||||
limit: 200,
|
||||
})
|
||||
|
||||
const inAmount = computed(() =>
|
||||
ledger.value.filter((item) => item.direction === 'in').reduce((sum, item) => sum + Number(item.amount || 0), 0),
|
||||
)
|
||||
const outAmount = computed(() =>
|
||||
ledger.value.filter((item) => item.direction === 'out').reduce((sum, item) => sum + Number(item.amount || 0), 0),
|
||||
)
|
||||
const frozenAmount = computed(() =>
|
||||
ledger.value.filter((item) => item.balance_type === 'frozen').reduce((sum, item) => sum + Number(item.amount || 0), 0),
|
||||
)
|
||||
|
||||
onMounted(loadLedger)
|
||||
|
||||
async function loadLedger() {
|
||||
loading.value = true
|
||||
try {
|
||||
ledger.value = await fetchAdminWalletLedger(filters)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.user_id = ''
|
||||
filters.order_id = ''
|
||||
filters.biz_type = ''
|
||||
filters.limit = 200
|
||||
void loadLedger()
|
||||
}
|
||||
|
||||
function money(value: number) {
|
||||
return `¥${value.toFixed(2)}`
|
||||
}
|
||||
|
||||
function directionType(direction: string) {
|
||||
return direction === 'in' ? 'success' : 'danger'
|
||||
}
|
||||
|
||||
function directionLabel(direction: string) {
|
||||
return direction === 'in' ? '入账' : '出账'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<div class="page-header-row">
|
||||
<div class="page-header">
|
||||
<p class="eyebrow">Wallet Ledger</p>
|
||||
<h1>资金流水</h1>
|
||||
<p>查看租金、押金、冻结、解冻、退款和结算流水。当前为开发态账务记录,不代表真实支付余额。</p>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
<el-button type="primary" :icon="Search" :loading="loading" @click="loadLedger">查询</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metric-grid">
|
||||
<div class="metric-card">
|
||||
<span>当前结果</span>
|
||||
<strong>{{ ledger.length }} 笔</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>入账合计</span>
|
||||
<strong>{{ money(inAmount) }}</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>出账合计</span>
|
||||
<strong>{{ money(outAmount) }}</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>冻结相关金额</span>
|
||||
<strong>{{ money(frozenAmount) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form class="filter-panel" label-position="top">
|
||||
<el-form-item label="用户 ID">
|
||||
<el-input v-model="filters.user_id" clearable placeholder="按用户筛选" />
|
||||
</el-form-item>
|
||||
<el-form-item label="订单 ID">
|
||||
<el-input v-model="filters.order_id" clearable placeholder="按订单筛选" />
|
||||
</el-form-item>
|
||||
<el-form-item label="业务类型">
|
||||
<el-select v-model="filters.biz_type" clearable placeholder="全部业务" class="full-control">
|
||||
<el-option label="订单冻结" value="order_freeze" />
|
||||
<el-option label="订单取消释放" value="order_cancel_release" />
|
||||
<el-option label="订单结算" value="order_settlement" />
|
||||
<el-option label="押金退回" value="deposit_refund" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="查询条数">
|
||||
<el-input-number v-model="filters.limit" :min="20" :max="500" :step="20" class="full-control" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table v-loading="loading" class="table-panel" :data="ledger">
|
||||
<el-table-column prop="ledger_no" label="流水号" min-width="230" />
|
||||
<el-table-column label="用户" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<strong>{{ row.user_phone || `用户 ${row.user_id}` }}</strong>
|
||||
<span class="table-subtext">ID: {{ row.user_id }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="订单" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<RouterLink v-if="row.order_id" :to="`/admin/orders/${row.order_id}`">{{ row.order_no || row.order_id }}</RouterLink>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="biz_type" label="业务类型" width="150" />
|
||||
<el-table-column label="方向" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="directionType(row.direction)">{{ directionLabel(row.direction) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="金额" width="110">
|
||||
<template #default="{ row }">{{ money(Number(row.amount)) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="balance_type" label="余额类型" width="110" />
|
||||
<el-table-column label="变化后余额" width="130">
|
||||
<template #default="{ row }">{{ money(Number(row.balance_after)) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="220" />
|
||||
<el-table-column prop="created_at" label="创建时间" min-width="180" />
|
||||
</el-table>
|
||||
</section>
|
||||
</template>
|
||||
Reference in New Issue
Block a user