细节优化

This commit is contained in:
18631081161 2026-03-20 18:09:42 +08:00
parent 365116c2f8
commit d341e859dc
25 changed files with 1827 additions and 74 deletions

View File

@ -50,6 +50,10 @@
<el-icon><List /></el-icon>
<template #title>订单管理</template>
</el-menu-item>
<el-menu-item index="/withdrawals">
<el-icon><Money /></el-icon>
<template #title>提现管理</template>
</el-menu-item>
<el-menu-item index="/config">
<el-icon><Setting /></el-icon>
<template #title>配置管理</template>
@ -91,7 +95,7 @@
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import { Monitor, Fold, Expand, ArrowDown, Picture, Grid, Shop, Stamp, User, Star, Bell, List, Setting } from '@element-plus/icons-vue'
import { Monitor, Fold, Expand, ArrowDown, Picture, Grid, Shop, Stamp, User, Star, Bell, List, Setting, Money } from '@element-plus/icons-vue'
const router = useRouter()
const isCollapse = ref(false)

View File

@ -72,6 +72,12 @@ const routes = [
component: () => import('../views/Orders.vue'),
meta: { title: '订单管理' }
},
{
path: 'withdrawals',
name: 'Withdrawals',
component: () => import('../views/Withdrawals.vue'),
meta: { title: '提现管理' }
},
{
path: 'config',
name: 'Config',

View File

@ -84,6 +84,18 @@
</el-form>
</el-tab-pane>
<!-- 跑腿协议 -->
<el-tab-pane label="跑腿协议" name="runner_agreement">
<el-form label-width="100px" style="max-width: 700px;">
<el-form-item label="协议内容">
<el-input v-model="configs.runner_agreement" type="textarea" :rows="12" placeholder="请输入跑腿协议内容" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="saveConfig('runner_agreement')">保存</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<!-- 提现说明 -->
<el-tab-pane label="提现说明" name="withdrawal_guide">
<el-form label-width="100px" style="max-width: 700px;">
@ -154,7 +166,7 @@ const uploadHeaders = { Authorization: `Bearer ${localStorage.getItem('admin_tok
const commissionRules = ref([])
//
const configs = reactive({ qrcode: '', agreement: '', privacy: '', withdrawal_guide: '' })
const configs = reactive({ qrcode: '', agreement: '', privacy: '', runner_agreement: '', withdrawal_guide: '' })
const freezeDays = ref(1)
//
@ -256,6 +268,7 @@ onMounted(async () => {
fetchConfig('qrcode'),
fetchConfig('agreement'),
fetchConfig('privacy'),
fetchConfig('runner_agreement'),
fetchConfig('withdrawal_guide'),
fetchFreezeDays(),
fetchPageBanners()

View File

@ -1,21 +1,61 @@
<template>
<div>
<h3 style="margin: 0 0 16px;">跑腿管理</h3>
<div class="runners-page">
<div class="page-header">
<h2>跑腿管理</h2>
<el-tag type="info" size="large"> {{ list.length }} 名跑腿</el-tag>
</div>
<el-table :data="list" v-loading="loading" border>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="nickname" label="昵称" width="120" />
<el-table-column prop="phone" label="手机号" width="140" />
<el-table-column prop="runnerScore" label="评分" width="80" />
<el-table-column label="封禁状态" width="100">
<el-table :data="list" v-loading="loading" stripe style="width: 100%">
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="nickname" label="昵称" min-width="140" show-overflow-tooltip />
<el-table-column prop="phone" label="手机号" min-width="140" />
<el-table-column label="评分" width="160" align="center">
<template #default="{ row }">
<el-tag :type="row.isBanned ? 'danger' : 'success'">{{ row.isBanned ? '已封禁' : '正常' }}</el-tag>
<div class="score-cell">
<el-progress
:percentage="row.runnerScore"
:color="getScoreColor(row.runnerScore)"
:stroke-width="10"
:show-text="false"
style="width: 80px; display: inline-block; vertical-align: middle;"
/>
<span class="score-text" :style="{ color: getScoreColor(row.runnerScore) }">{{ row.runnerScore }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-button v-if="!row.isBanned" size="small" type="danger" @click="toggleBan(row, true)">封禁</el-button>
<el-button v-else size="small" type="success" @click="toggleBan(row, false)">解封</el-button>
<el-tag :type="row.isBanned ? 'danger' : 'success'" round size="small">
{{ row.isBanned ? '已封禁' : '正常' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="注册时间" min-width="170">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right" align="center">
<template #default="{ row }">
<el-popconfirm
v-if="!row.isBanned"
title="确定封禁该跑腿?"
confirm-button-text="封禁"
confirm-button-type="danger"
@confirm="toggleBan(row, true)"
>
<template #reference>
<el-button size="small" type="danger" plain>封禁</el-button>
</template>
</el-popconfirm>
<el-popconfirm
v-else
title="确定解封该跑腿?"
confirm-button-text="解封"
@confirm="toggleBan(row, false)"
>
<template #reference>
<el-button size="small" type="success" plain>解封</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
@ -24,7 +64,7 @@
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ElMessage } from 'element-plus'
import request from '../utils/request'
const loading = ref(false)
@ -41,11 +81,47 @@ async function fetchList() {
async function toggleBan(row, isBanned) {
const label = isBanned ? '封禁' : '解封'
await ElMessageBox.confirm(`确定${label}跑腿「${row.nickname}」?`, '提示', { type: 'warning' })
await request.put(`/admin/runners/${row.id}/ban`, { isBanned })
ElMessage.success(`${label}`)
fetchList()
}
function getScoreColor(score) {
if (score >= 80) return '#67c23a'
if (score >= 60) return '#e6a23c'
return '#f56c6c'
}
function formatTime(str) {
if (!str) return '-'
const d = new Date(str)
const pad = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
onMounted(fetchList)
</script>
<style scoped>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-header h2 {
margin: 0;
font-size: 20px;
}
.score-cell {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.score-text {
font-weight: bold;
font-size: 14px;
min-width: 24px;
}
</style>

View File

@ -0,0 +1,126 @@
<template>
<div class="withdrawals-page">
<div class="page-header">
<h2>提现管理</h2>
<el-radio-group v-model="statusFilter" @change="loadData">
<el-radio-button label="">全部</el-radio-button>
<el-radio-button label="Pending">待处理</el-radio-button>
<el-radio-button label="Processing">处理中</el-radio-button>
<el-radio-button label="Completed">已完成</el-radio-button>
</el-radio-group>
</div>
<el-table :data="list" stripe v-loading="loading" style="width: 100%">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="userNickname" label="用户" min-width="100" show-overflow-tooltip />
<el-table-column prop="amount" label="金额" width="100">
<template #default="{ row }">
<span style="color: #e64340; font-weight: bold">¥{{ row.amount }}</span>
</template>
</el-table-column>
<el-table-column prop="paymentMethod" label="收款方式" width="100">
<template #default="{ row }">
<el-tag :type="row.paymentMethod === 'WeChat' ? 'success' : 'primary'" round size="small">
{{ row.paymentMethod === 'WeChat' ? '微信' : '支付宝' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="收款码" width="80">
<template #default="{ row }">
<el-image v-if="row.qrCodeImage" :src="row.qrCodeImage" :preview-src-list="[row.qrCodeImage]"
style="width: 40px; height: 40px" fit="cover" preview-teleported />
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" round size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="申请时间" min-width="160">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<template v-if="row.status === 'Pending'">
<el-button type="warning" size="small" plain @click="handleAction(row, 'processing')">处理中</el-button>
<el-button type="success" size="small" plain @click="handleAction(row, 'approve')">通过</el-button>
<el-button type="danger" size="small" plain @click="handleAction(row, 'reject')">拒绝</el-button>
</template>
<template v-else-if="row.status === 'Processing'">
<el-button type="success" size="small" plain @click="handleAction(row, 'approve')">通过</el-button>
<el-button type="danger" size="small" plain @click="handleAction(row, 'reject')">拒绝</el-button>
</template>
<template v-else>
<span style="color: #999">已处理</span>
</template>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '../utils/request'
const list = ref([])
const loading = ref(false)
const statusFilter = ref('')
async function loadData() {
loading.value = true
try {
const params = statusFilter.value ? `?status=${statusFilter.value}` : ''
const res = await request.get(`/admin/withdrawals${params}`)
list.value = res || []
} catch (e) {
ElMessage.error('加载失败')
} finally {
loading.value = false
}
}
async function handleAction(row, action) {
const labels = { approve: '通过', reject: '拒绝', processing: '标记为处理中' }
try {
await ElMessageBox.confirm(`确定${labels[action]}该提现申请?`, '提示', { type: 'warning' })
await request.put(`/admin/withdrawals/${row.id}`, { action })
ElMessage.success('操作成功')
loadData()
} catch (e) {
if (e !== 'cancel') ElMessage.error(e?.response?.data?.message || '操作失败')
}
}
function statusLabel(s) {
return { Pending: '待处理', Processing: '处理中', Completed: '已完成' }[s] || s
}
function statusTagType(s) {
return { Pending: 'warning', Processing: '', Completed: 'success' }[s] || 'info'
}
function formatTime(str) {
if (!str) return '-'
const d = new Date(str)
const pad = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
onMounted(loadData)
</script>
<style scoped>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-header h2 {
margin: 0;
font-size: 20px;
}
</style>

View File

@ -181,6 +181,13 @@
"navigationStyle": "custom"
}
},
{
"path": "pages/config/runner-agreement",
"style": {
"navigationBarTitleText": "跑腿协议",
"navigationStyle": "custom"
}
},
{
"path": "pages/webview/webview",
"style": {
@ -197,7 +204,7 @@
},
"tabBar": {
"color": "#999999",
"selectedColor": "#007AFF",
"selectedColor": "#FFB700",
"borderStyle": "black",
"backgroundColor": "#FFFFFF",
"list": [

View File

@ -0,0 +1,105 @@
<template>
<view class="agreement-page">
<!-- 自定义导航栏 -->
<view class="custom-navbar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="navbar-content">
<view class="nav-back" @click="goBack">
<image class="back-icon" src="/static/ic_back.png" mode="aspectFit"></image>
</view>
<text class="navbar-title">跑腿协议</text>
<view class="nav-placeholder"></view>
</view>
</view>
<view :style="{ height: (statusBarHeight + 44) + 'px' }"></view>
<rich-text v-if="content" :nodes="content" class="rich-content"></rich-text>
<view v-else class="empty-tip">
<text>暂无内容</text>
</view>
</view>
</template>
<script>
import { getRunnerAgreement } from '../../utils/api'
export default {
data() {
return {
content: '',
statusBarHeight: 0
}
},
onLoad() {
const sysInfo = uni.getSystemInfoSync()
this.statusBarHeight = sysInfo.statusBarHeight || 0
this.loadContent()
},
methods: {
goBack() { uni.navigateBack() },
/** 加载跑腿协议内容 */
async loadContent() {
try {
const res = await getRunnerAgreement()
this.content = res?.value || res?.content || ''
} catch (e) {
//
}
}
}
}
</script>
<style scoped>
/* 自定义导航栏 */
.custom-navbar {
position: fixed;
top: 0;
left: 0;
width: 100%;
z-index: 999;
background: #FFB700;
}
.navbar-content {
height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20rpx;
}
.nav-back {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
}
.back-icon {
width: 40rpx;
height: 40rpx;
}
.navbar-title {
font-size: 34rpx;
font-weight: bold;
color: #363636;
}
.nav-placeholder {
width: 60rpx;
}
.agreement-page {
min-height: 100vh;
background-color: #ffffff;
padding: 30rpx;
}
.rich-content {
font-size: 28rpx;
color: #333333;
line-height: 1.8;
}
.empty-tip {
text-align: center;
padding: 100rpx 0;
}
.empty-tip text {
font-size: 28rpx;
color: #999999;
}
</style>

View File

@ -12,11 +12,21 @@
</view>
<view :style="{ height: (statusBarHeight + 44) + 'px' }"></view>
<!-- 顶部订单信息栏 -->
<view class="order-bar" v-if="orderInfo.id" @click="goOrderDetail">
<text class="order-bar-text">
{{ formatOrderType(orderInfo.orderType) }}订单 #{{ orderInfo.orderNo }}
</text>
<text class="order-bar-link">查看详情 </text>
<view class="order-bar" v-if="orderInfo.id">
<view class="order-bar-info" @click="goOrderDetail">
<text class="order-bar-text">
{{ formatOrderType(orderInfo.orderType) }}订单 #{{ orderInfo.orderNo }}
</text>
<text class="order-bar-link">查看详情 </text>
</view>
<view class="order-bar-actions">
<view class="bar-action-btn" @click="onCallPhone">
<text>📞 拨打电话</text>
</view>
<view class="bar-action-btn" @click="onContactService">
<text>💬 联系客服</text>
</view>
</view>
</view>
<!-- 聊天记录区域 -->
@ -253,8 +263,12 @@ export default {
async onLoad(options) {
const sysInfo = uni.getSystemInfoSync()
this.statusBarHeight = sysInfo.statusBarHeight || 0
this.orderId = options.orderId
await this.loadOrderInfo()
this.orderId = options.orderId || null
// targetUserId
this.targetImUserIdFromParam = options.targetUserId || null
if (this.orderId) {
await this.loadOrderInfo()
}
await this.loginIM()
},
onUnload() {
@ -280,13 +294,18 @@ export default {
this.imUserId = userId
// IM ID
const userStore = useUserStore()
if (this.orderInfo.ownerId === userStore.userId) {
//
this.targetImUserId = `user_${this.orderInfo.runnerId}`
} else {
//
this.targetImUserId = `user_${this.orderInfo.ownerId}`
if (this.targetImUserIdFromParam) {
// 使 targetUserId
this.targetImUserId = this.targetImUserIdFromParam
} else if (this.orderInfo.id) {
const userStore = useUserStore()
if (this.orderInfo.ownerId === userStore.userId) {
//
this.targetImUserId = `user_${this.orderInfo.runnerId}`
} else {
//
this.targetImUserId = `user_${this.orderInfo.ownerId}`
}
}
this.imReady = true
@ -519,6 +538,39 @@ export default {
uni.navigateTo({ url: `/pages/order/order-detail?id=${this.orderId}` })
},
/** 拨打电话(显示对方手机号) */
onCallPhone() {
//
const phone = this.isOwner ? this.orderInfo.runnerPhone : this.orderInfo.phone
if (!phone) {
uni.showToast({ title: '暂无对方手机号', icon: 'none' })
return
}
uni.showModal({
title: '对方手机号',
content: phone,
confirmText: '复制电话',
success: (res) => {
if (res.confirm) {
uni.setClipboardData({
data: phone,
success: () => {
uni.showToast({ title: '手机号已复制', icon: 'success' })
}
})
}
}
})
},
/** 联系客服 */
onContactService() {
//
// 使 button open-type="contact"
// navigateTo
uni.navigateTo({ url: '/pages/config/qrcode' })
},
previewImage(url) {
uni.previewImage({ urls: [url] })
},
@ -573,12 +625,14 @@ export default {
height: 100vh;
}
.order-bar {
background-color: #f0f7ff;
flex-shrink: 0;
}
.order-bar-info {
display: flex;
justify-content: space-between;
align-items: center;
background-color: #f0f7ff;
padding: 20rpx 30rpx;
flex-shrink: 0;
padding: 20rpx 30rpx 10rpx;
}
.order-bar-text {
font-size: 26rpx;
@ -588,6 +642,23 @@ export default {
font-size: 26rpx;
color: #FFB700;
}
.order-bar-actions {
display: flex;
gap: 16rpx;
padding: 10rpx 30rpx 20rpx;
}
.bar-action-btn {
flex: 1;
text-align: center;
padding: 12rpx 0;
background-color: #ffffff;
border-radius: 8rpx;
border: 1rpx solid #e0e0e0;
}
.bar-action-btn text {
font-size: 24rpx;
color: #666666;
}
.chat-body {
flex: 1;
padding: 20rpx 24rpx;

View File

@ -72,6 +72,7 @@
<script>
import { getUnreadCount } from '../../utils/api'
import { getConversationList, getChatInstance } from '../../utils/im'
export default {
data() {
@ -109,10 +110,14 @@ export default {
}
},
/** 加载聊天记录列表(腾讯 IM SDK 集成后替换) */
loadChatList() {
// TODO: IM SDK SDK
//
/** 加载聊天记录列表(从腾讯 IM SDK 获取会话列表) */
async loadChatList() {
try {
const list = await getConversationList()
this.chatList = list
} catch (e) {
console.error('[消息页] 加载聊天列表失败:', e)
}
},
/** 更新底部导航栏未读数 badge */
@ -140,8 +145,9 @@ export default {
/** 跳转聊天页 */
goChat(item) {
// IM orderId targetUserId
uni.navigateTo({
url: `/pages/message/chat?orderId=${item.orderId}&targetUserId=${item.targetUserId}`
url: `/pages/message/chat?targetUserId=${item.targetUserId}`
})
},

View File

@ -36,7 +36,9 @@
</view>
<view class="notify-body">
<text class="notify-order-no">订单编号{{ item.orderNo }}</text>
<text class="notify-item-name" v-if="item.itemName">{{ item.itemName }}</text>
<text class="notify-item-name" v-if="item.itemName">
{{ getFirstFieldLabel(item.orderType) }}{{ item.itemName }}
</text>
</view>
<view class="notify-footer">
<text class="notify-time">{{ formatTime(item.createdAt) }}</text>
@ -120,6 +122,18 @@ export default {
return map[type] || type
},
/** 获取订单第1项字段标题 */
getFirstFieldLabel(type) {
const map = {
Pickup: '代取物品',
Delivery: '代送物品',
Help: '帮忙事项',
Purchase: '代购物品',
Food: '美食订单'
}
return map[type] || '物品'
},
/** 格式化时间(精确到年月日时分,) */
formatTime(dateStr) {
if (!dateStr) return ''

View File

@ -27,6 +27,10 @@
<text class="record-label">订单号</text>
<text class="record-value">{{ item.orderNo }}</text>
</view>
<view class="record-row" v-if="item.completedAt">
<text class="record-label">完成时间</text>
<text class="record-value">{{ formatTime(item.completedAt) }}</text>
</view>
<view class="record-row" v-if="item.goodsAmount">
<text class="record-label">垫付商品金额</text>
<text class="record-value">¥{{ item.goodsAmount }}</text>
@ -44,6 +48,11 @@
<text class="record-value highlight">¥{{ item.netEarning }}</text>
</view>
</view>
<view class="record-footer">
<view class="view-order-btn" @click="goOrderDetail(item.orderId)">
<text>查看订单</text>
</view>
</view>
</view>
</view>
</template>
@ -84,6 +93,12 @@ export default {
return map[type] || type || '跑腿'
},
/** 跳转订单详情 */
goOrderDetail(orderId) {
if (!orderId) return
uni.navigateTo({ url: `/pages/order/order-detail?id=${orderId}` })
},
formatTime(dateStr) {
if (!dateStr) return '-'
const d = new Date(dateStr)
@ -196,4 +211,23 @@ export default {
color: #52c41a;
font-weight: bold;
}
.record-footer {
display: flex;
justify-content: flex-end;
padding-top: 16rpx;
border-top: 1rpx solid #f0f0f0;
margin-top: 12rpx;
}
.view-order-btn {
padding: 10rpx 28rpx;
border: 1rpx solid #FFB700;
border-radius: 32rpx;
}
.view-order-btn text {
font-size: 24rpx;
color: #FFB700;
}
</style>

View File

@ -175,7 +175,14 @@ export default {
getEarnings(),
getWithdrawals()
])
this.earnings = earningsRes || this.earnings
if (earningsRes) {
this.earnings = {
frozen: (earningsRes.frozenAmount || 0).toFixed(2),
available: (earningsRes.availableAmount || 0).toFixed(2),
withdrawing: (earningsRes.withdrawingAmount || 0).toFixed(2),
withdrawn: (earningsRes.withdrawnAmount || 0).toFixed(2)
}
}
this.withdrawals = withdrawalsRes?.items || withdrawalsRes || []
} catch (e) {
//

View File

@ -154,8 +154,7 @@ export default {
goAgreement() { uni.navigateTo({ url: '/pages/config/agreement' }) },
goPrivacy() { uni.navigateTo({ url: '/pages/config/privacy' }) },
goRunnerAgreement() {
//
uni.navigateTo({ url: '/pages/config/agreement' })
uni.navigateTo({ url: '/pages/config/runner-agreement' })
},
/** 退出登录 */
onLogout() {

View File

@ -440,7 +440,7 @@ export default {
}
.confirm-ok {
background-color: #007AFF;
background-color: #FAD146;
color: #ffffff;
}
@ -462,7 +462,7 @@ export default {
}
.submit-btn {
background-color: #007AFF;
background-color: #FAD146;
color: #ffffff;
text-align: center;
padding: 24rpx 0;

View File

@ -100,10 +100,13 @@
</view>
</template>
<!-- 待确认确认处理 -->
<!-- 待确认确认完成 + 订单未完成 + 查看详情 -->
<template v-else-if="order.status === 'WaitConfirm'">
<view class="btn btn-primary" @click="goConfirm(order)">
<text>确认处理</text>
<view class="btn btn-cancel" @click="onRejectComplete(order)">
<text>订单未完成</text>
</view>
<view class="btn btn-primary" @click="onConfirmComplete(order)">
<text>确认订单完成</text>
</view>
<view class="btn btn-detail" @click="goDetail(order)">
<text>查看详情</text>
@ -167,7 +170,7 @@
</template>
<script>
import { getMyOrders, cancelOrder, submitReview } from '../../utils/api'
import { getMyOrders, cancelOrder, submitReview, confirmOrder, rejectOrder } from '../../utils/api'
export default {
data() {
@ -296,9 +299,36 @@ export default {
uni.navigateTo({ url: `/pages/message/chat?orderId=${order.id}` })
},
/** 跳转确认处理页 */
goConfirm(order) {
uni.navigateTo({ url: `/pages/order/complete-order?id=${order.id}&mode=confirm` })
/** 单主确认订单完成 */
onConfirmComplete(order) {
uni.showModal({
title: '确认完成',
content: '确认该订单已完成?',
success: async (res) => {
if (!res.confirm) return
try {
await confirmOrder(order.id)
uni.showToast({ title: '订单已完成', icon: 'success' })
this.loadOrders()
} catch (e) {}
}
})
},
/** 单主拒绝订单完成 */
onRejectComplete(order) {
uni.showModal({
title: '拒绝完成',
content: '确认该订单未完成?订单将继续进行。',
success: async (res) => {
if (!res.confirm) return
try {
await rejectOrder(order.id)
uni.showToast({ title: '已拒绝,订单继续进行', icon: 'none' })
this.loadOrders()
} catch (e) {}
}
})
},
/** 打开评价弹窗 */
@ -475,7 +505,7 @@ export default {
}
.status-pending { color: #faad14; }
.status-progress { color: #007AFF; }
.status-progress { color: #FFB700; }
.status-confirm { color: #ff9900; }
.status-done { color: #52c41a; }
.status-cancel { color: #999999; }

View File

@ -153,27 +153,38 @@
</view>
</template>
<!-- 进行中联系跑腿 -->
<!-- 进行中单主看联系跑腿跑腿看完成订单+联系单主 -->
<template v-else-if="order.status === 'InProgress'">
<view class="action-btn btn-primary" @click="goChat">
<text>联系跑腿</text>
<view v-if="!isOwner" class="action-btn btn-primary" @click="goCompleteOrder">
<text>完成订单</text>
</view>
<view class="action-btn btn-secondary" @click="goChat">
<text>{{ isOwner ? '联系跑腿' : '联系单主' }}</text>
</view>
</template>
<!-- 已完成评价跑腿 + 联系跑腿 -->
<!-- 已完成单主可评价+联系跑腿跑腿可联系单主 -->
<template v-else-if="order.status === 'Completed'">
<view v-if="!order.isReviewed" class="action-btn btn-primary" @click="openReview">
<view v-if="isOwner && !order.isReviewed" class="action-btn btn-primary" @click="openReview">
<text>评价跑腿</text>
</view>
<view class="action-btn btn-secondary" @click="goChat">
<text>联系跑腿</text>
<text>{{ isOwner ? '联系跑腿' : '联系单主' }}</text>
</view>
</template>
<!-- 待确认确认处理 -->
<!-- 待确认单主看到确认/拒绝按钮跑腿看到联系单主 -->
<template v-else-if="order.status === 'WaitConfirm'">
<view class="action-btn btn-primary" @click="goConfirm">
<text>确认处理</text>
<template v-if="isOwner">
<view class="action-btn btn-cancel" @click="onRejectComplete">
<text>订单未完成</text>
</view>
<view class="action-btn btn-primary" @click="onConfirmComplete">
<text>确认订单完成</text>
</view>
</template>
<view class="action-btn btn-secondary" @click="goChat">
<text>{{ isOwner ? '联系跑腿' : '联系单主' }}</text>
</view>
</template>
</view>
@ -207,7 +218,7 @@
</template>
<script>
import { getOrderDetail, cancelOrder, submitReview } from '../../utils/api'
import { getOrderDetail, cancelOrder, submitReview, confirmOrder, rejectOrder } from '../../utils/api'
import { useUserStore } from '../../stores/user'
export default {
@ -225,6 +236,11 @@ export default {
}
},
computed: {
/** 当前用户是否为单主 */
isOwner() {
const userStore = useUserStore()
return this.order.ownerId === userStore.userId
},
/** 按门店分组美食街菜品 */
groupedFoodItems() {
const groups = {}
@ -324,9 +340,45 @@ export default {
uni.navigateTo({ url: `/pages/message/chat?orderId=${this.orderId}` })
},
/** 跳转确认处理页 */
goConfirm() {
uni.navigateTo({ url: `/pages/order/complete-order?id=${this.orderId}&mode=confirm` })
/** 跳转完成订单页(跑腿提交完成凭证) */
goCompleteOrder() {
uni.navigateTo({ url: `/pages/order/complete-order?id=${this.orderId}` })
},
/** 单主确认订单完成 */
onConfirmComplete() {
uni.showModal({
title: '确认完成',
content: '确认该订单已完成?',
success: async (res) => {
if (!res.confirm) return
try {
await confirmOrder(this.orderId)
uni.showToast({ title: '订单已完成', icon: 'success' })
this.loadDetail()
} catch (e) {
// request
}
}
})
},
/** 单主拒绝订单完成 */
onRejectComplete() {
uni.showModal({
title: '拒绝完成',
content: '确认该订单未完成?订单将继续进行。',
success: async (res) => {
if (!res.confirm) return
try {
await rejectOrder(this.orderId)
uni.showToast({ title: '已拒绝,订单继续进行', icon: 'none' })
this.loadDetail()
} catch (e) {
// request
}
}
})
},
/** 打开评价弹窗 */
@ -433,7 +485,7 @@ export default {
}
.status-pending .status-text { color: #faad14; }
.status-progress .status-text { color: #007AFF; }
.status-progress .status-text { color: #FFB700; }
.status-confirm .status-text { color: #ff9900; }
.status-done .status-text { color: #52c41a; }
.status-cancel .status-text { color: #999999; }
@ -679,7 +731,7 @@ export default {
}
.modal-btn.confirm {
background-color: #007AFF;
background-color: #FAD146;
color: #ffffff;
}
</style>

View File

@ -192,6 +192,11 @@ export function getPrivacy() {
return request({ url: '/api/config/privacy' })
}
/** 获取跑腿协议 */
export function getRunnerAgreement() {
return request({ url: '/api/config/runner-agreement' })
}
/** 获取提现说明 */
export function getWithdrawalGuide() {
return request({ url: '/api/config/withdrawal-guide' })

View File

@ -91,6 +91,49 @@ export function getConversationId(targetUserId) {
return `C2C${targetUserId}`
}
/**
* 获取会话列表用于消息页展示聊天记录
* @returns {Promise<Array>} 会话列表
*/
export async function getConversationList() {
if (!chat || !isReady) return []
try {
const res = await chat.getConversationList()
const list = res.data.conversationList || []
// 只返回 C2C 单聊会话
return list
.filter(c => c.type === TencentCloudChat.TYPES.CONV_C2C)
.map(c => {
const lastMsg = c.lastMessage
let lastMessageText = ''
if (lastMsg) {
if (lastMsg.type === TencentCloudChat.TYPES.MSG_TEXT) {
lastMessageText = lastMsg.payload?.text || ''
} else if (lastMsg.type === TencentCloudChat.TYPES.MSG_IMAGE) {
lastMessageText = '[图片]'
} else if (lastMsg.type === TencentCloudChat.TYPES.MSG_CUSTOM) {
lastMessageText = lastMsg.payload?.description || '[自定义消息]'
} else {
lastMessageText = '[消息]'
}
}
return {
conversationID: c.conversationID,
targetUserId: c.userProfile?.userID || c.conversationID.replace('C2C', ''),
nickname: c.userProfile?.nick || c.conversationID.replace('C2C', ''),
avatarUrl: c.userProfile?.avatar || '',
lastMessage: lastMessageText,
lastMessageTime: lastMsg ? lastMsg.lastTime * 1000 : 0,
unreadCount: c.unreadCount || 0
}
})
.sort((a, b) => b.lastMessageTime - a.lastMessageTime)
} catch (e) {
console.error('[IM] 获取会话列表失败:', e)
return []
}
}
/**
* 拉取历史消息
* @param {string} targetUserId - 对方用户 ID

View File

@ -0,0 +1,907 @@
// <auto-generated />
using System;
using CampusErrand.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace CampusErrand.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260320094803_AddWithdrawalProcessedAt")]
partial class AddWithdrawalProcessedAt
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CampusErrand.Models.Appeal", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<int>("OrderId")
.HasColumnType("int");
b.Property<string>("Result")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.HasKey("Id");
b.HasIndex("OrderId");
b.ToTable("Appeals");
});
modelBuilder.Entity("CampusErrand.Models.Banner", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("ImageUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<bool>("IsEnabled")
.HasColumnType("bit");
b.Property<string>("LinkType")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("LinkUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("SortOrder");
b.ToTable("Banners");
});
modelBuilder.Entity("CampusErrand.Models.CommissionRule", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<decimal?>("MaxAmount")
.HasColumnType("decimal(10,2)");
b.Property<decimal>("MinAmount")
.HasColumnType("decimal(10,2)");
b.Property<decimal>("Rate")
.HasColumnType("decimal(10,4)");
b.Property<string>("RateType")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("CommissionRules");
});
modelBuilder.Entity("CampusErrand.Models.Dish", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<bool>("IsEnabled")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Photo")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<decimal>("Price")
.HasColumnType("decimal(10,2)");
b.Property<int>("ShopId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("ShopId");
b.ToTable("Dishes");
});
modelBuilder.Entity("CampusErrand.Models.Earning", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("Commission")
.HasColumnType("decimal(10,2)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<DateTime>("FrozenUntil")
.HasColumnType("datetime2");
b.Property<decimal?>("GoodsAmount")
.HasColumnType("decimal(10,2)");
b.Property<decimal>("NetEarning")
.HasColumnType("decimal(10,2)");
b.Property<int>("OrderId")
.HasColumnType("int");
b.Property<decimal>("PlatformFee")
.HasColumnType("decimal(10,2)");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("OrderId");
b.HasIndex("UserId");
b.ToTable("Earnings");
});
modelBuilder.Entity("CampusErrand.Models.FoodOrderItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("DishId")
.HasColumnType("int");
b.Property<int>("OrderId")
.HasColumnType("int");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<int>("ShopId")
.HasColumnType("int");
b.Property<decimal>("UnitPrice")
.HasColumnType("decimal(10,2)");
b.HasKey("Id");
b.HasIndex("DishId");
b.HasIndex("OrderId");
b.HasIndex("ShopId");
b.ToTable("FoodOrderItems");
});
modelBuilder.Entity("CampusErrand.Models.MessageRead", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("MessageId")
.HasColumnType("int");
b.Property<string>("MessageType")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<DateTime>("ReadAt")
.HasColumnType("datetime2");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("UserId", "MessageType", "MessageId")
.IsUnique();
b.ToTable("MessageReads");
});
modelBuilder.Entity("CampusErrand.Models.Order", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime?>("AcceptedAt")
.HasColumnType("datetime2");
b.Property<decimal>("Commission")
.HasColumnType("decimal(10,2)");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("datetime2");
b.Property<string>("CompletionProof")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("DeliveryLocation")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<decimal?>("GoodsAmount")
.HasColumnType("decimal(10,2)");
b.Property<bool>("IsReviewed")
.HasColumnType("bit");
b.Property<string>("ItemName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("OrderNo")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("OrderType")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("OwnerId")
.HasColumnType("int");
b.Property<string>("Phone")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("PickupLocation")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Remark")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<int?>("RunnerId")
.HasColumnType("int");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<decimal>("TotalAmount")
.HasColumnType("decimal(10,2)");
b.HasKey("Id");
b.HasIndex("OrderNo")
.IsUnique();
b.HasIndex("OwnerId");
b.HasIndex("RunnerId");
b.HasIndex("Status");
b.ToTable("Orders");
});
modelBuilder.Entity("CampusErrand.Models.PriceChange", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ChangeType")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<int>("InitiatorId")
.HasColumnType("int");
b.Property<decimal>("NewPrice")
.HasColumnType("decimal(10,2)");
b.Property<int>("OrderId")
.HasColumnType("int");
b.Property<decimal>("OriginalPrice")
.HasColumnType("decimal(10,2)");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("InitiatorId");
b.HasIndex("OrderId");
b.ToTable("PriceChanges");
});
modelBuilder.Entity("CampusErrand.Models.Review", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Content")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<bool>("IsDisabled")
.HasColumnType("bit");
b.Property<int>("OrderId")
.HasColumnType("int");
b.Property<int>("Rating")
.HasColumnType("int");
b.Property<int>("RunnerId")
.HasColumnType("int");
b.Property<int>("ScoreChange")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("OrderId");
b.HasIndex("RunnerId");
b.ToTable("Reviews");
});
modelBuilder.Entity("CampusErrand.Models.RunnerCertification", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("Phone")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("RealName")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<DateTime?>("ReviewedAt")
.HasColumnType("datetime2");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("RunnerCertifications");
});
modelBuilder.Entity("CampusErrand.Models.ServiceEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("IconUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<bool>("IsEnabled")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("PagePath")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("SortOrder");
b.ToTable("ServiceEntries");
});
modelBuilder.Entity("CampusErrand.Models.Shop", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<bool>("IsEnabled")
.HasColumnType("bit");
b.Property<string>("Location")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Notice")
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<decimal>("PackingFeeAmount")
.HasColumnType("decimal(10,2)");
b.Property<string>("PackingFeeType")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Photo")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.HasKey("Id");
b.ToTable("Shops");
});
modelBuilder.Entity("CampusErrand.Models.ShopBanner", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ImageUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<int>("ShopId")
.HasColumnType("int");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("ShopId");
b.ToTable("ShopBanners");
});
modelBuilder.Entity("CampusErrand.Models.SystemConfig", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("Key")
.IsUnique();
b.ToTable("SystemConfigs");
});
modelBuilder.Entity("CampusErrand.Models.SystemMessage", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Content")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("TargetType")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("TargetUserIds")
.HasColumnType("nvarchar(max)");
b.Property<string>("ThumbnailUrl")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.HasKey("Id");
b.ToTable("SystemMessages");
});
modelBuilder.Entity("CampusErrand.Models.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("AvatarUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<bool>("IsBanned")
.HasColumnType("bit");
b.Property<string>("Nickname")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("OpenId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("Phone")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("RunnerScore")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("OpenId")
.IsUnique();
b.HasIndex("Phone");
b.ToTable("Users");
});
modelBuilder.Entity("CampusErrand.Models.Withdrawal", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("Amount")
.HasColumnType("decimal(10,2)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("PaymentMethod")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("ProcessedAt")
.HasColumnType("datetime2");
b.Property<string>("QrCodeImage")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Withdrawals");
});
modelBuilder.Entity("CampusErrand.Models.Appeal", b =>
{
b.HasOne("CampusErrand.Models.Order", "Order")
.WithMany()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Order");
});
modelBuilder.Entity("CampusErrand.Models.Dish", b =>
{
b.HasOne("CampusErrand.Models.Shop", "Shop")
.WithMany("Dishes")
.HasForeignKey("ShopId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Shop");
});
modelBuilder.Entity("CampusErrand.Models.Earning", b =>
{
b.HasOne("CampusErrand.Models.Order", "Order")
.WithMany()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("CampusErrand.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Order");
b.Navigation("User");
});
modelBuilder.Entity("CampusErrand.Models.FoodOrderItem", b =>
{
b.HasOne("CampusErrand.Models.Dish", "Dish")
.WithMany()
.HasForeignKey("DishId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("CampusErrand.Models.Order", "Order")
.WithMany("FoodOrderItems")
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("CampusErrand.Models.Shop", "Shop")
.WithMany()
.HasForeignKey("ShopId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Dish");
b.Navigation("Order");
b.Navigation("Shop");
});
modelBuilder.Entity("CampusErrand.Models.MessageRead", b =>
{
b.HasOne("CampusErrand.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("CampusErrand.Models.Order", b =>
{
b.HasOne("CampusErrand.Models.User", "Owner")
.WithMany()
.HasForeignKey("OwnerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("CampusErrand.Models.User", "Runner")
.WithMany()
.HasForeignKey("RunnerId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Owner");
b.Navigation("Runner");
});
modelBuilder.Entity("CampusErrand.Models.PriceChange", b =>
{
b.HasOne("CampusErrand.Models.User", "Initiator")
.WithMany()
.HasForeignKey("InitiatorId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("CampusErrand.Models.Order", "Order")
.WithMany()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Initiator");
b.Navigation("Order");
});
modelBuilder.Entity("CampusErrand.Models.Review", b =>
{
b.HasOne("CampusErrand.Models.Order", "Order")
.WithMany()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("CampusErrand.Models.User", "Runner")
.WithMany()
.HasForeignKey("RunnerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Order");
b.Navigation("Runner");
});
modelBuilder.Entity("CampusErrand.Models.RunnerCertification", b =>
{
b.HasOne("CampusErrand.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("CampusErrand.Models.ShopBanner", b =>
{
b.HasOne("CampusErrand.Models.Shop", "Shop")
.WithMany("ShopBanners")
.HasForeignKey("ShopId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Shop");
});
modelBuilder.Entity("CampusErrand.Models.Withdrawal", b =>
{
b.HasOne("CampusErrand.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("CampusErrand.Models.Order", b =>
{
b.Navigation("FoodOrderItems");
});
modelBuilder.Entity("CampusErrand.Models.Shop", b =>
{
b.Navigation("Dishes");
b.Navigation("ShopBanners");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,29 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CampusErrand.Migrations
{
/// <inheritdoc />
public partial class AddWithdrawalProcessedAt : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "ProcessedAt",
table: "Withdrawals",
type: "datetime2",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ProcessedAt",
table: "Withdrawals");
}
}
}

View File

@ -697,6 +697,9 @@ namespace CampusErrand.Migrations
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("ProcessedAt")
.HasColumnType("datetime2");
b.Property<string>("QrCodeImage")
.IsRequired()
.HasMaxLength(512)

View File

@ -66,3 +66,13 @@ public class WithdrawRequest
[Required(ErrorMessage = "收款二维码不能为空")]
public string QrCodeImage { get; set; } = string.Empty;
}
/// <summary>
/// 管理端提现审核请求
/// </summary>
public class AdminWithdrawalRequest
{
/// <summary>操作approve通过、reject拒绝、processing处理中</summary>
[Required(ErrorMessage = "操作类型不能为空")]
public string Action { get; set; } = string.Empty;
}

View File

@ -83,6 +83,8 @@ public class OrderResponse
public string? RunnerNickname { get; set; }
/// <summary>跑腿 UID</summary>
public int? RunnerUid { get; set; }
/// <summary>跑腿手机号(认证时填写的手机号,仅单主可见)</summary>
public string? RunnerPhone { get; set; }
public List<FoodOrderItemResponse>? FoodItems { get; set; }
}

View File

@ -31,6 +31,9 @@ public class Withdrawal
/// <summary>申请时间</summary>
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
/// <summary>处理时间</summary>
public DateTime? ProcessedAt { get; set; }
// 导航属性
[ForeignKey(nameof(UserId))]
public User? User { get; set; }

View File

@ -721,6 +721,17 @@ app.MapGet("/api/orders/{id}", async (int id, HttpContext httpContext, AppDbCont
runnerUid = order.RunnerId;
}
// 跑腿手机号:从认证记录中获取,仅单主可见
string? runnerPhone = null;
if (currentUserId == order.OwnerId && order.RunnerId != null)
{
var cert = await db.RunnerCertifications
.Where(c => c.UserId == order.RunnerId && c.Status == CertificationStatus.Approved)
.OrderByDescending(c => c.CreatedAt)
.FirstOrDefaultAsync();
runnerPhone = cert?.Phone;
}
if (order.Status == OrderStatus.Completed || order.Status == OrderStatus.WaitConfirm)
{
// 已完成和待确认状态显示完成时间和凭证
@ -750,7 +761,8 @@ app.MapGet("/api/orders/{id}", async (int id, HttpContext httpContext, AppDbCont
AcceptedAt = visibleAcceptedAt,
CompletedAt = visibleCompletedAt,
RunnerNickname = runnerNickname,
RunnerUid = runnerUid
RunnerUid = runnerUid,
RunnerPhone = runnerPhone
};
// 美食街订单附带菜品详情
@ -2119,6 +2131,118 @@ app.MapPost("/api/earnings/withdraw", async (WithdrawRequest request, HttpContex
});
}).RequireAuthorization();
// ========== 管理端提现审核接口 ==========
// 管理端获取提现列表
app.MapGet("/api/admin/withdrawals", async (string? status, AppDbContext db) =>
{
var query = db.Withdrawals
.Include(w => w.User)
.AsQueryable();
if (!string.IsNullOrEmpty(status) && Enum.TryParse<WithdrawalStatus>(status, out var s))
query = query.Where(w => w.Status == s);
var list = await query
.OrderByDescending(w => w.CreatedAt)
.Select(w => new
{
w.Id,
w.UserId,
UserNickname = w.User!.Nickname ?? ("用户" + w.UserId),
w.Amount,
PaymentMethod = w.PaymentMethod.ToString(),
w.QrCodeImage,
Status = w.Status.ToString(),
w.CreatedAt,
w.ProcessedAt
})
.ToListAsync();
return Results.Ok(list);
}).RequireAuthorization("AdminOnly");
// 管理端审核提现(通过/拒绝)
app.MapPut("/api/admin/withdrawals/{id}", async (int id, AdminWithdrawalRequest request, AppDbContext db) =>
{
var withdrawal = await db.Withdrawals.FindAsync(id);
if (withdrawal == null)
return Results.NotFound(new { code = 404, message = "提现记录不存在" });
if (withdrawal.Status != WithdrawalStatus.Pending && withdrawal.Status != WithdrawalStatus.Processing)
return Results.BadRequest(new { code = 400, message = "该提现记录已处理完毕" });
if (request.Action == "approve")
{
withdrawal.Status = WithdrawalStatus.Completed;
withdrawal.ProcessedAt = DateTime.UtcNow;
// 将对应收益标记为已提现
var earnings = await db.Earnings
.Where(e => e.UserId == withdrawal.UserId && e.Status == EarningStatus.Withdrawing)
.OrderBy(e => e.CreatedAt)
.ToListAsync();
var remaining = withdrawal.Amount;
foreach (var earning in earnings)
{
if (remaining <= 0) break;
if (earning.NetEarning <= remaining)
{
earning.Status = EarningStatus.Withdrawn;
remaining -= earning.NetEarning;
}
else
{
earning.Status = EarningStatus.Withdrawn;
remaining = 0;
}
}
}
else if (request.Action == "reject")
{
withdrawal.Status = WithdrawalStatus.Pending;
withdrawal.ProcessedAt = DateTime.UtcNow;
// 将对应收益退回待提现状态
var earnings = await db.Earnings
.Where(e => e.UserId == withdrawal.UserId && e.Status == EarningStatus.Withdrawing)
.OrderBy(e => e.CreatedAt)
.ToListAsync();
var remaining = withdrawal.Amount;
foreach (var earning in earnings)
{
if (remaining <= 0) break;
if (earning.NetEarning <= remaining)
{
earning.Status = EarningStatus.Available;
remaining -= earning.NetEarning;
}
else
{
earning.Status = EarningStatus.Available;
remaining = 0;
}
}
// 拒绝后将提现记录状态设为特殊标记(复用 Pending 但已有 ProcessedAt
// 实际上拒绝后应该删除或标记,这里直接删除提现记录
db.Withdrawals.Remove(withdrawal);
}
else if (request.Action == "processing")
{
withdrawal.Status = WithdrawalStatus.Processing;
}
else
{
return Results.BadRequest(new { code = 400, message = "无效操作,可选: approve, reject, processing" });
}
await db.SaveChangesAsync();
return Results.Ok(new { message = "操作成功" });
}).RequireAuthorization("AdminOnly");
// ========== 佣金规则接口 ==========
// 获取佣金规则
@ -2369,13 +2493,13 @@ app.MapPost("/api/admin/notifications", async (CreateNotificationRequest request
// 管理端获取跑腿列表
app.MapGet("/api/admin/runners", async (AppDbContext db) =>
{
var runners = await db.Users
.Where(u => db.RunnerCertifications.Any(c => c.UserId == u.Id && c.Status == CertificationStatus.Approved))
.Select(u => new
var runners = await db.RunnerCertifications
.Where(c => c.Status == CertificationStatus.Approved)
.Join(db.Users, c => c.UserId, u => u.Id, (c, u) => new
{
u.Id,
u.Nickname,
u.Phone,
Phone = c.Phone, // 使用认证表中的手机号
u.RunnerScore,
u.IsBanned,
u.CreatedAt
@ -2638,6 +2762,18 @@ app.MapGet("/api/config/privacy", async (AppDbContext db) =>
});
});
// 获取跑腿协议
app.MapGet("/api/config/runner-agreement", async (AppDbContext db) =>
{
var config = await db.SystemConfigs.FirstOrDefaultAsync(c => c.Key == "runner_agreement");
return Results.Ok(new ConfigResponse
{
Key = "runner_agreement",
Value = config?.Value ?? "",
UpdatedAt = config?.UpdatedAt ?? DateTime.MinValue
});
});
// 获取提现说明
app.MapGet("/api/config/withdrawal-guide", async (AppDbContext db) =>
{
@ -2668,7 +2804,7 @@ app.MapPut("/api/admin/config/{key}", async (string key, UpdateConfigRequest req
// 允许的配置键白名单
var allowedKeys = new HashSet<string>
{
"qrcode", "agreement", "privacy", "withdrawal_guide", "freeze_days",
"qrcode", "agreement", "privacy", "runner_agreement", "withdrawal_guide", "freeze_days",
"page_banner_pickup", "page_banner_delivery", "page_banner_help", "page_banner_purchase", "page_banner_food",
"page_banner_order-hall"
};
@ -2750,6 +2886,26 @@ using (var scope = app.Services.CreateScope())
}
}
// 注册后台定时任务每10分钟执行一次自动确认和解冻
_ = Task.Run(async () =>
{
while (true)
{
await Task.Delay(TimeSpan.FromMinutes(10));
try
{
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await AutoConfirmExpiredOrders(db);
await UnfreezeEarnings(db);
}
catch (Exception ex)
{
Console.WriteLine($"[定时任务] 执行失败: {ex.Message}");
}
}
});
app.Run();
// Banner 请求校验辅助方法
@ -2880,5 +3036,50 @@ static async Task UnfreezeEarnings(AppDbContext db)
}
}
/// <summary>
/// 自动确认超过24小时未处理的待确认订单
/// </summary>
static async Task AutoConfirmExpiredOrders(AppDbContext db)
{
var cutoff = DateTime.UtcNow.AddHours(-24);
var expiredOrders = await db.Orders
.Where(o => o.Status == OrderStatus.WaitConfirm && o.CompletedAt != null && o.CompletedAt <= cutoff)
.ToListAsync();
foreach (var order in expiredOrders)
{
order.Status = OrderStatus.Completed;
// 计算佣金收益
var rules = await db.CommissionRules.OrderBy(r => r.MinAmount).ToListAsync();
var platformFee = CalculatePlatformFee(order.Commission, rules);
var netEarning = order.Commission - platformFee;
var freezeDaysConfig = await db.SystemConfigs.FirstOrDefaultAsync(c => c.Key == "freeze_days");
var freezeDays = 1;
if (freezeDaysConfig != null && int.TryParse(freezeDaysConfig.Value, out var configDays))
freezeDays = configDays;
db.Earnings.Add(new Earning
{
UserId = order.RunnerId!.Value,
OrderId = order.Id,
GoodsAmount = order.GoodsAmount,
Commission = order.Commission,
PlatformFee = platformFee,
NetEarning = netEarning,
Status = EarningStatus.Frozen,
FrozenUntil = DateTime.UtcNow.AddDays(freezeDays),
CreatedAt = DateTime.UtcNow
});
}
if (expiredOrders.Count > 0)
{
await db.SaveChangesAsync();
Console.WriteLine($"[定时任务] 自动确认了 {expiredOrders.Count} 个超时订单");
}
}
// 用于集成测试访问
public partial class Program { }