feat: add web application
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="n-layout-page-header">
|
||||
<n-card :bordered="false" title="客服管理">
|
||||
配置客服入口、工作时间和会话映射;第三方服务不可用时由 C 端展示备用联系方式和错误编号。
|
||||
</n-card>
|
||||
</div>
|
||||
<n-card :bordered="false" class="proCard">
|
||||
<BasicForm
|
||||
ref="searchFormRef"
|
||||
@register="register"
|
||||
@submit="reloadTable"
|
||||
@reset="reloadTable"
|
||||
/>
|
||||
<BasicTable
|
||||
ref="actionRef"
|
||||
:columns="columns"
|
||||
:request="loadDataTable"
|
||||
:row-key="(row) => row.id"
|
||||
:action-column="actionColumn"
|
||||
:scroll-x="scrollX"
|
||||
:resize-height-offset="-10000"
|
||||
>
|
||||
<template #tableTitle>
|
||||
<n-button v-if="hasPermission(['/customerService/edit'])" type="primary" @click="edit()">
|
||||
新增客服
|
||||
</n-button>
|
||||
</template>
|
||||
</BasicTable>
|
||||
</n-card>
|
||||
|
||||
<n-modal
|
||||
v-model:show="showEdit"
|
||||
preset="dialog"
|
||||
:title="form.id ? '编辑客服' : '新增客服'"
|
||||
:show-icon="false"
|
||||
>
|
||||
<n-form ref="editFormRef" :model="form" :rules="rules" label-placement="top">
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-form-item-gi label="客服编码" path="serviceCode">
|
||||
<n-input v-model:value="form.serviceCode" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="客服名称" path="name">
|
||||
<n-input v-model:value="form.name" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="工作时间" path="workTime">
|
||||
<n-input v-model:value="form.workTime" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="在线状态" path="onlineStatus">
|
||||
<n-select
|
||||
v-model:value="form.onlineStatus"
|
||||
:options="dict.getOptionUnRef('customerServiceOnline')"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="状态" path="status">
|
||||
<n-select
|
||||
v-model:value="form.status"
|
||||
:options="dict.getOptionUnRef('sys_normal_disable')"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="排序">
|
||||
<n-input-number v-model:value="form.sort" />
|
||||
</n-form-item-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="头像地址">
|
||||
<n-input v-model:value="form.avatarUrl" />
|
||||
</n-form-item>
|
||||
<n-form-item label="客服跳转 URL" path="serviceUrl">
|
||||
<n-input v-model:value="form.serviceUrl" placeholder="必须使用 HTTPS" />
|
||||
</n-form-item>
|
||||
<n-form-item label="备用联系方式" path="backupContact">
|
||||
<n-input v-model:value="form.backupContact" />
|
||||
</n-form-item>
|
||||
<n-form-item label="备注">
|
||||
<n-input v-model:value="form.remark" type="textarea" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #action>
|
||||
<n-space>
|
||||
<n-button @click="showEdit = false">取消</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="save">保存</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
<n-modal v-model:show="showDelete" preset="dialog" title="删除客服" :show-icon="false">
|
||||
<n-alert type="error" :show-icon="false">仅停用状态允许删除。</n-alert>
|
||||
<n-form-item
|
||||
label="删除原因"
|
||||
:validation-status="deleteReasonError ? 'error' : undefined"
|
||||
:feedback="deleteReasonError"
|
||||
>
|
||||
<n-input v-model:value="deleteReason" type="textarea" @input="deleteReasonError = ''" />
|
||||
</n-form-item>
|
||||
<template #action>
|
||||
<n-space>
|
||||
<n-button @click="showDelete = false">取消</n-button>
|
||||
<n-button type="error" :loading="deleting" @click="remove">确认删除</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { BasicTable, TableAction } from '@/components/Table';
|
||||
import { BasicForm, useForm } from '@/components/Form';
|
||||
import { usePermission } from '@/hooks/web/usePermission';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { adaTableScrollX } from '@/utils/hotgo';
|
||||
import {
|
||||
CustomerServiceDelete,
|
||||
CustomerServiceEdit,
|
||||
CustomerServiceList,
|
||||
CustomerServiceStatus,
|
||||
CustomerServiceView,
|
||||
} from '@/api/trade';
|
||||
import { columns, loadOptions, schemas, State } from './model';
|
||||
|
||||
const message = useMessage();
|
||||
const dict = useDictStore();
|
||||
const { hasPermission } = usePermission();
|
||||
const actionRef = ref();
|
||||
const searchFormRef = ref<any>({});
|
||||
const editFormRef = ref();
|
||||
const showEdit = ref(false);
|
||||
const showDelete = ref(false);
|
||||
const saving = ref(false);
|
||||
const deleting = ref(false);
|
||||
const deleteId = ref(0);
|
||||
const deleteReason = ref('');
|
||||
const deleteReasonError = ref('');
|
||||
const form = reactive(new State());
|
||||
const rules = {
|
||||
serviceCode: { required: true, message: '请输入客服编码', trigger: ['input', 'blur'] },
|
||||
name: { required: true, message: '请输入客服名称', trigger: ['input', 'blur'] },
|
||||
workTime: { required: true, message: '请输入工作时间', trigger: ['input', 'blur'] },
|
||||
onlineStatus: { required: true, type: 'number', message: '请选择在线状态', trigger: 'change' },
|
||||
status: { required: true, type: 'number', message: '请选择状态', trigger: 'change' },
|
||||
serviceUrl: {
|
||||
required: true,
|
||||
pattern: /^https:\/\/.+/i,
|
||||
message: '请输入有效的 HTTPS 地址',
|
||||
trigger: ['input', 'blur'],
|
||||
},
|
||||
backupContact: { required: true, message: '请输入备用联系方式', trigger: ['input', 'blur'] },
|
||||
};
|
||||
const [register] = useForm({
|
||||
gridProps: { cols: '1 s:1 m:2 l:3 xl:4' },
|
||||
labelWidth: 80,
|
||||
schemas,
|
||||
});
|
||||
const loadDataTable = (res) => CustomerServiceList({ ...searchFormRef.value?.formModel, ...res });
|
||||
const reloadTable = () => actionRef.value?.reload();
|
||||
|
||||
async function edit(row?: any) {
|
||||
Object.assign(form, new State());
|
||||
if (row) Object.assign(form, await CustomerServiceView({ id: row.id }));
|
||||
showEdit.value = true;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
await editFormRef.value?.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await CustomerServiceEdit(form);
|
||||
message.success('客服配置已保存');
|
||||
showEdit.value = false;
|
||||
reloadTable();
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function status(row) {
|
||||
await CustomerServiceStatus({ id: row.id, status: row.status === 1 ? 2 : 1 });
|
||||
message.success('客服状态已更新');
|
||||
reloadTable();
|
||||
}
|
||||
|
||||
function openDelete(row) {
|
||||
deleteId.value = row.id;
|
||||
deleteReason.value = '';
|
||||
deleteReasonError.value = '';
|
||||
showDelete.value = true;
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!deleteReason.value.trim()) {
|
||||
deleteReasonError.value = '请输入删除原因';
|
||||
return;
|
||||
}
|
||||
deleting.value = true;
|
||||
try {
|
||||
await CustomerServiceDelete({ id: deleteId.value, reason: deleteReason.value.trim() });
|
||||
message.success('客服配置已删除');
|
||||
showDelete.value = false;
|
||||
reloadTable();
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const actionColumn = reactive({
|
||||
width: 190,
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
render: (row) =>
|
||||
h(TableAction as any, {
|
||||
style: 'button',
|
||||
actions: [
|
||||
{ label: '编辑', onClick: () => edit(row), auth: ['/customerService/edit'] },
|
||||
{
|
||||
label: row.status === 1 ? '停用' : '启用',
|
||||
onClick: () => status(row),
|
||||
auth: ['/customerService/status'],
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
disabled: row.status === 1,
|
||||
onClick: () => openDelete(row),
|
||||
auth: ['/customerService/delete'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
const scrollX = computed(() => adaTableScrollX(columns, actionColumn.width));
|
||||
onMounted(loadOptions);
|
||||
</script>
|
||||
@@ -0,0 +1,18 @@
|
||||
import { computed, h, ref } from 'vue';
|
||||
import { NTag } from 'naive-ui';
|
||||
import { FormSchema } from '@/components/Form';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
const dict=useDictStore();
|
||||
export class State { id=0;serviceCode='';name='';avatarUrl='';workTime='09:00-18:00';onlineStatus=2;serviceUrl='https://';backupContact='';status=1;sort=0;remark=''; }
|
||||
export const schemas=ref<FormSchema[]>([
|
||||
{field:'name',component:'NInput',label:'客服名称',componentProps:{clearable:true}},
|
||||
{field:'status',component:'NSelect',label:'状态',componentProps:{options:computed(()=>dict.getOptionUnRef('sys_normal_disable')),clearable:true}},
|
||||
]);
|
||||
const tag=(type,value)=>h(NTag,{type:dict.getType(type,value),bordered:false},{default:()=>dict.getLabel(type,value)||'-'});
|
||||
export const columns=[
|
||||
{title:'编码',key:'serviceCode',width:130},{title:'客服名称',key:'name',width:140},{title:'工作时间',key:'workTime',width:150},
|
||||
{title:'在线状态',key:'onlineStatus',width:100,render:(r)=>tag('customerServiceOnline',r.onlineStatus)},{title:'跳转 URL',key:'serviceUrl',width:260,ellipsis:{tooltip:true}},
|
||||
{title:'备用联系方式',key:'backupContact',width:220,ellipsis:{tooltip:true}},{title:'状态',key:'status',width:90,render:(r)=>tag('sys_normal_disable',r.status)},
|
||||
{title:'排序',key:'sort',width:80},{title:'创建时间',key:'createdAt',width:180},
|
||||
];
|
||||
export const loadOptions=()=>dict.loadOptions(['customerServiceOnline','sys_normal_disable']);
|
||||
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="n-layout-page-header"><n-card :bordered="false" title="订单列表">“匹配中”的订单可在操作栏选择商品完成匹配。</n-card></div>
|
||||
<n-card :bordered="false" class="proCard">
|
||||
<BasicForm ref="searchFormRef" @register="register" @submit="reloadTable" @reset="reloadTable" />
|
||||
<BasicTable ref="actionRef" :columns="columns" :request="loadDataTable" :row-key="(row)=>row.id" :action-column="actionColumn" :scroll-x="scrollX" :resize-height-offset="-10000">
|
||||
<template #tableTitle><n-space align="center"><n-switch v-model:value="autoRefresh"/><span>自动刷新</span><span v-if="autoRefresh">({{ refreshCountdown }}S)</span></n-space></template>
|
||||
</BasicTable>
|
||||
</n-card>
|
||||
<n-modal v-model:show="showMatch" preset="card" title="匹配订单" class="match-order-modal" :mask-closable="false">
|
||||
<n-spin :show="matchLoading">
|
||||
<n-form label-placement="left" :label-width="90">
|
||||
<n-form-item label="用户名称"><n-input :value="matchMember.username" disabled /></n-form-item>
|
||||
<n-form-item label="账号余额"><n-input :value="yuanPlain(matchMember.availableAmount)" disabled /></n-form-item>
|
||||
<n-form-item label="冻结金额"><n-input :value="yuanPlain(matchMember.frozenAmount)" disabled /></n-form-item>
|
||||
<n-form-item label="选择商品">
|
||||
<n-space vertical class="match-product-area">
|
||||
<n-space align="center" wrap>
|
||||
<span>商品名称</span><n-input v-model:value="productFilter.name" placeholder="商品名称" clearable class="filter-name" />
|
||||
<span>商品价格</span><n-input-number v-model:value="productFilter.priceMin" :min="0" placeholder="最低价" clearable class="filter-price" />
|
||||
<span>-</span><n-input-number v-model:value="productFilter.priceMax" :min="0" placeholder="最高价" clearable class="filter-price" />
|
||||
<n-button type="primary" @click="searchProducts">筛选商品</n-button>
|
||||
<n-button @click="clearProductFilter">清理缓存</n-button>
|
||||
</n-space>
|
||||
<n-data-table
|
||||
:columns="productColumns"
|
||||
:data="products"
|
||||
:loading="productLoading"
|
||||
:row-key="(row) => row.id"
|
||||
:max-height="410"
|
||||
striped
|
||||
/>
|
||||
<n-pagination
|
||||
v-if="productPager.total > productPager.pageSize"
|
||||
v-model:page="productPager.page"
|
||||
:page-size="productPager.pageSize"
|
||||
:item-count="productPager.total"
|
||||
@update:page="loadProducts"
|
||||
/>
|
||||
</n-space>
|
||||
</n-form-item>
|
||||
<n-form-item label="商品单价">
|
||||
<n-input :value="selectedProduct ? yuanPlain(selectedProduct.priceAmount) : ''" disabled placeholder="请先选择商品" />
|
||||
</n-form-item>
|
||||
<n-form-item label="商品数量">
|
||||
<n-input-number v-model:value="matchForm.quantity" :min="1" :max="999" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-spin>
|
||||
<template #action>
|
||||
<n-space>
|
||||
<n-button @click="showMatch=false">取消</n-button>
|
||||
<n-button type="primary" :loading="matchSubmitting" @click="confirmMatch">确定</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showDetail" preset="card" title="订单详情" class="max-w-900px">
|
||||
<n-descriptions v-if="detail" bordered label-placement="left" :column="2">
|
||||
<n-descriptions-item label="订单号">{{ detail.orderNo }}</n-descriptions-item><n-descriptions-item label="会员">{{ detail.username }} / {{ detail.mobileMask }}</n-descriptions-item>
|
||||
<n-descriptions-item label="商品/任务">{{ detail.productName || '-' }}</n-descriptions-item><n-descriptions-item label="规则等级ID">{{ detail.memberLevelId }}</n-descriptions-item>
|
||||
<n-descriptions-item label="订单金额">{{ yuan(detail.totalAmount) }}</n-descriptions-item><n-descriptions-item label="佣金">{{ yuan(detail.commissionAmount) }}</n-descriptions-item>
|
||||
<n-descriptions-item label="规则快照" :span="2"><n-code :code="JSON.stringify(detail.ruleSnapshot || {}, null, 2)" language="json" word-wrap /></n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { computed, h, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { NButton, NImage, NTag, useMessage } from 'naive-ui';
|
||||
import { BasicTable, TableAction } from '@/components/Table';import { BasicForm,useForm } from '@/components/Form';import { adaTableScrollX } from '@/utils/hotgo';
|
||||
import { TradeOrderList,TradeOrderMatch,TradeOrderMatchProducts,TradeOrderView } from '@/api/trade';import { columns,schemas,loadOptions } from './model';
|
||||
const message=useMessage();const actionRef=ref(),searchFormRef=ref<any>({}),showDetail=ref(false),showMatch=ref(false),detail=ref<any>(),current=ref<any>(),autoRefresh=ref(false),refreshCountdown=ref(10);let timer:any;
|
||||
const [register]=useForm({gridProps:{cols:'1 s:1 m:2 l:3 xl:5'},labelWidth:80,schemas});const loadDataTable=(res)=>TradeOrderList({...searchFormRef.value?.formModel,...res});const reloadTable=()=>actionRef.value?.reload();const yuan=(v)=>`¥${((v||0)/100).toFixed(2)}`;
|
||||
const yuanPlain=(v)=>((v||0)/100).toFixed(2);
|
||||
const matchLoading=ref(false),productLoading=ref(false),matchSubmitting=ref(false),products=ref<any[]>([]),selectedProduct=ref<any>();
|
||||
const matchMember=reactive({id:0,username:'',availableAmount:0,frozenAmount:0});
|
||||
const matchForm=reactive({id:0,productId:0,quantity:1});
|
||||
const productFilter=reactive<{name:string;priceMin:number|null;priceMax:number|null}>({name:'',priceMin:null,priceMax:null});
|
||||
const productPager=reactive({page:1,pageSize:10,total:0});
|
||||
const productColumns=[
|
||||
{title:'图片',key:'coverUrl',width:90,render:(row)=>h(NImage,{width:54,height:54,objectFit:'cover',src:row.coverUrl,previewDisabled:true})},
|
||||
{title:'商品名称',key:'name',minWidth:300,ellipsis:{tooltip:true}},
|
||||
{title:'商品单价',key:'priceAmount',width:130,sorter:'default',render:(row)=>yuanPlain(row.priceAmount)},
|
||||
{title:'操作',key:'action',width:100,render:(row)=>selectedProduct.value?.id===row.id?h(NTag,{type:'success',bordered:false},{default:()=> '已选择'}):h(NButton,{size:'small',type:'primary',onClick:()=>selectProduct(row)},{default:()=> '选择'})},
|
||||
];
|
||||
async function openMatch(row){
|
||||
current.value=row;Object.assign(matchMember,{id:row.memberId,username:row.username,availableAmount:row.availableAmount,frozenAmount:row.frozenAmount||0});
|
||||
Object.assign(matchForm,{id:row.id,productId:0,quantity:1});Object.assign(productFilter,{name:'',priceMin:null,priceMax:null});selectedProduct.value=undefined;productPager.page=1;showMatch.value=true;await loadProducts();
|
||||
}
|
||||
async function loadProducts(){
|
||||
productLoading.value=true;
|
||||
try{const res:any=await TradeOrderMatchProducts({page:productPager.page,pageSize:productPager.pageSize,name:productFilter.name,priceMin:productFilter.priceMin,priceMax:productFilter.priceMax});products.value=res.list||[];productPager.total=res.totalCount||0}finally{productLoading.value=false}
|
||||
}
|
||||
function searchProducts(){productPager.page=1;loadProducts()}
|
||||
function clearProductFilter(){Object.assign(productFilter,{name:'',priceMin:null,priceMax:null});productPager.page=1;selectedProduct.value=undefined;matchForm.productId=0;loadProducts();message.success('商品列表已刷新')}
|
||||
function selectProduct(row){selectedProduct.value=row;matchForm.productId=row.id}
|
||||
async function confirmMatch(){
|
||||
if(!matchForm.productId){message.error('请先选择商品');return}
|
||||
matchSubmitting.value=true;try{await TradeOrderMatch(matchForm);message.success('订单匹配成功');showMatch.value=false;reloadTable()}finally{matchSubmitting.value=false}
|
||||
}
|
||||
async function view(row){detail.value=await TradeOrderView({id:row.id});showDetail.value=true}
|
||||
const actionColumn=reactive({width:180,title:'操作',key:'action',fixed:'right',render:(row)=>h(TableAction as any,{style:'button',actions:[
|
||||
{label:'匹配订单',type:'primary',disabled:!row.canMatch,title:row.canMatch?'匹配订单':'仅“匹配中”的订单可操作',onClick:()=>openMatch(row)},
|
||||
{label:'详情',onClick:()=>view(row)},
|
||||
]})});const scrollX=computed(()=>adaTableScrollX(columns,actionColumn.width));
|
||||
watch(autoRefresh,(enabled)=>{
|
||||
clearInterval(timer);refreshCountdown.value=10;
|
||||
if(!enabled)return;
|
||||
timer=setInterval(()=>{
|
||||
refreshCountdown.value-=1;
|
||||
if(refreshCountdown.value>0)return;
|
||||
reloadTable();refreshCountdown.value=10;
|
||||
},1000);
|
||||
});
|
||||
onBeforeUnmount(()=>clearInterval(timer));onMounted(loadOptions);
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
:deep(.match-order-modal) { width: min(1040px, 94vw); }
|
||||
.match-product-area { width: 100%; }
|
||||
.filter-name { width: 180px; }
|
||||
.filter-price { width: 130px; }
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { computed, h, ref } from 'vue';
|
||||
import { NTag } from 'naive-ui';
|
||||
import { FormSchema } from '@/components/Form';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { defRangeShortcuts } from '@/utils/dateUtil';
|
||||
|
||||
const dict = useDictStore();
|
||||
export const schemas = ref<FormSchema[]>([
|
||||
{ field: 'orderNo', component: 'NInput', label: '订单号', componentProps: { placeholder: '请输入订单号', clearable: true } },
|
||||
{ field: 'username', component: 'NInput', label: '用户名', componentProps: { clearable: true } },
|
||||
{ field: 'mobile', component: 'NInput', label: '手机号', componentProps: { placeholder: '精确查询', clearable: true } },
|
||||
{ field: 'orderStatus', component: 'NSelect', label: '订单状态', componentProps: { options: computed(() => dict.getOptionUnRef('tradeOrderStatus')), clearable: true } },
|
||||
{ field: 'createdAt', component: 'NDatePicker', label: '下单时间', componentProps: { type: 'datetimerange', clearable: true, shortcuts: defRangeShortcuts() } },
|
||||
]);
|
||||
const tag = (type, value) => h(NTag, { type: dict.getType(type, value), bordered: false }, { default: () => dict.getLabel(type, value) || '-' });
|
||||
const yuan = (value) => `¥${((value || 0) / 100).toFixed(2)}`;
|
||||
export const columns = [
|
||||
{ title: '订单号', key: 'orderNo', width: 210 },
|
||||
{ title: '绑卡姓名', key: 'realNameMask', width: 110 },
|
||||
{ title: '用户名', key: 'username', width: 120 },
|
||||
{ title: '手机号', key: 'mobileMask', width: 130 },
|
||||
{ title: '自动派单', key: 'autoDispatch', width: 100, render: (r) => tag('tradeAutoDispatch', r.autoDispatch) },
|
||||
{ title: '余额', key: 'availableAmount', width: 120, render: (r) => yuan(r.availableAmount) },
|
||||
{ title: '当日交易', key: 'todayTradeCount', width: 90 },
|
||||
{ title: '商品/任务', key: 'productName', width: 160, ellipsis: { tooltip: true } },
|
||||
{ title: '类型', key: 'orderType', width: 100, render: (r) => tag('tradeOrderType', r.orderType) },
|
||||
{ title: '数量', key: 'quantity', width: 70 },
|
||||
{ title: '订单金额', key: 'totalAmount', width: 120, render: (r) => yuan(r.totalAmount) },
|
||||
{ title: '佣金', key: 'commissionAmount', width: 110, render: (r) => yuan(r.commissionAmount) },
|
||||
{ title: '下单时间', key: 'createdAt', width: 180 },
|
||||
{ title: '解冻时间', key: 'unfrozenAt', width: 180 },
|
||||
{ title: '消息', key: 'message', width: 180, ellipsis: { tooltip: true } },
|
||||
{ title: '订单状态', key: 'orderStatus', width: 100, render: (r) => tag('tradeOrderStatus', r.orderStatus) },
|
||||
{ title: '冻结状态', key: 'freezeStatus', width: 110, render: (r) => tag('tradeFreezeStatus', r.freezeStatus) },
|
||||
];
|
||||
export const loadOptions = () => dict.loadOptions(['tradeOrderType', 'tradeOrderStatus', 'tradeFreezeStatus', 'tradeAutoDispatch', 'trade_exception_reason']);
|
||||
@@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="n-layout-page-header">
|
||||
<n-card :bordered="false" title="充值管理" />
|
||||
</div>
|
||||
|
||||
<n-grid :cols="5" :x-gap="12" class="mb-3">
|
||||
<n-gi v-for="item in summaryCards" :key="item.label">
|
||||
<n-card size="small">
|
||||
<n-statistic :label="item.label" :value="yuan(item.value)">
|
||||
<template #suffix>元</template>
|
||||
</n-statistic>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<n-card :bordered="false" class="proCard">
|
||||
<BasicForm
|
||||
ref="searchFormRef"
|
||||
@register="register"
|
||||
@submit="reloadTable"
|
||||
@reset="reloadTable"
|
||||
/>
|
||||
<BasicTable
|
||||
ref="actionRef"
|
||||
:columns="columns"
|
||||
:request="loadDataTable"
|
||||
:row-key="(row) => row.id"
|
||||
:action-column="actionColumn"
|
||||
:scroll-x="scrollX"
|
||||
:resize-height-offset="-10000"
|
||||
/>
|
||||
</n-card>
|
||||
|
||||
<n-modal v-model:show="showReview" preset="dialog" :title="reviewTitle" :show-icon="false">
|
||||
<n-alert type="warning" :show-icon="false">
|
||||
请核对金额和打款凭证,提交后将立即完成入账或驳回。
|
||||
</n-alert>
|
||||
<n-form label-placement="top">
|
||||
<template v-if="form.decision === 1">
|
||||
<n-form-item label="实付金额(元)">
|
||||
<n-input-number v-model:value="paidYuan" :min="0" :precision="2" />
|
||||
</n-form-item>
|
||||
<n-form-item label="外部支付单号">
|
||||
<n-input v-model:value="form.externalNo" placeholder="选填" />
|
||||
</n-form-item>
|
||||
</template>
|
||||
<n-form-item v-else label="驳回原因">
|
||||
<n-select
|
||||
v-model:value="form.reasonCode"
|
||||
:options="dict.getOptionUnRef('recharge_reject_reason')"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="审核说明">
|
||||
<n-input v-model:value="form.reasonText" type="textarea" placeholder="选填" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #action>
|
||||
<n-space>
|
||||
<n-button @click="showReview = false">取消</n-button>
|
||||
<n-button
|
||||
:type="form.decision === 1 ? 'primary' : 'error'"
|
||||
:loading="reviewSubmitting"
|
||||
@click="submitReview"
|
||||
>
|
||||
{{ form.decision === 1 ? '提交通过' : '提交驳回' }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue';
|
||||
import { NText, useMessage } from 'naive-ui';
|
||||
import { BasicTable, TableAction } from '@/components/Table';
|
||||
import { BasicForm, useForm } from '@/components/Form';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { adaTableScrollX } from '@/utils/hotgo';
|
||||
import { RechargeList, RechargeReview, RechargeSummary } from '@/api/trade';
|
||||
import { columns, loadOptions, schemas } from './model';
|
||||
|
||||
const message = useMessage();
|
||||
const dict = useDictStore();
|
||||
const actionRef = ref();
|
||||
const searchFormRef = ref<any>({});
|
||||
const showReview = ref(false);
|
||||
const reviewSubmitting = ref(false);
|
||||
const paidYuan = ref(0);
|
||||
const summary = reactive<any>({
|
||||
totalAmount: 0,
|
||||
paidAmount: 0,
|
||||
unpaidAmount: 0,
|
||||
reviewAmount: 0,
|
||||
rejectedAmount: 0,
|
||||
});
|
||||
const summaryCards = computed(() => [
|
||||
{ label: '全部', value: summary.totalAmount },
|
||||
{ label: '用户已支付', value: summary.paidAmount },
|
||||
{ label: '用户未支付', value: summary.unpaidAmount },
|
||||
{ label: '手动审核', value: summary.reviewAmount },
|
||||
{ label: '手动驳回', value: summary.rejectedAmount },
|
||||
]);
|
||||
const form = reactive({
|
||||
id: 0,
|
||||
decision: 1,
|
||||
paidAmount: 0,
|
||||
externalNo: '',
|
||||
reasonCode: 'voucher_verified',
|
||||
reasonText: '',
|
||||
});
|
||||
const reviewTitle = computed(() => (form.decision === 1 ? '充值审核通过' : '充值审核驳回'));
|
||||
|
||||
const [register] = useForm({
|
||||
gridProps: { cols: '1 s:1 m:2 l:3 xl:5' },
|
||||
labelWidth: 80,
|
||||
schemas,
|
||||
});
|
||||
|
||||
const params = () => searchFormRef.value?.formModel || {};
|
||||
|
||||
async function loadDataTable(res) {
|
||||
Object.assign(summary, await RechargeSummary(params()));
|
||||
return RechargeList({ ...params(), ...res });
|
||||
}
|
||||
|
||||
const reloadTable = () => actionRef.value?.reload();
|
||||
const yuan = (value) => ((value || 0) / 100).toFixed(2);
|
||||
|
||||
function review(row, decision) {
|
||||
Object.assign(form, {
|
||||
id: row.id,
|
||||
decision,
|
||||
externalNo: row.externalNo || '',
|
||||
reasonCode: decision === 1 ? 'voucher_verified' : '',
|
||||
reasonText: '',
|
||||
});
|
||||
paidYuan.value = (row.requestAmount || 0) / 100;
|
||||
showReview.value = true;
|
||||
}
|
||||
|
||||
async function submitReview() {
|
||||
if (form.decision === 2 && !form.reasonCode) {
|
||||
message.warning('请选择驳回原因');
|
||||
return;
|
||||
}
|
||||
form.paidAmount = form.decision === 1 ? Math.round(paidYuan.value * 100) : 0;
|
||||
reviewSubmitting.value = true;
|
||||
try {
|
||||
await RechargeReview(form);
|
||||
message.success(form.decision === 1 ? '充值审核已通过并入账' : '充值申请已驳回');
|
||||
showReview.value = false;
|
||||
reloadTable();
|
||||
} finally {
|
||||
reviewSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resultText(row) {
|
||||
if (row.rechargeStatus === 3) {
|
||||
return `审核${dict.getLabel('financeDecision', 1)}`;
|
||||
}
|
||||
if (row.rechargeStatus === 4) {
|
||||
return `审核${dict.getLabel('financeDecision', 2)}`;
|
||||
}
|
||||
return dict.getLabel('rechargeStatus', row.rechargeStatus) || '-';
|
||||
}
|
||||
|
||||
const actionColumn = reactive({
|
||||
width: 150,
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
render: (row) => {
|
||||
if (row.rechargeStatus !== 2) {
|
||||
return h(
|
||||
NText,
|
||||
{
|
||||
type:
|
||||
row.rechargeStatus === 3 ? 'success' : row.rechargeStatus === 4 ? 'error' : 'default',
|
||||
},
|
||||
{ default: () => resultText(row) }
|
||||
);
|
||||
}
|
||||
return h(TableAction as any, {
|
||||
style: 'button',
|
||||
actions: [
|
||||
{
|
||||
label: '通过',
|
||||
type: 'primary',
|
||||
onClick: () => review(row, 1),
|
||||
auth: ['/rechargeOrder/review'],
|
||||
},
|
||||
{
|
||||
label: '驳回',
|
||||
type: 'error',
|
||||
onClick: () => review(row, 2),
|
||||
auth: ['/rechargeOrder/review'],
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
const scrollX = computed(() => adaTableScrollX(columns, actionColumn.width));
|
||||
|
||||
onMounted(loadOptions);
|
||||
</script>
|
||||
@@ -0,0 +1,48 @@
|
||||
import { h, ref } from 'vue';
|
||||
import { NTag } from 'naive-ui';
|
||||
import { FormSchema } from '@/components/Form';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { defRangeShortcuts } from '@/utils/dateUtil';
|
||||
import { renderImageGroup } from '@/utils';
|
||||
|
||||
const dict = useDictStore();
|
||||
|
||||
export const schemas = ref<FormSchema[]>([
|
||||
{ field: 'rechargeNo', component: 'NInput', label: '订单号', componentProps: { clearable: true } },
|
||||
{ field: 'username', component: 'NInput', label: '用户名', componentProps: { clearable: true } },
|
||||
{ field: 'mobile', component: 'NInput', label: '手机号', componentProps: { placeholder: '精确查询', clearable: true } },
|
||||
{ field: 'channelName', component: 'NInput', label: '支付方式', componentProps: { clearable: true } },
|
||||
{ field: 'createdAt', component: 'NDatePicker', label: '筛选时间', componentProps: { type: 'datetimerange', clearable: true, shortcuts: defRangeShortcuts() } },
|
||||
]);
|
||||
|
||||
const tag = (type, value) =>
|
||||
h(NTag, { type: dict.getType(type, value), bordered: false }, { default: () => dict.getLabel(type, value) || '-' });
|
||||
const yuan = (value) => `¥${((value || 0) / 100).toFixed(2)}`;
|
||||
|
||||
export const columns = [
|
||||
{ title: '订单号', key: 'rechargeNo', width: 210 },
|
||||
{ title: '真实姓名', key: 'realNameMask', width: 110 },
|
||||
{ title: '手机号', key: 'mobileMask', width: 130 },
|
||||
{ title: '用户余额', key: 'availableAmount', width: 120, render: (row) => yuan(row.availableAmount) },
|
||||
{ title: '交易金额', key: 'requestAmount', width: 120, render: (row) => yuan(row.requestAmount) },
|
||||
{
|
||||
title: '打款凭证',
|
||||
key: 'voucherAttachmentIds',
|
||||
width: 110,
|
||||
render: (row) => renderImageGroup(row.voucherAttachmentIds),
|
||||
},
|
||||
{ title: '添加时间', key: 'createdAt', width: 180 },
|
||||
{ title: '支付方式', key: 'channelName', width: 120 },
|
||||
{ title: '类型', key: 'rechargeType', width: 100, render: (row) => tag('rechargeType', row.rechargeType) },
|
||||
{ title: '处理时间', key: 'reviewedAt', width: 180 },
|
||||
{
|
||||
title: '备注',
|
||||
key: 'reviewReason',
|
||||
width: 180,
|
||||
ellipsis: { tooltip: true },
|
||||
render: (row) => row.reviewReason || row.remark || dict.getLabel('rechargeStatus', row.rechargeStatus) || '-',
|
||||
},
|
||||
];
|
||||
|
||||
export const loadOptions = () =>
|
||||
dict.loadOptions(['rechargeStatus', 'rechargeType', 'financeDecision', 'recharge_reject_reason']);
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="n-layout-page-header">
|
||||
<n-card :bordered="false" title="提现管理" />
|
||||
</div>
|
||||
<n-card :bordered="false" class="proCard">
|
||||
<BasicForm
|
||||
ref="searchFormRef"
|
||||
@register="register"
|
||||
@submit="reloadTable"
|
||||
@reset="reloadTable"
|
||||
/>
|
||||
<BasicTable
|
||||
ref="actionRef"
|
||||
:columns="columns"
|
||||
:request="loadDataTable"
|
||||
:row-key="(row) => row.id"
|
||||
:action-column="actionColumn"
|
||||
:scroll-x="scrollX"
|
||||
:resize-height-offset="-10000"
|
||||
/>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue';
|
||||
import { NTag, useDialog, useMessage } from 'naive-ui';
|
||||
import { BasicTable, TableAction } from '@/components/Table';
|
||||
import { BasicForm, useForm } from '@/components/Form';
|
||||
import { adaTableScrollX } from '@/utils/hotgo';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { WithdrawalList, WithdrawalReview } from '@/api/trade';
|
||||
import { columns, loadOptions, schemas } from './model';
|
||||
|
||||
const dialog = useDialog();
|
||||
const message = useMessage();
|
||||
const dict = useDictStore();
|
||||
const actionRef = ref();
|
||||
const searchFormRef = ref<any>({});
|
||||
const [register] = useForm({
|
||||
gridProps: { cols: '1 s:1 m:2 l:3 xl:5' },
|
||||
labelWidth: 80,
|
||||
schemas,
|
||||
});
|
||||
const loadDataTable = (params) =>
|
||||
WithdrawalList({ ...searchFormRef.value?.formModel, ...params });
|
||||
const reloadTable = () => actionRef.value?.reload();
|
||||
|
||||
async function submitDecision(row, decision) {
|
||||
const approved = decision === 1;
|
||||
await WithdrawalReview({
|
||||
id: row.id,
|
||||
decision,
|
||||
reasonCode: approved ? 'manual_approve' : 'manual_reject',
|
||||
reasonText: approved ? '后台人工审核通过' : '后台人工审核驳回',
|
||||
});
|
||||
message.success(approved ? '审核通过' : '审核驳回');
|
||||
reloadTable();
|
||||
}
|
||||
|
||||
function confirmDecision(row, decision) {
|
||||
const approved = decision === 1;
|
||||
dialog.warning({
|
||||
title: '信息',
|
||||
content: `确定${approved ? '通过' : '驳回'}吗?`,
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => submitDecision(row, decision),
|
||||
});
|
||||
}
|
||||
|
||||
function resultTag(row) {
|
||||
return h(
|
||||
NTag,
|
||||
{ type: dict.getType('withdrawalStatus', row.withdrawalStatus), bordered: false },
|
||||
{ default: () => dict.getLabel('withdrawalStatus', row.withdrawalStatus) || '-' }
|
||||
);
|
||||
}
|
||||
|
||||
const actionColumn = reactive({
|
||||
width: 150,
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
render: (row) => {
|
||||
if (row.withdrawalStatus !== 1) return resultTag(row);
|
||||
return h(TableAction as any, {
|
||||
style: 'button',
|
||||
actions: [
|
||||
{
|
||||
label: '通过',
|
||||
type: 'primary',
|
||||
onClick: () => confirmDecision(row, 1),
|
||||
auth: ['/withdrawalOrder/review'],
|
||||
},
|
||||
{
|
||||
label: '驳回',
|
||||
type: 'error',
|
||||
onClick: () => confirmDecision(row, 2),
|
||||
auth: ['/withdrawalOrder/review'],
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
const scrollX = computed(() => adaTableScrollX(columns, actionColumn.width));
|
||||
onMounted(loadOptions);
|
||||
</script>
|
||||
@@ -0,0 +1,71 @@
|
||||
import { h, ref } from 'vue';
|
||||
import { NImage } from 'naive-ui';
|
||||
import { FormSchema } from '@/components/Form';
|
||||
import { defRangeShortcuts } from '@/utils/dateUtil';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
|
||||
const dict = useDictStore();
|
||||
|
||||
export const schemas = ref<FormSchema[]>([
|
||||
{
|
||||
field: 'withdrawalNo',
|
||||
component: 'NInput',
|
||||
label: '订单号',
|
||||
componentProps: { clearable: true },
|
||||
},
|
||||
{ field: 'username', component: 'NInput', label: '用户名', componentProps: { clearable: true } },
|
||||
{
|
||||
field: 'createdAt',
|
||||
component: 'NDatePicker',
|
||||
label: '筛选时间',
|
||||
componentProps: { type: 'daterange', clearable: true, shortcuts: defRangeShortcuts() },
|
||||
},
|
||||
]);
|
||||
|
||||
const yuan = (value) => ((value || 0) / 100).toFixed(2);
|
||||
const accountType = (row) => row.paymentAccountType || 1;
|
||||
const accountValue = (row, type) => (accountType(row) === type ? row.accountMask || '-' : '-');
|
||||
const qrCode = (row, type) => {
|
||||
if (accountType(row) !== type) return '-';
|
||||
if (!row.qrCodeUrl) return row.accountMask || '-';
|
||||
return h(NImage, { width: 46, height: 46, objectFit: 'cover', src: row.qrCodeUrl });
|
||||
};
|
||||
|
||||
export const columns = [
|
||||
{ title: '提现用户', key: 'username', width: 130 },
|
||||
{
|
||||
title: '用户余额',
|
||||
key: 'availableAmount',
|
||||
width: 115,
|
||||
render: (row) => yuan(row.availableAmount),
|
||||
},
|
||||
{ title: '提现金额', key: 'requestAmount', width: 115, render: (row) => yuan(row.requestAmount) },
|
||||
{ title: '手续费', key: 'feeAmount', width: 100, render: (row) => yuan(row.feeAmount) },
|
||||
{ title: '实际到账', key: 'actualAmount', width: 115, render: (row) => yuan(row.actualAmount) },
|
||||
{
|
||||
title: '银行名称',
|
||||
key: 'bankName',
|
||||
width: 150,
|
||||
render: (row) => (accountType(row) === 1 ? row.bankName || '-' : '-'),
|
||||
},
|
||||
{
|
||||
title: '开户名称',
|
||||
key: 'holderMask',
|
||||
width: 120,
|
||||
render: (row) => (accountType(row) === 1 ? row.holderMask || '-' : '-'),
|
||||
},
|
||||
{ title: '银行卡号', key: 'bankAccount', width: 190, render: (row) => accountValue(row, 1) },
|
||||
{
|
||||
title: '云闪付账号',
|
||||
key: 'unionPayAccount',
|
||||
width: 160,
|
||||
render: (row) => accountValue(row, 3),
|
||||
},
|
||||
{ title: '数字钱包', key: 'digitalWallet', width: 160, render: (row) => accountValue(row, 2) },
|
||||
{ title: '支付宝收款码', key: 'alipayQrCode', width: 140, render: (row) => qrCode(row, 4) },
|
||||
{ title: '微信收款码', key: 'wechatQrCode', width: 140, render: (row) => qrCode(row, 5) },
|
||||
{ title: '发起时间', key: 'createdAt', width: 180 },
|
||||
{ title: '处理时间', key: 'completedAt', width: 180 },
|
||||
];
|
||||
|
||||
export const loadOptions = () => dict.loadOptions(['withdrawalStatus']);
|
||||
Reference in New Issue
Block a user