Files
olons_admin/web/src/views/trade/order/index.vue
T
richjohnrambo 70ef3d9d04
Deploy olons-admin production / deploy (push) Successful in 8m50s
feat: publish member notices and balance-aware order settlement
2026-10-22 21:56:06 +08:00

168 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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-alert v-if="matchShortfall > 0" type="warning" :bordered="false">
会员余额不足:当前可用 {{ yuan(matchMember.availableAmount) }},订单金额 {{ yuan(selectedProduct.priceAmount * matchForm.quantity) }},还差 {{ yuan(matchShortfall) }}。订单将保留为待处理,充值审核通过且余额足够后自动完成。
</n-alert>
</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="showAmount" preset="card" title="更改金额" class="amount-modal" :mask-closable="false">
<n-form label-placement="left" :label-width="90">
<n-form-item label="用户名称"><n-input :value="amountMember.username" disabled /></n-form-item>
<n-form-item label="账号余额"><n-input :value="yuanPlain(amountMember.availableAmount)" disabled /></n-form-item>
<n-form-item label="更改金额"><n-input-number v-model:value="amountForm.amount" :min="0.01" :precision="2" class="w-full" /></n-form-item>
<n-form-item label="更改数量"><n-input-number v-model:value="amountForm.quantity" :min="1" :max="999" class="w-full" /></n-form-item>
</n-form>
<template #action><n-space><n-button @click="showAmount=false">取消</n-button><n-button type="primary" :loading="amountSubmitting" @click="confirmAmount">确定</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.mobile }}</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="订单状态">{{ dict.getLabel('tradeOrderStatus', detail.orderStatus) || '-' }}</n-descriptions-item><n-descriptions-item label="冻结状态">{{ dict.getLabel('tradeFreezeStatus', detail.freezeStatus) || '-' }}</n-descriptions-item>
<n-descriptions-item label="创建时间">{{ detail.createdAt || '-' }}</n-descriptions-item><n-descriptions-item label="更新时间">{{ detail.updatedAt || '-' }}</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-divider title-placement="left">状态时间线</n-divider>
<n-timeline v-if="detail?.timeline?.length"><n-timeline-item v-for="item in detail.timeline" :key="`${item.title}-${item.time}`" :title="item.title" :time="item.time" /></n-timeline>
<n-empty v-else description="暂无状态时间线" />
<n-divider title-placement="left">资金流水</n-divider>
<n-data-table :columns="ledgerColumns" :data="detail?.ledgers || []" :bordered="false" />
<n-empty v-if="!detail?.ledgers?.length" description="暂无关联资金流水" />
<n-divider title-placement="left">审批记录</n-divider>
<n-data-table :columns="approvalColumns" :data="detail?.approvalActions || []" :bordered="false" />
<n-empty v-if="!detail?.approvalActions?.length" description="暂无关联审批记录" />
</n-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, h, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { NButton, NImage, NTag, useDialog, useMessage } from 'naive-ui';
import { useDictStore } from '@/store/modules/dict';
import { BasicTable, TableAction } from '@/components/Table';import { BasicForm,useForm } from '@/components/Form';import { adaTableScrollX } from '@/utils/hotgo';
import { TradeOrderForcePay,TradeOrderFreeze,TradeOrderList,TradeOrderMatch,TradeOrderMatchProducts,TradeOrderUpdateAmount,TradeOrderView } from '@/api/trade';import { columns,schemas,loadOptions } from './model';
const message=useMessage();const dialog=useDialog();const router=useRouter();const dict=useDictStore();const actionRef=ref(),searchFormRef=ref<any>({}),showDetail=ref(false),showMatch=ref(false),showAmount=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 ledgerColumns=[{title:'流水号',key:'ledgerNo',width:180},{title:'业务',key:'bizType',width:120},{title:'方向',key:'direction',width:80,render:(row)=>row.direction===1?'收入':'支出'},{title:'金额',key:'amount',width:110,render:(row)=>yuan(row.amount)},{title:'变动前',key:'beforeAmount',width:110,render:(row)=>yuan(row.beforeAmount)},{title:'变动后',key:'afterAmount',width:110,render:(row)=>yuan(row.afterAmount)},{title:'说明',key:'remark',minWidth:180},{title:'发生时间',key:'occurredAt',width:180}];
const approvalColumns=[{title:'节点',key:'stepNo',width:70},{title:'操作人ID',key:'actorId',width:100},{title:'动作',key:'action',width:90},{title:'意见',key:'comment',minWidth:220},{title:'操作时间',key:'createdAt',width:180}];
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 matchShortfall=computed(()=>Math.max(0,(selectedProduct.value?.priceAmount||0)*matchForm.quantity-matchMember.availableAmount));
const amountMember=reactive({username:'',availableAmount:0});
const amountForm=reactive<{id:number;amount:number|null;quantity:number}>({id:0,amount:null,quantity:1});
const amountSubmitting=ref(false);
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}
function openAmount(row){Object.assign(amountMember,{username:row.username,availableAmount:row.availableAmount});Object.assign(amountForm,{id:row.id,amount:Number(row.totalAmount||0)/100,quantity:row.quantity||1});showAmount.value=true}
async function confirmAmount(){if(!amountForm.amount||amountForm.amount<=0){message.error('请输入正确金额');return}amountSubmitting.value=true;try{await TradeOrderUpdateAmount({id:amountForm.id,amount:amountForm.amount.toFixed(2),quantity:amountForm.quantity});message.success('订单金额已更改');showAmount.value=false;reloadTable()}finally{amountSubmitting.value=false}}
function forcePay(row){dialog.warning({title:'强制付款',content:`确认对订单 ${row.orderNo} 执行强制付款吗?允许付款后余额为负数。`,positiveText:'确认付款',negativeText:'取消',onPositiveClick:async()=>{await TradeOrderForcePay({id:row.id});message.success('强制付款成功');reloadTable()}})}
function freezeOrder(row){dialog.warning({title:'冻结订单',content:`确认冻结订单 ${row.orderNo} 吗?冻结后会员需要联系客服处理。`,positiveText:'确认冻结',negativeText:'取消',onPositiveClick:async()=>{await TradeOrderFreeze({id:row.id});message.success('订单已冻结');reloadTable()}})}
const actionColumn=reactive({width:300,title:'操作',key:'action',fixed:'right',render:(row)=>{
const actions:any[]=[];
if(row.canMatch)actions.push({label:'匹配订单',type:'primary',onClick:()=>openMatch(row)});
if(row.canChangeAmount)actions.push({label:'更改金额',type:'primary',onClick:()=>openAmount(row)});
if(row.canForcePay)actions.push({label:'强制付款',type:'primary',onClick:()=>forcePay(row)});
if(row.canFreeze)actions.push({label:'冻结订单',type:'warning',onClick:()=>freezeOrder(row)});
const hasManualActions=row.canChangeAmount||row.canForcePay||row.canFreeze;
if(!hasManualActions&&row.paymentStatus==='balance_pending')actions.push({label:'余额不足待处理',type:'error',disabled:true,title:row.message||'充值审核通过且余额足够后将自动完成'});
else if(!hasManualActions&&row.orderType===2&&row.orderStatus===2&&row.canReview)actions.push({label:'审核任务',type:'warning',onClick:()=>router.push({name:'task_submission_list',query:{tradeOrderId:String(row.id)}})});
else if(!hasManualActions&&row.orderType===2&&row.orderStatus===2)actions.push({label:'等待会员付款',disabled:true,title:'等待会员完成付款'});
if(!hasManualActions)actions.push({label:'详情',onClick:()=>view(row)});
return h(TableAction as any,{style:'button',actions});
}});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); }
:deep(.amount-modal) { width: min(620px, 92vw); }
.match-product-area { width: 100%; }
.filter-name { width: 180px; }
.filter-price { width: 130px; }
</style>