feat: 假单管理后台页面(商品管理权限)

商品管理菜单下新增假单 CRUD 页,按俱乐部配置各商品类型假单,支持头像上传。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-05 22:28:14 +08:00
parent 4e2e7604c3
commit 21f56619b6
4 changed files with 531 additions and 0 deletions

View File

@@ -228,6 +228,11 @@ const routes = [
component: () => import('@/views/product/Data.vue'),
meta: { title: '商品数据分析' }
},
{
path: 'product/fake-orders',
component: () => import('@/views/product/FakeGrabOrderManage.vue'),
meta: { title: '假单管理' }
},
{
path: 'product/detail/:id',
component: () => import('@/views/product/Detail.vue'),

View File

@@ -317,6 +317,9 @@ export const JITUAN_API = {
miniappIconList: '/jituan/houtai/miniapp-icon-list',
miniappIconManage: '/jituan/houtai/miniapp-icon-manage',
pindaoManage: '/jituan/houtai/pindao-manage',
fakeGrabOrderList: '/jituan/houtai/fake-grab-order-list',
fakeGrabOrderManage: '/jituan/houtai/fake-grab-order-manage',
fakeGrabOrderAvatar: '/jituan/houtai/fake-grab-order-avatar',
clubLeixingList: '/jituan/houtai/club-leixing-list',
clubLeixingCatalog: '/jituan/houtai/club-leixing-catalog',
clubLeixingEnable: '/jituan/houtai/club-leixing-enable',

View File

@@ -120,6 +120,7 @@
<el-menu-item v-if="canShow('product.list') && isAllClubScope" index="/product/list">商品列表</el-menu-item>
<el-menu-item v-if="canShow('product.type-zone') && isAllClubScope" index="/product/type-zone">商品类型专区管理</el-menu-item>
<el-menu-item v-if="canShow('product.data') && isAllClubScope" index="/product/data">商品数据分析</el-menu-item>
<el-menu-item v-if="canShow('product.fake-orders') && !isAllClubScope" index="/product/fake-orders">假单管理</el-menu-item>
</el-sub-menu>
<!-- 会员管理 -->

View File

@@ -0,0 +1,522 @@
<template>
<div class="fake-order-page">
<div v-if="!hasPermission" class="no-permission">
<div class="no-permission-icon">🔒</div>
<div class="no-permission-text">您无权访问此页面</div>
<div class="no-permission-desc">需要商品管理权限2200a</div>
</div>
<div v-else-if="isAllClubScope" class="scope-block">
<el-alert
type="warning"
:closable="false"
show-icon
title="请切换到具体俱乐部"
description="假单按俱乐部独立配置。请先在顶栏选择具体小程序/俱乐部,不要用「集团汇总」。"
/>
</div>
<div v-else>
<ClubScopeHint />
<el-alert
type="info"
:closable="false"
show-icon
class="tip"
title="假单说明"
description="无会员/低押金打手在抢单大厅仅能看到假单(不可真正接单)。按商品类型 Tab 展示:有什么类型就配什么类型的假单。所有全局商品类型均可绑定。"
/>
<div class="toolbar">
<el-select
v-model="filterLeixingId"
clearable
filterable
placeholder="全部商品类型"
class="dark-select"
style="width: 220px"
@change="search"
>
<el-option
v-for="opt in leixingOptions"
:key="opt.id"
:label="`${opt.jieshao || '类型'} (ID ${opt.id})`"
:value="opt.id"
/>
</el-select>
<el-button type="primary" @click="openCreate">新建假单</el-button>
<el-button @click="loadList" :loading="loading">刷新</el-button>
</div>
<div class="table-wrap" v-loading="loading">
<el-table :data="list" border class="fake-order-table" row-key="order_id">
<el-table-column label="头像" width="72">
<template #default="{ row }">
<el-image :src="fullUrl(row.paidan_touxiang)" fit="cover" class="avatar-thumb" />
</template>
</el-table-column>
<el-table-column prop="order_id" label="订单ID" min-width="170" show-overflow-tooltip />
<el-table-column label="商品类型" min-width="130">
<template #default="{ row }">
<div>{{ row.leixing_name }}</div>
<div class="sub">ID {{ row.leixing_id }}</div>
</template>
</el-table-column>
<el-table-column label="价格(打手分红)" width="120">
<template #default="{ row }">¥{{ formatMoney(row.amount) }}</template>
</el-table-column>
<el-table-column prop="platform_label" label="平台" width="90" />
<el-table-column prop="shangjia_nicheng" label="商家名" min-width="100" show-overflow-tooltip />
<el-table-column label="优质" width="70">
<template #default="{ row }">
<el-tag v-if="row.is_youzhi_shangjia" size="small" type="warning"></el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="jieshao" label="介绍" min-width="160" show-overflow-tooltip />
<el-table-column prop="beizhu" label="备注" min-width="100" show-overflow-tooltip />
<el-table-column prop="sort_order" label="排序" width="70" />
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button size="small" link type="danger" @click="confirmDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@current-change="loadList"
@size-change="onSizeChange"
/>
</div>
</div>
</div>
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑假单' : '新建假单'"
width="680px"
destroy-on-close
@close="resetForm"
>
<el-form :model="form" :rules="rules" ref="formRef" label-width="120px">
<el-form-item label="商品类型" prop="leixing_id">
<el-select v-model="form.leixing_id" filterable placeholder="选择商品类型" style="width: 100%">
<el-option
v-for="opt in leixingOptions"
:key="opt.id"
:label="`${opt.jieshao || '类型'} (ID ${opt.id})`"
:value="opt.id"
/>
</el-select>
</el-form-item>
<el-form-item label="价格(元)" prop="amount">
<el-input-number v-model="form.amount" :min="0" :precision="2" :step="1" style="width: 100%" />
</el-form-item>
<el-form-item label="发单平台" prop="fadan_pingtai">
<el-radio-group v-model="form.fadan_pingtai">
<el-radio :value="1">平台发单</el-radio>
<el-radio :value="2">商家发单</el-radio>
</el-radio-group>
</el-form-item>
<template v-if="form.fadan_pingtai === 2">
<el-form-item label="商家名称">
<el-input v-model="form.shangjia_nicheng" maxlength="50" placeholder="商家发单时展示" />
</el-form-item>
<el-form-item label="优质商家">
<el-switch v-model="form.is_youzhi_shangjia" />
</el-form-item>
</template>
<el-form-item label="订单介绍">
<el-input v-model="form.jieshao" type="textarea" :rows="4" placeholder="抢单大厅展示的介绍文案" />
</el-form-item>
<el-form-item label="订单备注">
<el-input v-model="form.beizhu" type="textarea" :rows="2" placeholder="选填" />
</el-form-item>
<el-form-item label="派单用户ID">
<el-input v-model="form.paidan_yonghuid" maxlength="32" placeholder="选填,展示用" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sort_order" :min="0" :max="9999" style="width: 100%" />
<span class="hint">数值越大越靠前打乱前基准顺序</span>
</el-form-item>
<el-form-item label="展示头像">
<div class="avatar-upload">
<el-image v-if="form.paidan_touxiang" :src="fullUrl(form.paidan_touxiang)" fit="cover" class="avatar-preview" />
<el-upload
v-if="isEdit"
action="#"
:auto-upload="false"
:show-file-list="false"
accept="image/jpeg,image/png,image/jpg,image/webp"
:on-change="uploadAvatar"
>
<el-button size="small" type="primary" :loading="uploading">上传头像</el-button>
</el-upload>
<span v-else-if="!form.paidan_touxiang" class="hint">创建后可编辑并上传</span>
</div>
</el-form-item>
<el-form-item v-if="isEdit" label="订单ID">
<el-input :model-value="form.order_id" disabled />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submitForm">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/utils/request'
import ClubScopeHint from '@/components/ClubScopeHint.vue'
import { JITUAN_API, getAdminClubScope } from '@/utils/club-context'
const username = ref(localStorage.getItem('username') || '')
const hasPermission = ref(true)
const loading = ref(false)
const saving = ref(false)
const uploading = ref(false)
const list = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const leixingOptions = ref([])
const filterLeixingId = ref('')
const dialogVisible = ref(false)
const isEdit = ref(false)
const formRef = ref(null)
const ossBaseUrl = ref(window.$ossURL || '')
const isAllClubScope = computed(() => getAdminClubScope() === 'all')
const defaultForm = () => ({
order_id: '',
leixing_id: '',
amount: 0,
fadan_pingtai: 1,
shangjia_nicheng: '',
is_youzhi_shangjia: false,
jieshao: '',
beizhu: '',
paidan_yonghuid: '',
paidan_touxiang: '',
sort_order: 0,
})
const form = ref(defaultForm())
const rules = {
leixing_id: [{ required: true, message: '请选择商品类型', trigger: 'change' }],
amount: [{ required: true, message: '请输入价格', trigger: 'blur' }],
fadan_pingtai: [{ required: true, message: '请选择平台', trigger: 'change' }],
}
function fullUrl(path) {
if (!path) return ''
if (path.startsWith('http')) return path
return ossBaseUrl.value + path
}
function formatMoney(val) {
if (val === undefined || val === null || val === '') return '0.00'
return Number(val).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
async function loadList() {
if (isAllClubScope.value) return
loading.value = true
try {
const res = await request.post(JITUAN_API.fakeGrabOrderList, {
username: username.value,
page: page.value,
page_size: pageSize.value,
leixing_id: filterLeixingId.value || undefined,
})
if (res.code === 403) {
hasPermission.value = false
return
}
if (res.code === 0) {
const data = res.data || {}
if (data.scope_error) {
ElMessage.warning(data.scope_error)
list.value = []
return
}
list.value = data.list || []
total.value = data.total || 0
leixingOptions.value = data.leixing_options || []
} else {
ElMessage.error(res.msg || '加载失败')
}
} catch (e) {
console.error(e)
ElMessage.error('加载失败')
} finally {
loading.value = false
}
}
function search() {
page.value = 1
loadList()
}
function onSizeChange() {
page.value = 1
loadList()
}
function openCreate() {
isEdit.value = false
form.value = defaultForm()
if (filterLeixingId.value) {
form.value.leixing_id = filterLeixingId.value
}
dialogVisible.value = true
}
function openEdit(row) {
isEdit.value = true
form.value = {
order_id: row.order_id,
leixing_id: row.leixing_id,
amount: Number(row.amount || 0),
fadan_pingtai: row.fadan_pingtai || 1,
shangjia_nicheng: row.shangjia_nicheng || '',
is_youzhi_shangjia: Boolean(row.is_youzhi_shangjia),
jieshao: row.jieshao || '',
beizhu: row.beizhu || '',
paidan_yonghuid: row.paidan_yonghuid || '',
paidan_touxiang: row.paidan_touxiang || '',
sort_order: row.sort_order || 0,
}
dialogVisible.value = true
}
function resetForm() {
form.value = defaultForm()
formRef.value?.resetFields()
}
async function submitForm() {
try {
await formRef.value?.validate()
} catch {
return
}
if (form.value.amount < 0) {
ElMessage.error('价格不能为负')
return
}
saving.value = true
try {
const payload = {
username: username.value,
action: isEdit.value ? 'update' : 'create',
leixing_id: form.value.leixing_id,
amount: form.value.amount,
fadan_pingtai: form.value.fadan_pingtai,
jieshao: form.value.jieshao,
beizhu: form.value.beizhu,
paidan_yonghuid: form.value.paidan_yonghuid,
sort_order: form.value.sort_order,
}
if (form.value.fadan_pingtai === 2) {
payload.shangjia_nicheng = form.value.shangjia_nicheng
payload.is_youzhi_shangjia = form.value.is_youzhi_shangjia
}
if (isEdit.value) {
payload.order_id = form.value.order_id
}
const res = await request.post(JITUAN_API.fakeGrabOrderManage, payload)
if (res.code === 0) {
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
dialogVisible.value = false
await loadList()
} else {
ElMessage.error(res.msg || '保存失败')
}
} catch (e) {
console.error(e)
ElMessage.error('保存失败')
} finally {
saving.value = false
}
}
async function uploadAvatar(file) {
if (!file?.raw || !form.value.order_id) return
uploading.value = true
try {
const fd = new FormData()
fd.append('username', username.value)
fd.append('order_id', form.value.order_id)
fd.append('file', file.raw)
const res = await request.post(JITUAN_API.fakeGrabOrderAvatar, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
if (res.code === 0) {
form.value.paidan_touxiang = res.data?.paidan_touxiang || ''
ElMessage.success('头像已更新')
await loadList()
} else {
ElMessage.error(res.msg || '上传失败')
}
} catch {
ElMessage.error('上传失败')
} finally {
uploading.value = false
}
}
async function confirmDelete(row) {
try {
await ElMessageBox.confirm(`确定删除假单 ${row.order_id} 吗?`, '确认删除', { type: 'warning' })
} catch {
return
}
try {
const res = await request.post(JITUAN_API.fakeGrabOrderManage, {
username: username.value,
action: 'delete',
order_id: row.order_id,
})
if (res.code === 0) {
ElMessage.success('已删除')
await loadList()
} else {
ElMessage.error(res.msg || '删除失败')
}
} catch {
ElMessage.error('删除失败')
}
}
onMounted(loadList)
</script>
<style scoped>
.fake-order-page {
padding: 20px;
min-height: 100vh;
color: #c0e0ff;
}
.tip { margin-bottom: 16px; }
.scope-block { padding: 24px 0; }
.toolbar {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
}
.table-wrap {
background: rgba(18, 25, 35, 0.85);
border: 1px solid rgba(64, 158, 255, 0.15);
border-radius: 12px;
padding: 16px;
}
.avatar-thumb {
width: 40px;
height: 40px;
border-radius: 6px;
border: 1px solid rgba(64, 158, 255, 0.2);
}
.avatar-preview {
width: 64px;
height: 64px;
border-radius: 8px;
margin-right: 12px;
}
.avatar-upload {
display: flex;
align-items: center;
gap: 8px;
}
.sub {
font-size: 12px;
color: #9ec5ff;
}
.hint {
margin-left: 8px;
font-size: 12px;
color: #8fb8e8;
}
.pager {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.no-permission {
text-align: center;
padding: 80px 20px;
}
.no-permission-icon { font-size: 48px; }
.no-permission-text { font-size: 20px; color: #6cc3e8; margin-top: 12px; }
.no-permission-desc { color: #8fb8e8; margin-top: 8px; }
.fake-order-table :deep(.el-table) {
--el-table-bg-color: transparent;
--el-table-header-bg-color: rgba(20, 30, 45, 0.95);
--el-table-text-color: #ffffff;
--el-table-header-text-color: #9ec5ff;
--el-table-border-color: rgba(64, 158, 255, 0.12);
background: transparent;
}
.fake-order-table :deep(.el-table th.el-table__cell),
.fake-order-table :deep(.el-table td.el-table__cell) {
background: rgba(12, 18, 28, 0.55) !important;
color: #ffffff !important;
}
.fake-order-table :deep(.el-table__body tr:hover > td.el-table__cell) {
background: rgba(64, 158, 255, 0.1) !important;
}
.toolbar :deep(.el-input__wrapper),
.toolbar :deep(.el-select .el-input__wrapper),
:deep(.el-dialog .el-input__wrapper),
:deep(.el-dialog .el-select .el-input__wrapper),
:deep(.el-dialog .el-input-number .el-input__wrapper),
:deep(.el-dialog .el-textarea__inner) {
background: #0a121c !important;
border: 1px solid #2a6f8f !important;
box-shadow: none !important;
}
:deep(.el-dialog .el-input__inner),
:deep(.el-dialog .el-textarea__inner),
:deep(.el-dialog .el-input-number__input) {
color: #ffffff !important;
}
:deep(.el-dialog) {
background: rgba(18, 25, 35, 0.96);
border: 1px solid #2a6f8f;
}
:deep(.el-dialog__title) {
color: #6cc3e8;
}
:deep(.el-form-item__label) {
color: #9ec5ff !important;
}
.pager :deep(.el-pagination .el-select .el-input__wrapper),
.pager :deep(.el-pagination .btn-prev),
.pager :deep(.el-pagination .btn-next),
.pager :deep(.el-pagination .el-pager li) {
background: #0a121c !important;
border: 1px solid #2a6f8f !important;
color: #9ec5ff !important;
}
.pager :deep(.el-pagination__total) {
color: #9ec5ff !important;
}
</style>