feat(pickup): 新增管理员线下提号功能

- 新建 admin_pickups 表独立于 rental_orders,不污染订单状态机与财务统计口径
- 提号中/已完成/已取消三态:创建锁定账号,完成时给卖家钱包加可用余额并下架 listing,取消解锁账号
- 完成时 listing 置 completed,复用现有 listingLockedForOwnerMutation 拦截再上架
- 管理端提号管理页(列表/创建/完成/取消/可提号账号搜索)+ 卖家端提号记录页
- 财务仪表盘新增线下提号独立统计块,与正常订单口径分离
- 钱包流水 label、状态标签、菜单入口同步补齐
- 优化卖家服务悬浮菜单为一行四个,尺寸与配色对齐买家服务
This commit is contained in:
yml2213
2026-07-03 19:04:35 +08:00
parent f5a1630a27
commit 9ef0843eb0
22 changed files with 1649 additions and 13 deletions
@@ -44,9 +44,16 @@ export interface FinanceDailyItem {
settled_order_count: number
}
export interface FinancePickupSummary {
settled_amount_cent: number
completed_count: number
in_progress_count: number
}
export interface FinanceDashboard {
summary: FinanceSummary
daily_items: FinanceDailyItem[]
pickup_summary: FinancePickupSummary
generated_at: string
}
@@ -109,6 +116,11 @@ export async function fetchFinanceDashboard(query: FinanceDateQuery = {}) {
return {
...data.data,
daily_items: Array.isArray(data.data?.daily_items) ? data.data.daily_items : [],
pickup_summary: data.data?.pickup_summary ?? {
settled_amount_cent: 0,
completed_count: 0,
in_progress_count: 0,
},
}
}
@@ -0,0 +1,106 @@
import { apiClient } from '@/shared/api/client'
import type { ApiResponse, PaginatedResult } from '@/shared/types/types'
export interface AdminPickup {
id: number
pickup_no: string
listing_id: number
listing_no: string
account_id: number
account_title: string
server_region: string
login_platform: string
owner_id: number
owner_phone: string
admin_id: number
platform: string
settle_amount_cent: number
status: string
remark: string
complete_remark: string
created_at: string
completed_at?: string
cancelled_at?: string
}
export interface AvailableListing {
id: number
listing_no: string
account_id: number
account_title: string
server_region: string
login_platform: string
owner_id: number
owner_phone: string
}
export interface AdminPickupCreateRequest {
listing_id: number
platform?: string
remark?: string
}
export interface AdminPickupCompleteRequest {
settle_amount_cent: number
complete_remark?: string
}
export interface AdminPickupListQuery {
page?: number
page_size?: number
keyword?: string
status?: string
}
function cleanParams(query: object) {
return Object.fromEntries(
Object.entries(query).filter(([, value]) => value !== '' && value !== undefined)
)
}
export async function fetchAdminPickups(query: AdminPickupListQuery = {}) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminPickup>>>(
'/admin/pickups',
{ params: cleanParams(query) }
)
const result = data.data
return {
items: Array.isArray(result?.items) ? result.items : [],
total: Number(result?.total ?? 0),
page: Number(result?.page ?? query.page ?? 1),
page_size: Number(result?.page_size ?? query.page_size ?? 20),
}
}
export async function fetchAvailableListings(keyword: string, page = 1, page_size = 20) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AvailableListing>>>(
'/admin/pickups/available-listings',
{ params: cleanParams({ keyword, page, page_size }) }
)
const result = data.data
return {
items: Array.isArray(result?.items) ? result.items : [],
total: Number(result?.total ?? 0),
}
}
export async function createAdminPickup(req: AdminPickupCreateRequest) {
const { data } = await apiClient.post<ApiResponse<AdminPickup>>('/admin/pickups', req)
return data.data
}
export async function completeAdminPickup(id: number, req: AdminPickupCompleteRequest) {
const { data } = await apiClient.post<ApiResponse<AdminPickup>>(
`/admin/pickups/${id}/complete`,
req
)
return data.data
}
export async function cancelAdminPickup(id: number, reason: string) {
const { data } = await apiClient.post<ApiResponse<{ cancelled: boolean }>>(
`/admin/pickups/${id}/cancel`,
{ reason }
)
return data.data
}
@@ -139,6 +139,19 @@ function rowDiffClass(row: FinanceDailyItem) {
</div>
</div>
<div v-if="dashboard?.pickup_summary" class="metric-grid pickup-grid">
<div class="metric-card pickup-card">
<span>线下提号结算</span>
<strong>{{ moneyCent(dashboard.pickup_summary.settled_amount_cent) }}</strong>
<small>{{ dashboard.pickup_summary.completed_count }} 笔已完成</small>
</div>
<div class="metric-card pickup-card">
<span>提号中</span>
<strong>{{ dashboard.pickup_summary.in_progress_count }}</strong>
<small>进行中笔数</small>
</div>
</div>
<el-table v-loading="loading" class="table-panel" :data="dailyItems">
<el-table-column prop="date" label="日期" width="130" />
<el-table-column label="总流水" width="130">
@@ -193,6 +206,14 @@ function rowDiffClass(row: FinanceDailyItem) {
font-weight: 700;
}
.pickup-grid {
margin-top: 12px;
}
.pickup-card {
border-left: 3px solid #6366f1;
}
.generated-at {
margin: 12px 0 0;
color: #64748b;
@@ -0,0 +1,358 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search } from '@element-plus/icons-vue'
import {
cancelAdminPickup,
completeAdminPickup,
createAdminPickup,
fetchAdminPickups,
fetchAvailableListings,
type AdminPickup,
type AvailableListing,
} from '@/features/admin/api/adminPickup'
import { formatCentWithSymbol } from '@/shared/utils/money'
import { formatDateTime } from '@/shared/utils/time'
import { pickupStatusLabel } from '@/shared/utils/statusLabels'
const loading = ref(false)
const items = ref<AdminPickup[]>([])
const total = ref(0)
const filters = reactive({ page: 1, page_size: 20, keyword: '', status: '' })
const createDialogVisible = ref(false)
const completeDialogVisible = ref(false)
const activePickupId = ref(0)
const createForm = reactive({
listing_id: null as number | null,
platform: '',
remark: '',
})
const completeForm = reactive({ settle_amount: 0, complete_remark: '' })
const listingOptions = ref<AvailableListing[]>([])
const listingLoading = ref(false)
onMounted(loadList)
async function loadList() {
loading.value = true
try {
const result = await fetchAdminPickups(filters)
items.value = result.items
total.value = result.total
} finally {
loading.value = false
}
}
function handleSearch() {
filters.page = 1
loadList()
}
function handlePageChange(p: number) {
filters.page = p
loadList()
}
function handleSizeChange(s: number) {
filters.page_size = s
filters.page = 1
loadList()
}
function openCreateDialog() {
createForm.listing_id = null
createForm.platform = ''
createForm.remark = ''
listingOptions.value = []
createDialogVisible.value = true
}
async function searchListings(keyword: string) {
if (!keyword) {
listingOptions.value = []
return
}
listingLoading.value = true
try {
const result = await fetchAvailableListings(keyword)
listingOptions.value = result.items
} finally {
listingLoading.value = false
}
}
async function handleCreate() {
if (!createForm.listing_id) {
ElMessage.warning('请选择要提号的账号')
return
}
loading.value = true
try {
await createAdminPickup({
listing_id: createForm.listing_id,
platform: createForm.platform,
remark: createForm.remark,
})
ElMessage.success('提号订单已创建')
createDialogVisible.value = false
loadList()
} catch (e: unknown) {
ElMessage.error(errorMessage(e) || '创建失败')
} finally {
loading.value = false
}
}
function openCompleteDialog(id: number) {
activePickupId.value = id
completeForm.settle_amount = 0
completeForm.complete_remark = ''
completeDialogVisible.value = true
}
async function handleComplete() {
if (completeForm.settle_amount <= 0) {
ElMessage.warning('请输入结算金额')
return
}
loading.value = true
try {
await completeAdminPickup(activePickupId.value, {
settle_amount_cent: Math.round(completeForm.settle_amount * 100),
complete_remark: completeForm.complete_remark,
})
ElMessage.success('提号已完成,已给卖家结算')
completeDialogVisible.value = false
loadList()
} catch (e: unknown) {
ElMessage.error(errorMessage(e) || '完成失败')
} finally {
loading.value = false
}
}
async function handleCancel(id: number) {
try {
const { value } = await ElMessageBox.prompt('请输入取消原因', '取消提号', {
confirmButtonText: '确认取消',
cancelButtonText: '返回',
inputPattern: /.+/,
inputErrorMessage: '取消原因不能为空',
})
loading.value = true
try {
await cancelAdminPickup(id, value)
ElMessage.success('提号已取消')
loadList()
} catch (e: unknown) {
ElMessage.error(errorMessage(e) || '取消失败')
} finally {
loading.value = false
}
} catch {
/* 用户放弃 */
}
}
function statusType(status: string) {
if (status === 'picking_up') return 'warning'
if (status === 'completed') return 'success'
if (status === 'cancelled') return 'info'
return ''
}
function errorMessage(e: unknown) {
if (typeof e === 'object' && e !== null && 'message' in e) {
return String((e as { message?: unknown }).message)
}
return ''
}
</script>
<template>
<section class="page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Admin Pickup</p>
<h1>线下提号</h1>
<p>管理员线下提号跳过支付与交接流程完成后给卖家结算余额</p>
</div>
<div class="toolbar-actions">
<el-button type="primary" :icon="Plus" @click="openCreateDialog">创建提号</el-button>
</div>
</div>
<div class="filter-bar">
<el-input
v-model="filters.keyword"
placeholder="提号编号 / 上架编号 / 账号标题"
clearable
style="width: 240px"
@keyup.enter="handleSearch"
/>
<el-select v-model="filters.status" placeholder="状态" clearable style="width: 130px">
<el-option label="提号中" value="picking_up" />
<el-option label="已完成" value="completed" />
<el-option label="已取消" value="cancelled" />
</el-select>
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
<el-button :icon="Refresh" @click="loadList">刷新</el-button>
</div>
<el-table v-loading="loading" :data="items" class="table-panel">
<el-table-column prop="pickup_no" label="提号编号" width="190" />
<el-table-column prop="account_title" label="账号" min-width="180" />
<el-table-column label="区服/平台" width="150">
<template #default="{ row }">{{ row.server_region || '-' }} / {{ row.login_platform || '-' }}</template>
</el-table-column>
<el-table-column label="上架编号" width="120">
<template #default="{ row }">{{ row.listing_no }}</template>
</el-table-column>
<el-table-column label="卖家" width="140">
<template #default="{ row }">{{ row.owner_phone }}</template>
</el-table-column>
<el-table-column label="交易渠道" width="110">
<template #default="{ row }">{{ row.platform || '-' }}</template>
</el-table-column>
<el-table-column label="结算金额" width="130">
<template #default="{ row }">
<span v-if="row.status === 'completed'">{{ formatCentWithSymbol(row.settle_amount_cent) }}</span>
<span v-else class="text-muted">待结算</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" effect="light">
{{ pickupStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button
v-if="row.status === 'picking_up'"
type="primary"
size="small"
@click="openCompleteDialog(row.id)"
>
完成结算
</el-button>
<el-button
v-if="row.status === 'picking_up'"
type="danger"
size="small"
plain
@click="handleCancel(row.id)"
>
取消
</el-button>
<span v-if="row.status !== 'picking_up'" class="text-muted"></span>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper">
<el-pagination
:current-page="filters.page"
:page-size="filters.page_size"
:page-sizes="[20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
<!-- 创建提号 -->
<el-dialog v-model="createDialogVisible" title="创建线下提号" width="520px">
<el-form label-width="110px">
<el-form-item label="选择账号" required>
<el-select
v-model="createForm.listing_id"
filterable
remote
clearable
:remote-method="searchListings"
:loading="listingLoading"
placeholder="输入上架编号或账号标题搜索"
style="width: 100%"
>
<el-option
v-for="item in listingOptions"
:key="item.id"
:label="`${item.listing_no} · ${item.account_title}`"
:value="item.id"
/>
</el-select>
<div class="form-hint">仅显示已发布已审核未在交易中的账号</div>
</el-form-item>
<el-form-item label="交易渠道">
<el-input v-model="createForm.platform" placeholder="如 微信 / QQ / 闲鱼" />
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="createForm.remark"
type="textarea"
:rows="3"
placeholder="线下交易说明(可选)"
/>
</el-form-item>
<el-alert type="info" :closable="false">
创建后账号将被锁定为"提号中"卖家收到通知不会立即结算需手动完成结算
</el-alert>
</el-form>
<template #footer>
<el-button @click="createDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleCreate">创建提号</el-button>
</template>
</el-dialog>
<!-- 完成结算 -->
<el-dialog v-model="completeDialogVisible" title="完成提号结算" width="480px">
<el-form label-width="110px">
<el-form-item label="结算金额(元)" required>
<el-input-number
v-model="completeForm.settle_amount"
:min="0.01"
:step="1"
:precision="2"
controls-position="right"
style="width: 100%"
/>
<div class="form-hint">给卖家钱包增加的可用余额</div>
</el-form-item>
<el-form-item label="完成备注">
<el-input
v-model="completeForm.complete_remark"
type="textarea"
:rows="3"
placeholder="结算说明(可选)"
/>
</el-form-item>
<el-alert type="warning" :closable="false">
完成后订单置为"已完成"卖家钱包立即入账账号下架不可再上架操作不可撤销
</el-alert>
</el-form>
<template #footer>
<el-button @click="completeDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleComplete">确认结算</el-button>
</template>
</el-dialog>
</section>
</template>
<style scoped>
.form-hint {
margin-top: 4px;
font-size: 12px;
color: #64748b;
}
.text-muted {
color: #94a3b8;
}
</style>
@@ -0,0 +1,32 @@
import { apiClient } from '@/shared/api/client'
import type { ApiResponse, PaginatedResult } from '@/shared/types/types'
import type { AdminPickup } from '@/features/admin/api/adminPickup'
export interface SellerPickupListQuery {
page?: number
page_size?: number
status?: string
}
function cleanParams(query: object) {
return Object.fromEntries(
Object.entries(query).filter(([, value]) => value !== '' && value !== undefined)
)
}
// 复用 AdminPickup 类型:卖家视角字段集合相同(admin_id 也会返回,前端不展示即可)
export type SellerPickup = AdminPickup
export async function fetchSellerPickups(query: SellerPickupListQuery = {}) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<SellerPickup>>>(
'/seller/pickups',
{ params: cleanParams(query) }
)
const result = data.data
return {
items: Array.isArray(result?.items) ? result.items : [],
total: Number(result?.total ?? 0),
page: Number(result?.page ?? query.page ?? 1),
page_size: Number(result?.page_size ?? query.page_size ?? 20),
}
}
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { Refresh, Search } from '@element-plus/icons-vue'
import { fetchSellerPickups, type SellerPickup } from '@/features/seller/api/sellerPickup'
import { formatCentWithSymbol } from '@/shared/utils/money'
import { formatDateTime } from '@/shared/utils/time'
import { pickupStatusLabel } from '@/shared/utils/statusLabels'
const loading = ref(false)
const items = ref<SellerPickup[]>([])
const total = ref(0)
const filters = reactive({ page: 1, page_size: 20, status: '' })
onMounted(loadList)
async function loadList() {
loading.value = true
try {
const result = await fetchSellerPickups(filters)
items.value = result.items
total.value = result.total
} finally {
loading.value = false
}
}
function handleSearch() {
filters.page = 1
loadList()
}
function handlePageChange(p: number) {
filters.page = p
loadList()
}
function handleSizeChange(s: number) {
filters.page_size = s
filters.page = 1
loadList()
}
function statusType(status: string) {
if (status === 'picking_up') return 'warning'
if (status === 'completed') return 'success'
if (status === 'cancelled') return 'info'
return ''
}
</script>
<template>
<section class="page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Seller Pickup</p>
<h1>提号记录</h1>
<p>您的账号被管理员线下提号的记录完成后结算金额会进入钱包余额</p>
</div>
</div>
<div class="filter-bar">
<el-select v-model="filters.status" placeholder="状态" clearable style="width: 130px">
<el-option label="提号中" value="picking_up" />
<el-option label="已完成" value="completed" />
<el-option label="已取消" value="cancelled" />
</el-select>
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
<el-button :icon="Refresh" @click="loadList">刷新</el-button>
</div>
<el-table v-loading="loading" :data="items" class="table-panel">
<el-table-column prop="pickup_no" label="提号编号" width="190" />
<el-table-column prop="account_title" label="账号" min-width="180" />
<el-table-column label="区服/平台" width="150">
<template #default="{ row }">{{ row.server_region || '-' }} / {{ row.login_platform || '-' }}</template>
</el-table-column>
<el-table-column label="交易渠道" width="110">
<template #default="{ row }">{{ row.platform || '-' }}</template>
</el-table-column>
<el-table-column label="结算金额" width="140">
<template #default="{ row }">
<span v-if="row.status === 'completed'" class="amount-in">
{{ formatCentWithSymbol(row.settle_amount_cent) }}
</span>
<span v-else class="text-muted"></span>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" effect="light">
{{ pickupStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="时间" width="170">
<template #default="{ row }">
<span v-if="row.status === 'completed' && row.completed_at">
{{ formatDateTime(row.completed_at) }}
</span>
<span v-else>{{ formatDateTime(row.created_at) }}</span>
</template>
</el-table-column>
<el-table-column label="备注" min-width="160">
<template #default="{ row }">
<span v-if="row.status === 'completed' && row.complete_remark">{{ row.complete_remark }}</span>
<span v-else-if="row.remark">{{ row.remark }}</span>
<span v-else class="text-muted"></span>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper">
<el-pagination
:current-page="filters.page"
:page-size="filters.page_size"
:page-sizes="[20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
</section>
</template>
<style scoped>
.text-muted {
color: #94a3b8;
}
.amount-in {
color: #16a34a;
font-weight: 600;
}
</style>
@@ -112,6 +112,7 @@ function walletBizTypeLabel(type: string) {
checkout_refund: '结账退款',
channel_deposit_refund: '押金退还',
withdraw_apply: '申请提现',
admin_pickup_settle: '线下提号结算',
}
return map[type] || type || '-'
}
+7
View File
@@ -19,6 +19,7 @@ import {
Service,
Shop,
Setting,
Switch,
SwitchButton,
Tickets,
Tools,
@@ -89,6 +90,12 @@ const allNavGroups: NavGroup[] = [
icon: Money,
permission: 'order:close',
},
{
label: '线下提号',
to: adminPath('pickup'),
icon: Switch,
permission: 'order:pickup',
},
{ label: '商品管理', to: adminPath('listings'), icon: Shop, permission: 'listing:view' },
{
label: '商品审核',
+9 -7
View File
@@ -16,6 +16,7 @@ import {
Service,
Shop,
Star,
Switch,
SwitchButton,
Tickets,
UserFilled,
@@ -48,6 +49,7 @@ const buyerServiceItems = [
const sellerServiceItems = [
{ label: '发布商品', icon: CirclePlus, to: '/seller/listings/create' },
{ label: '我的商品', icon: Shop, to: '/seller/listings' },
{ label: '提号记录', icon: Switch, to: '/seller/pickups' },
{ label: '提现/账单', icon: Wallet, to: '/wallet' },
]
@@ -886,27 +888,27 @@ async function handleSupportClick() {
.dropdown-seller-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.dropdown-seller-card {
display: grid;
min-width: 0;
min-height: 74px;
place-items: center;
align-items: center;
gap: 8px;
min-height: 72px;
padding: 10px 6px;
gap: 6px;
padding: 8px 4px;
border-radius: 10px;
background: #f8fafc;
color: #475569;
font-size: 13px;
font-size: 12px;
font-weight: 800;
text-align: center;
}
.dropdown-seller-card .el-icon {
color: #94a3b8;
color: #ff6a00;
font-size: 18px;
}
+6
View File
@@ -36,6 +36,12 @@ export const adminRoutes: RouteRecordRaw[] = [
component: () => import('@/features/admin/views/AdminRefundReviewView.vue'),
meta: adminMeta,
},
{
path: adminPath('pickup'),
name: 'admin-pickup',
component: () => import('@/features/admin/views/AdminPickupView.vue'),
meta: adminMeta,
},
{
path: adminPath('orders/:id'),
name: 'admin-order-detail',
+6
View File
@@ -31,4 +31,10 @@ export const sellerRoutes: RouteRecordRaw[] = [
component: () => import('@/features/seller/views/SellerEarningsView.vue'),
meta: { requiresAuth: true },
},
{
path: '/seller/pickups',
name: 'seller-pickups',
component: () => import('@/features/seller/views/SellerPickupsView.vue'),
meta: { requiresAuth: true },
},
]
+10
View File
@@ -134,6 +134,12 @@ const balanceTypeMap: Record<BalanceType, string> = {
frozen: '冻结余额',
}
const pickupStatusMap: Record<string, string> = {
picking_up: '提号中',
completed: '已完成',
cancelled: '已取消',
}
function readLabel(map: Record<string, string>, value: string) {
return map[value] || value || '-'
}
@@ -189,3 +195,7 @@ export function ledgerDirectionLabel(direction: string) {
export function balanceTypeLabel(type: string) {
return readLabel(balanceTypeMap, type)
}
export function pickupStatusLabel(status: string) {
return readLabel(pickupStatusMap, status)
}