后台“资金流水

This commit is contained in:
yml
2026-05-22 17:43:45 +08:00
parent 7df557d668
commit c9e6ea2b71
28 changed files with 1271 additions and 34 deletions
@@ -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>
+110
View File
@@ -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>