跑腿,聊天

This commit is contained in:
18631081161 2026-04-01 12:50:17 +08:00
parent 58b34666bd
commit f9b90a761a
33 changed files with 1640 additions and 180 deletions

View File

@ -1,47 +1,85 @@
<template>
<div>
<h3 style="margin: 0 0 16px;">发布系统通知</h3>
<div class="notifications-page">
<el-tabs v-model="activeTab">
<!-- 发布通知 -->
<el-tab-pane label="发布通知" name="publish">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" style="max-width: 700px;">
<el-form-item label="通知标题" prop="title">
<el-input v-model="form.title" placeholder="请输入通知标题" />
</el-form-item>
<el-form-item label="通知正文" prop="content">
<el-input v-model="form.content" type="textarea" :rows="8" placeholder="支持富文本内容" />
</el-form-item>
<el-form-item label="缩略图">
<el-input v-model="form.thumbnailUrl" placeholder="缩略图地址(选填)" />
<el-upload action="/api/upload/image" :headers="uploadHeaders" :show-file-list="false"
:on-success="(res) => form.thumbnailUrl = res.url" accept="image/*" style="margin-top: 8px;">
<el-button size="small">上传图片</el-button>
</el-upload>
</el-form-item>
<el-form-item label="目标用户" prop="targetType">
<el-select v-model="form.targetType" style="width: 100%;">
<el-option label="全部用户" value="All" />
<el-option label="下单用户" value="OrderUser" />
<el-option label="跑腿用户" value="RunnerUser" />
<el-option label="指定用户" value="Specific" />
</el-select>
</el-form-item>
<el-form-item v-if="form.targetType === 'Specific'" label="用户ID列表">
<el-input v-model="targetUserIdsText" placeholder="多个用户ID用逗号分隔1,2,3" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitting" @click="handleSubmit">发布通知</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" style="max-width: 700px;">
<el-form-item label="通知标题" prop="title">
<el-input v-model="form.title" placeholder="请输入通知标题" />
</el-form-item>
<el-form-item label="通知正文" prop="content">
<el-input v-model="form.content" type="textarea" :rows="8" placeholder="支持富文本内容" />
</el-form-item>
<el-form-item label="缩略图">
<el-input v-model="form.thumbnailUrl" placeholder="缩略图地址(选填)" />
<el-upload action="/api/upload/image" :headers="uploadHeaders" :show-file-list="false"
:on-success="(res) => form.thumbnailUrl = res.url" accept="image/*" style="margin-top: 8px;">
<el-button size="small">上传图片</el-button>
</el-upload>
</el-form-item>
<el-form-item label="目标用户" prop="targetType">
<el-select v-model="form.targetType" style="width: 100%;">
<el-option label="全部用户" value="All" />
<el-option label="下单用户" value="OrderUser" />
<el-option label="跑腿用户" value="RunnerUser" />
<el-option label="指定用户" value="Specific" />
</el-select>
</el-form-item>
<el-form-item v-if="form.targetType === 'Specific'" label="用户ID列表">
<el-input v-model="targetUserIdsText" placeholder="多个用户ID用逗号分隔1,2,3" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitting" @click="handleSubmit">发布通知</el-button>
</el-form-item>
</el-form>
<!-- 已发送通知 -->
<el-tab-pane label="已发送" name="list">
<el-table :data="notifyList" v-loading="listLoading" stripe>
<el-table-column prop="id" label="ID" width="60" align="center" />
<el-table-column prop="title" label="标题" min-width="180" show-overflow-tooltip />
<el-table-column prop="contentPreview" label="内容预览" min-width="200" show-overflow-tooltip />
<el-table-column label="目标" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" :type="targetTagType(row.targetType)">{{ targetLabel(row.targetType) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="缩略图" width="80" align="center">
<template #default="{ row }">
<el-image v-if="row.thumbnailUrl" :src="row.thumbnailUrl" style="width: 40px; height: 40px;" fit="cover" :preview-src-list="[row.thumbnailUrl]" />
<span v-else style="color: #ccc;">-</span>
</template>
</el-table-column>
<el-table-column label="发布时间" width="170">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="80" fixed="right" align="center">
<template #default="{ row }">
<el-popconfirm title="删除后用户端也会同步删除,确定?" @confirm="deleteNotify(row)">
<template #reference>
<el-button size="small" type="danger" plain>删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import request from '../utils/request'
const activeTab = ref('publish')
const submitting = ref(false)
const formRef = ref(null)
const targetUserIdsText = ref('')
const listLoading = ref(false)
const notifyList = ref([])
const uploadHeaders = { Authorization: `Bearer ${localStorage.getItem('admin_token')}` }
@ -58,10 +96,13 @@ const rules = {
targetType: [{ required: true, message: '请选择目标用户', trigger: 'change' }]
}
const targetLabel = (t) => ({ All: '全部', OrderUser: '下单用户', RunnerUser: '跑腿用户', Specific: '指定用户' }[t] || t)
const targetTagType = (t) => ({ All: '', OrderUser: 'success', RunnerUser: 'warning', Specific: 'info' }[t] || '')
const formatTime = (t) => t ? new Date(t).toLocaleString('zh-CN') : ''
async function handleSubmit() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
const data = { ...form }
if (data.targetType === 'Specific') {
data.targetUserIds = targetUserIdsText.value.split(',').map(s => parseInt(s.trim())).filter(n => !isNaN(n))
@ -70,16 +111,38 @@ async function handleSubmit() {
return
}
}
submitting.value = true
try {
await request.post('/admin/notifications', data)
ElMessage.success('发布成功')
//
formRef.value.resetFields()
targetUserIdsText.value = ''
fetchList()
} finally {
submitting.value = false
}
}
async function fetchList() {
listLoading.value = true
try {
notifyList.value = await request.get('/admin/notifications')
} finally {
listLoading.value = false
}
}
async function deleteNotify(row) {
await request.delete(`/admin/notifications/${row.id}`)
ElMessage.success('已删除')
fetchList()
}
onMounted(fetchList)
</script>
<style scoped>
.notifications-page {
max-width: 900px;
}
</style>

View File

@ -2,10 +2,16 @@
<div class="runners-page">
<div class="page-header">
<h2>跑腿管理</h2>
<el-tag type="info" size="large"> {{ list.length }} 名跑腿</el-tag>
<div class="header-actions">
<el-input v-model="searchUid" placeholder="输入UID搜索" clearable style="width: 200px;"
@clear="searchUid = ''" @keyup.enter="searchUid = searchUid.trim()">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-tag type="info" size="large"> {{ filteredList.length }} 名跑腿</el-tag>
</div>
</div>
<el-table :data="list" v-loading="loading" stripe style="width: 100%">
<el-table :data="filteredList" 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="120" show-overflow-tooltip />
<el-table-column prop="phone" label="手机号" min-width="130" />
@ -25,10 +31,9 @@
</el-table-column>
<el-table-column label="评价星级" width="120" align="center">
<template #default="{ row }">
<span v-if="row.reviewCount > 0" style="color: #ff9900; font-weight: bold;">
{{ row.avgRating }}
<span style="color: #ff9900; font-weight: bold;">
{{ row.starRating }}
</span>
<span v-else style="color: #ccc;">暂无</span>
</template>
</el-table-column>
<el-table-column label="被评价" width="100" align="center">
@ -107,12 +112,19 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import request from '../utils/request'
const loading = ref(false)
const list = ref([])
const searchUid = ref('')
const filteredList = computed(() => {
if (!searchUid.value) return list.value
return list.value.filter(r => String(r.id).includes(searchUid.value.trim()))
})
const reviewDialogVisible = ref(false)
const reviewLoading = ref(false)
const reviews = ref([])
@ -177,6 +189,11 @@ onMounted(fetchList)
margin: 0;
font-size: 20px;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.score-cell {
display: flex;
align-items: center;

View File

@ -10,7 +10,8 @@
"license": "ISC",
"dependencies": {
"@tencentcloud/chat": "^3.6.6",
"pinia": "^3.0.4"
"pinia": "^3.0.4",
"tim-upload-plugin": "^1.4.3"
},
"devDependencies": {
"fast-check": "^4.5.3",
@ -1599,6 +1600,12 @@
"node": ">=16"
}
},
"node_modules/tim-upload-plugin": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/tim-upload-plugin/-/tim-upload-plugin-1.4.3.tgz",
"integrity": "sha512-3ZmbA36dr3eG9YGDon9MLBUtbNawYWkL+TBa+VS0Uviguc7PlVSOIVRG2C4irXX16slDT2Kj+HAZapp+Xqp2xg==",
"license": "ISC"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",

View File

@ -12,7 +12,8 @@
"type": "commonjs",
"dependencies": {
"@tencentcloud/chat": "^3.6.6",
"pinia": "^3.0.4"
"pinia": "^3.0.4",
"tim-upload-plugin": "^1.4.3"
},
"devDependencies": {
"fast-check": "^4.5.3",

View File

@ -79,7 +79,7 @@
</template>
<script>
import { createOrder, getPageBanner, getMinCommission } from '../../utils/api'
import { createOrder, getPageBanner, getMinCommission, cancelOrder, confirmPayment } from '../../utils/api'
export default {
data() {
@ -139,8 +139,8 @@ export default {
if (isNaN(num) || num < this.minCommission) {
uni.showToast({ title: `跑腿佣金不可低于${this.minCommission}`, icon: 'none' }); return false
}
if (val.includes('.') && val.split('.')[1].length > 1) {
uni.showToast({ title: '佣金最多支持小数点后1位', icon: 'none' }); return false
if (val.includes('.') && val.split('.')[1].length > 2) {
uni.showToast({ title: '佣金最多支持小数点后2位', icon: 'none' }); return false
}
return true
},
@ -184,7 +184,17 @@ export default {
commission,
totalAmount: commission
})
if (result.paymentParams) await this.wxPay(result.paymentParams)
if (result.paymentParams) {
try {
await this.wxPay(result.paymentParams)
//
try { await confirmPayment(result.id) } catch (ex) {}
} catch (e) {
// /
try { await cancelOrder(result.id) } catch (ex) {}
return
}
}
uni.showToast({ title: '下单成功', icon: 'success' })
setTimeout(() => { uni.navigateBack() }, 1500)
} catch (e) {} finally { this.submitting = false }

View File

@ -68,7 +68,7 @@
</template>
<script>
import { createOrder, getMinCommission } from '../../utils/api'
import { createOrder, getMinCommission, cancelOrder, confirmPayment } from '../../utils/api'
import { useCartStore } from '../../stores/cart'
export default {
@ -133,8 +133,8 @@ export default {
uni.showToast({ title: `跑腿佣金不可低于${this.minCommission}`, icon: 'none' })
return false
}
if (val.includes('.') && val.split('.')[1].length > 1) {
uni.showToast({ title: '佣金最多支持小数点后1位', icon: 'none' })
if (val.includes('.') && val.split('.')[1].length > 2) {
uni.showToast({ title: '佣金最多支持小数点后2位', icon: 'none' })
return false
}
return true
@ -161,6 +161,13 @@ export default {
async onSubmit() {
if (!this.validateForm()) return
//
const token = uni.getStorageSync('token')
if (!token) {
uni.navigateTo({ url: '/pages/login/login' })
return
}
this.submitting = true
try {
const commission = parseFloat(this.form.commission)
@ -183,7 +190,15 @@ export default {
const result = await createOrder(orderData)
if (result.paymentParams) {
await this.wxPay(result.paymentParams)
try {
await this.wxPay(result.paymentParams)
//
try { await confirmPayment(result.id) } catch (ex) {}
} catch (e) {
// /
try { await cancelOrder(result.id) } catch (ex) {}
return
}
}
//

View File

@ -71,7 +71,9 @@
import {
createOrder,
getPageBanner,
getMinCommission
getMinCommission,
cancelOrder,
confirmPayment
} from '../../utils/api'
export default {
@ -149,9 +151,9 @@
})
return false
}
if (val.includes('.') && val.split('.')[1].length > 1) {
if (val.includes('.') && val.split('.')[1].length > 2) {
uni.showToast({
title: '佣金最多支持小数点后1位',
title: '佣金最多支持小数点后2位',
icon: 'none'
})
return false
@ -181,15 +183,11 @@
})
return false
}
if (!this.form.goodsAmount) {
uni.showToast({
title: '请输入商品总金额',
icon: 'none'
})
return false
if (!this.form.goodsAmount && this.form.goodsAmount !== '0') {
this.form.goodsAmount = '0'
}
const goodsNum = parseFloat(this.form.goodsAmount)
if (isNaN(goodsNum) || goodsNum <= 0) {
if (isNaN(goodsNum) || goodsNum < 0) {
uni.showToast({
title: '请输入正确的商品总金额',
icon: 'none'
@ -226,7 +224,17 @@
commission,
totalAmount: goodsAmount + commission
})
if (result.paymentParams) await this.wxPay(result.paymentParams)
if (result.paymentParams) {
try {
await this.wxPay(result.paymentParams)
//
try { await confirmPayment(result.id) } catch (ex) {}
} catch (e) {
// /
try { await cancelOrder(result.id) } catch (ex) {}
return
}
}
uni.showToast({
title: '下单成功',
icon: 'success'

View File

@ -22,9 +22,6 @@
<view class="service-list">
<view class="service-card" v-for="entry in serviceEntries" :key="entry.id" @click="onServiceClick(entry)">
<image class="service-bg" :src="entry.iconUrl" mode="aspectFit"></image>
<view class="service-overlay">
<text class="service-name">{{ entry.name }}</text>
</view>
</view>
</view>
</view>
@ -151,22 +148,4 @@
left: 0;
}
.service-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.service-name {
font-size: 48rpx;
font-weight: bold;
color: #363636;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
letter-spacing: 8rpx;
}
</style>

View File

@ -133,6 +133,9 @@
<view class="quick-btn" @click="onContactService">
<text>联系客服</text>
</view>
<view class="quick-btn btn-pay" v-if="pendingPayment" @click="retryPayment">
<text>补缴支付</text>
</view>
</view>
<!-- 底部输入区域 -->
@ -239,7 +242,8 @@
priceChangeType: '',
newPriceInput: '',
submittingPriceChange: false,
statusBarHeight: 0
statusBarHeight: 0,
pendingPayment: null
}
},
computed: {
@ -283,9 +287,8 @@
offNewMessage()
},
onShow() {
if (this.orderId && !this.orderInfo.id) {
this.loadOrderInfo()
} else if (this.orderId && this.lastOrderStatus) {
//
if (this.orderId) {
this.checkOrderStatusChange()
}
},
@ -339,7 +342,20 @@
const formatted = formatIMMessage(msg, this.imUserId)
// ID
if (this.orderId && formatted.orderId && String(formatted.orderId) !== String(this.orderId)) continue
this.chatMessages.push(formatted)
//
if (formatted.type === 'price-change-response') {
const card = this.chatMessages.find(m => m.type === 'price-change' && m.priceChangeId === formatted.priceChangeId)
if (card) card.status = formatted.status
//
this.chatMessages.push({
id: formatted.id,
type: 'system',
content: `对方${formatted.action === 'Accepted' ? '同意' : '拒绝'}${formatted.changeTypeLabel}改价`
})
if (formatted.action === 'Accepted') this.loadOrderInfo()
} else {
this.chatMessages.push(formatted)
}
}
}
this.scrollToBottom()
@ -360,9 +376,20 @@
const res = await getMessageList(this.targetImUserId, this.nextReqMessageID)
const allFormatted = res.messageList.map(m => formatIMMessage(m, this.imUserId))
// IDorderId
const formatted = this.orderId
const filtered = this.orderId
? allFormatted.filter(m => !m.orderId || String(m.orderId) === String(this.orderId))
: allFormatted
//
const formatted = []
for (const m of filtered) {
if (m.type === 'price-change-response') {
const card = filtered.find(c => c.type === 'price-change' && c.priceChangeId === m.priceChangeId)
if (card) card.status = m.status
formatted.push({ id: m.id, type: 'system', content: `${m.isSelf ? '您' : '对方'}${m.action === 'Accepted' ? '同意' : '拒绝'}${m.changeTypeLabel}改价` })
} else {
formatted.push(m)
}
}
this.chatMessages = [...formatted, ...this.chatMessages]
this.nextReqMessageID = res.nextReqMessageID
this.historyCompleted = res.isCompleted
@ -422,7 +449,7 @@
sourceType: ['album', 'camera'],
success: async (res) => {
try {
const msg = await sendImageMessage(this.targetImUserId, res.tempFilePaths[0], this.orderId)
const msg = await sendImageMessage(this.targetImUserId, res, this.orderId)
this.chatMessages.push(formatIMMessage(msg, this.imUserId))
this.scrollToBottom()
} catch (e) {
@ -435,17 +462,28 @@
})
},
onChangeCommission() {
this.showMorePanel = false;
this.showMorePanel = false
if (this.hasPendingPriceChange()) return
this.priceChangeType = 'Commission'
this.newPriceInput = '';
this.newPriceInput = ''
this.showPriceChangePopup = true
},
onChangeGoodsPrice() {
this.showMorePanel = false;
this.showMorePanel = false
if (this.hasPendingPriceChange()) return
this.priceChangeType = 'GoodsAmount'
this.newPriceInput = '';
this.newPriceInput = ''
this.showPriceChangePopup = true
},
/** 检查是否有待处理的改价申请 */
hasPendingPriceChange() {
const pending = this.chatMessages.find(m => m.type === 'price-change' && m.status === 'Pending')
if (pending) {
uni.showToast({ title: '已有等待处理的改价申请,无法同时申请', icon: 'none' })
return true
}
return false
},
async submitPriceChange() {
const newPrice = parseFloat(this.newPriceInput)
if (isNaN(newPrice) || newPrice < 0) {
@ -529,12 +567,47 @@
type: 'system',
content: `您已${actionLabel}${changeTypeLabel}改价`
})
if (action === 'Accepted' && res.difference !== 0) {
if (res.difference < 0) this.chatMessages.push({
id: `sys_r_${Date.now()}`,
type: 'system',
content: `已退还您${Math.abs(res.difference).toFixed(2)}`
})
// IM
if (this.imReady) {
await sendCustomMessage(this.targetImUserId, {
bizType: 'price-change-response',
priceChangeId: priceChangeId,
action: action,
status: res.status,
changeTypeLabel,
description: `${actionLabel}${changeTypeLabel}改价申请`
}, this.orderId)
}
if (action === 'Accepted') {
//
if (res.paymentParams) {
try {
await this.wxPay(res.paymentParams)
this.chatMessages.push({
id: `sys_pay_${Date.now()}`,
type: 'system',
content: `已补缴¥${res.difference.toFixed(2)}`
})
} catch (e) {
//
this.pendingPayment = res.paymentParams
this.chatMessages.push({
id: `sys_pay_${Date.now()}`,
type: 'system',
content: `需补缴¥${res.difference.toFixed(2)},点击下方"补缴支付"完成支付`
})
}
}
// 退退
if (res.difference < 0) {
this.chatMessages.push({
id: `sys_r_${Date.now()}`,
type: 'system',
content: res.refundSuccess ? `已退还您¥${Math.abs(res.difference).toFixed(2)}` : '退款处理中,请稍后查看'
})
}
await this.loadOrderInfo()
}
this.scrollToBottom()
@ -553,11 +626,16 @@
return
}
const newStatus = this.orderInfo.status
if (!oldStatus || oldStatus === newStatus) return
//
if (!oldStatus) {
this.lastOrderStatus = newStatus
return
}
if (oldStatus === newStatus) return
this.lastOrderStatus = newStatus
const statusMessages = {
'InProgress→WaitConfirm': '跑腿已提交完成,等待单主确认',
'InProgress→WaitConfirm': '跑腿已提交完成,请在订单详情中确认',
'WaitConfirm→Completed': '单主已确认,订单已完成',
'WaitConfirm→InProgress': '单主已拒绝完成,订单继续进行'
}
@ -565,24 +643,16 @@
const msg = statusMessages[key]
if (!msg) return
//
const lastMsg = [...this.chatMessages].reverse().find(m => m.type === 'system')
if (lastMsg && lastMsg.content === msg) return
this.chatMessages.push({
id: `sys_status_${Date.now()}`,
type: 'system',
content: msg
})
this.scrollToBottom()
if (this.imReady && this.targetImUserId) {
try {
await sendCustomMessage(this.targetImUserId, {
bizType: 'order-status',
action: key,
description: msg
}, this.orderId)
} catch (e) {
console.error('[聊天] 发送状态通知失败:', e)
}
}
},
onCompleteOrder() {
this.showMorePanel = false
@ -591,6 +661,14 @@
})
},
goOrderDetail() {
const pages = getCurrentPages()
if (pages.length >= 2) {
const prevPage = pages[pages.length - 2]
if (prevPage.route === 'pages/order/order-detail') {
uni.navigateBack()
return
}
}
uni.navigateTo({
url: `/pages/order/order-detail?id=${this.orderId}`
})
@ -614,6 +692,36 @@
url: '/pages/config/qrcode'
})
},
/** 微信支付 */
wxPay(params) {
return new Promise((resolve, reject) => {
uni.requestPayment({
provider: 'wxpay',
timeStamp: params.timeStamp,
nonceStr: params.nonceStr,
package: params.package_,
signType: params.signType,
paySign: params.paySign,
success: () => {
this.pendingPayment = null
resolve()
},
fail: (err) => {
if (err.errMsg !== 'requestPayment:fail cancel')
uni.showToast({ title: '支付失败', icon: 'none' })
reject(err)
}
})
})
},
/** 重新补缴支付 */
async retryPayment() {
if (!this.pendingPayment) return
try {
await this.wxPay(this.pendingPayment)
uni.showToast({ title: '补缴成功', icon: 'success' })
} catch (e) {}
},
previewImage(url) {
uni.previewImage({
urls: [url]
@ -887,6 +995,15 @@
color: #333;
}
.btn-pay {
background: #FAD146;
border-color: #FAD146;
}
.btn-pay text {
color: #fff;
}
/* 底部输入区域 */
.chat-bottom {
background: #fff;

View File

@ -52,7 +52,12 @@
:key="item.orderId"
@click="goChat(item)"
>
<image class="chat-avatar" :src="item.targetAvatar || '/static/logo.png'" mode="aspectFill"></image>
<view class="avatar-wrap">
<image class="chat-avatar" :src="item.targetAvatar || '/static/logo.png'" mode="aspectFill"></image>
<view v-if="item.imUnread > 0" class="unread-badge">
<text>{{ item.imUnread > 99 ? '99+' : item.imUnread }}</text>
</view>
</view>
<view class="chat-info">
<view class="chat-top">
<text class="chat-name">{{ displayNickname(item) }}</text>
@ -75,6 +80,7 @@
<script>
import { getUnreadCount, getChatOrderList } from '../../utils/api'
import { getConversationList, initIM } from '../../utils/im'
export default {
data() {
@ -86,7 +92,8 @@ export default {
totalUnread: 0
},
// IM SDK SDK
chatList: []
chatList: [],
imUnreadTotal: 0
}
},
onShow() {
@ -112,11 +119,29 @@ export default {
}
},
/** 加载聊天记录列表 */
/** 加载聊天记录列表含IM未读数 */
async loadChatList() {
try {
const list = await getChatOrderList()
this.chatList = list || []
this.chatList = (list || []).map(item => ({ ...item, imUnread: 0 }))
// IM
try {
await initIM()
const convList = await getConversationList()
const unreadMap = {}
convList.forEach(c => { unreadMap[c.targetUserId] = c.unreadCount || 0 })
let totalImUnread = 0
this.chatList.forEach(item => {
const imUserId = `user_${item.targetUserId}`
item.imUnread = unreadMap[imUserId] || 0
totalImUnread += item.imUnread
})
// tabBar badge + IM
this.imUnreadTotal = totalImUnread
this.updateTabBarBadge()
} catch (e) {
// IM
}
} catch (e) {
console.error('[消息页] 加载聊天列表失败:', e)
}
@ -124,7 +149,7 @@ export default {
/** 更新底部导航栏未读数 badge */
updateTabBarBadge() {
const total = this.unreadCount.totalUnread
const total = (this.unreadCount.totalUnread || 0) + (this.imUnreadTotal || 0)
if (total > 0) {
uni.setTabBarBadge({
index: 2,
@ -316,10 +341,37 @@ export default {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
flex-shrink: 0;
}
.avatar-wrap {
position: relative;
margin-right: 24rpx;
flex-shrink: 0;
}
.avatar-wrap .chat-avatar {
margin-right: 0;
}
.unread-badge {
position: absolute;
top: -8rpx;
right: -8rpx;
background: #ff4d4f;
min-width: 36rpx;
height: 36rpx;
line-height: 36rpx;
border-radius: 18rpx;
padding: 0 8rpx;
text-align: center;
}
.unread-badge text {
font-size: 20rpx;
color: #fff;
}
.chat-info {
flex: 1;
overflow: hidden;

View File

@ -356,14 +356,13 @@ export default {
}
.amount-value {
font-size: 32rpx;
font-size: 40rpx;
color: rgba(255, 255, 255, 0.85);
font-weight: bold;
margin-bottom: 8rpx;
}
.amount-value.highlight {
font-size: 40rpx;
color: #ffffff;
}

View File

@ -13,7 +13,7 @@
<image class="user-avatar" :src="userInfo.avatarUrl || '/static/logo.png'" mode="aspectFill"></image>
<view class="user-info">
<text class="user-name">{{ isLoggedIn ? (userInfo.nickname || '用户') : '点击注册/登录' }}</text>
<text class="user-uid" v-if="isLoggedIn">UID{{ userInfo.id }}</text>
<text class="user-uid" v-if="isLoggedIn">UID{{ userInfo.uid || userInfo.id }}</text>
</view>
<image class="arrow-icon" src="/static/ic_arrow.png" mode="aspectFit"></image>
</view>

View File

@ -170,7 +170,8 @@
acceptOrder,
getCertificationStatus,
submitCertification,
getPageBanner
getPageBanner,
getServiceEntries
} from '../../utils/api'
export default {
@ -227,12 +228,29 @@
onShow() {
const sysInfo = uni.getSystemInfoSync()
this.statusBarHeight = sysInfo.statusBarHeight || 0
//
this.certStatus = null
this.loadBanner()
this.loadTabs()
this.loadOrders()
},
methods: {
/** 从服务入口获取标签排序 */
async loadTabs() {
try {
const entries = await getServiceEntries()
if (entries && entries.length > 0) {
//
const nameMap = { '代取': 'Pickup', '代送': 'Delivery', '万能帮': 'Help', '代购': 'Purchase', '美食街': 'Food' }
const sorted = entries
.filter(e => nameMap[e.name])
.map(e => ({ label: e.name, value: nameMap[e.name] }))
if (sorted.length > 0) {
this.tabs = sorted
this.currentTab = sorted[0].value
}
}
} catch (e) {}
},
/** 加载顶部大图 */
async loadBanner() {
try {

View File

@ -258,7 +258,7 @@ export default {
getStatusLabel(status) {
const map = {
Pending: '待接单', InProgress: '进行中', WaitConfirm: '待确认',
Unpaid: '待支付', Pending: '待接单', InProgress: '进行中', WaitConfirm: '待确认',
Completed: '已完成', Cancelled: '已取消', Appealing: '申诉中'
}
return map[status] || status

View File

@ -305,7 +305,7 @@ export default {
getStatusLabel(status) {
const map = {
Pending: '待接单', InProgress: '进行中', WaitConfirm: '待确认',
Unpaid: '待支付', Pending: '待接单', InProgress: '进行中', WaitConfirm: '待确认',
Completed: '已完成', Cancelled: '已取消', Appealing: '申诉中'
}
return map[status] || status
@ -350,8 +350,16 @@ export default {
})
},
/** 跳转聊天页 */
/** 跳转聊天页(如果上一页就是聊天页,直接返回) */
goChat() {
const pages = getCurrentPages()
if (pages.length >= 2) {
const prevPage = pages[pages.length - 2]
if (prevPage.route === 'pages/message/chat') {
uni.navigateBack()
return
}
}
uni.navigateTo({ url: `/pages/message/chat?orderId=${this.orderId}` })
},
@ -745,6 +753,10 @@ export default {
border: none;
}
.modal-btn::after {
border: none;
}
.modal-btn.cancel {
background-color: #f5f5f5;
color: #666666;

View File

@ -79,7 +79,7 @@
</template>
<script>
import { createOrder, getPageBanner, getMinCommission } from '../../utils/api'
import { createOrder, getPageBanner, getMinCommission, cancelOrder, confirmPayment } from '../../utils/api'
export default {
data() {
@ -141,8 +141,8 @@ export default {
uni.showToast({ title: `跑腿佣金不可低于${this.minCommission}`, icon: 'none' })
return false
}
if (val.includes('.') && val.split('.')[1].length > 1) {
uni.showToast({ title: '佣金最多支持小数点后1位', icon: 'none' })
if (val.includes('.') && val.split('.')[1].length > 2) {
uni.showToast({ title: '佣金最多支持小数点后2位', icon: 'none' })
return false
}
return true
@ -191,7 +191,17 @@ export default {
commission,
totalAmount: commission
})
if (result.paymentParams) await this.wxPay(result.paymentParams)
if (result.paymentParams) {
try {
await this.wxPay(result.paymentParams)
//
try { await confirmPayment(result.id) } catch (ex) {}
} catch (e) {
// /
try { await cancelOrder(result.id) } catch (ex) {}
return
}
}
uni.showToast({ title: '下单成功', icon: 'success' })
setTimeout(() => { uni.switchTab({ url: '/pages/index/index' }) }, 1500)
} catch (e) {} finally { this.submitting = false }

View File

@ -83,7 +83,9 @@
import {
createOrder,
getPageBanner,
getMinCommission
getMinCommission,
cancelOrder,
confirmPayment
} from '../../utils/api'
export default {
@ -163,9 +165,9 @@
});
return false
}
if (val.includes('.') && val.split('.')[1].length > 1) {
if (val.includes('.') && val.split('.')[1].length > 2) {
uni.showToast({
title: '佣金最多支持小数点后1位',
title: '佣金最多支持小数点后2位',
icon: 'none'
});
return false
@ -252,7 +254,17 @@
commission,
totalAmount: goodsAmount + commission
})
if (result.paymentParams) await this.wxPay(result.paymentParams)
if (result.paymentParams) {
try {
await this.wxPay(result.paymentParams)
//
try { await confirmPayment(result.id) } catch (ex) {}
} catch (e) {
// /
try { await cancelOrder(result.id) } catch (ex) {}
return
}
}
uni.showToast({
title: '下单成功',
icon: 'success'

View File

@ -121,10 +121,6 @@ export const useCartStore = defineStore('cart', () => {
if (shop.items[index].quantity <= 0) {
shop.items.splice(index, 1)
}
// 如果门店购物车为空,移除门店
if (shop.items.length === 0) {
delete shops.value[currentShopId.value]
}
}
/** 清空所有购物车 */

View File

@ -58,6 +58,11 @@ export function cancelOrder(id) {
return request({ url: `/api/orders/${id}/cancel`, method: 'POST' })
}
/** 确认支付成功(前端支付成功后调用) */
export function confirmPayment(id) {
return request({ url: `/api/orders/${id}/pay-confirm`, method: 'POST' })
}
/** 跑腿提交完成 */
export function completeOrder(id, data) {
return request({ url: `/api/orders/${id}/complete`, method: 'POST', data })

View File

@ -3,6 +3,7 @@
* 负责初始化登录收发消息
*/
import TencentCloudChat from '@tencentcloud/chat'
import TIMUploadPlugin from 'tim-upload-plugin'
import request from './request'
let chat = null
@ -28,6 +29,8 @@ export async function initIM() {
// 创建 SDK 实例
if (!chat) {
chat = TencentCloudChat.create({ SDKAppID: sdkAppId })
// 注册上传插件(发送图片/文件必需)
chat.registerPlugin({ 'tim-upload-plugin': TIMUploadPlugin })
chat.setLogLevel(1) // Release 级别日志
// 监听 SDK 就绪
@ -189,14 +192,14 @@ export async function sendTextMessage(targetUserId, text, orderId) {
/**
* 发送图片消息
* @param {string} targetUserId - 对方用户 ID
* @param {string} filePath - 本地图片路径
* @param {Object} fileRes - uni.chooseImage success 回调结果
* @param {string|number} [orderId] - 关联订单ID
*/
export async function sendImageMessage(targetUserId, filePath, orderId) {
export async function sendImageMessage(targetUserId, fileRes, orderId) {
const message = chat.createImageMessage({
to: targetUserId,
conversationType: TencentCloudChat.TYPES.CONV_C2C,
payload: { file: { tempFilePaths: [filePath] } }
payload: { file: fileRes }
})
if (orderId) message.cloudCustomData = JSON.stringify({ orderId: String(orderId) })
const res = await chat.sendMessage(message)
@ -289,6 +292,20 @@ export function formatIMMessage(msg, currentImUserId) {
orderId: msgOrderId
}
}
if (data.bizType === 'price-change-response') {
return {
id: msg.ID,
type: 'price-change-response',
priceChangeId: data.priceChangeId,
action: data.action,
status: data.status,
changeTypeLabel: data.changeTypeLabel,
content: data.description || '',
isSelf,
time: msg.time * 1000,
orderId: msgOrderId
}
}
if (data.bizType === 'order-status') {
return {
id: msg.ID,

View File

@ -227,7 +227,8 @@ public static class AdminEndpoints
return new
{
r.Id, r.Nickname, r.Phone, r.RunnerScore, r.IsBanned, r.CreatedAt,
AvgRating = stats?.AvgRating ?? 0,
// 评价星级基于评分计算每20分一颗星最低1星最高5星
StarRating = Math.Round(Math.Clamp(r.RunnerScore / 20.0, 1.0, 5.0), 1),
ReviewCount = stats?.ReviewCount ?? 0
};
}).ToList();

View File

@ -2,6 +2,7 @@ using CampusErrand.Data;
using CampusErrand.Models;
using CampusErrand.Models.Dtos;
using CampusErrand.Services;
using CampusErrand.Helpers;
using Microsoft.EntityFrameworkCore;
namespace CampusErrand.Endpoints;
@ -42,6 +43,7 @@ public static class AuthEndpoints
{
user = new User
{
Uid = await BusinessHelpers.GenerateUniqueUid(db),
OpenId = sessionResult.OpenId!,
Phone = phoneNumber,
Nickname = $"用户{phoneNumber[^4..]}",
@ -66,6 +68,7 @@ public static class AuthEndpoints
UserInfo = new UserInfo
{
Id = user.Id,
Uid = user.Uid,
Phone = user.Phone,
Nickname = user.Nickname,
AvatarUrl = user.AvatarUrl,
@ -98,6 +101,7 @@ public static class AuthEndpoints
var randomSuffix = Random.Shared.Next(1000, 9999).ToString();
user = new User
{
Uid = await BusinessHelpers.GenerateUniqueUid(db),
OpenId = openId,
Phone = "",
Nickname = $"微信用户{randomSuffix}",
@ -116,6 +120,7 @@ public static class AuthEndpoints
UserInfo = new UserInfo
{
Id = user.Id,
Uid = user.Uid,
Phone = user.Phone,
Nickname = user.Nickname,
AvatarUrl = user.AvatarUrl,

View File

@ -179,6 +179,26 @@ public static class MessageEndpoints
return Results.Ok(orders);
}).RequireAuthorization();
// 管理端获取已发送通知列表
app.MapGet("/api/admin/notifications", async (AppDbContext db) =>
{
var list = await db.SystemMessages
.OrderByDescending(m => m.CreatedAt)
.Select(m => new
{
m.Id,
m.Title,
ContentPreview = m.Content.Length > 80 ? m.Content.Substring(0, 80) + "…" : m.Content,
m.ThumbnailUrl,
TargetType = m.TargetType.ToString(),
m.TargetUserIds,
m.CreatedAt
})
.ToListAsync();
return Results.Ok(list);
}).RequireAuthorization("AdminOnly");
// 管理端发布系统通知
app.MapPost("/api/admin/notifications", async (CreateNotificationRequest request, AppDbContext db) =>
{
@ -218,5 +238,21 @@ public static class MessageEndpoints
CreatedAt = message.CreatedAt
});
}).RequireAuthorization("AdminOnly");
// 管理端删除通知
app.MapDelete("/api/admin/notifications/{id}", async (int id, AppDbContext db) =>
{
var msg = await db.SystemMessages.FindAsync(id);
if (msg == null)
return Results.NotFound(new { code = 404, message = "通知不存在" });
// 删除关联的已读记录
db.MessageReads.RemoveRange(
db.MessageReads.Where(r => r.MessageType == MessageType.System && r.MessageId == id));
db.SystemMessages.Remove(msg);
await db.SaveChangesAsync();
return Results.Ok(new { message = "已删除" });
}).RequireAuthorization("AdminOnly");
}
}

View File

@ -36,10 +36,10 @@ public static class OrderEndpoints
return Results.BadRequest(new { code = 400, message = $"跑腿佣金不可低于{minCommission}元" });
}
// 佣金校验:小数点后最多 1
if (request.Commission != Math.Round(request.Commission, 1))
// 佣金校验:小数点后最多 2
if (request.Commission != Math.Round(request.Commission, 2))
{
return Results.BadRequest(new { code = 400, message = "跑腿佣金最多支持小数点后1位" });
return Results.BadRequest(new { code = 400, message = "跑腿佣金最多支持小数点后2位" });
}
// 计算支付总金额
@ -89,7 +89,7 @@ public static class OrderEndpoints
OrderNo = orderNo,
OwnerId = userId,
OrderType = orderType,
Status = OrderStatus.Pending,
Status = OrderStatus.Unpaid,
ItemName = request.ItemName,
PickupLocation = request.PickupLocation,
DeliveryLocation = request.DeliveryLocation,
@ -547,19 +547,41 @@ public static class OrderEndpoints
if (result.TradeState == "SUCCESS")
{
// 支付成功,更新订单状态(如果还是 Pending 说明支付完成)
// 支付成功,将待支付订单改为待接单
var order = await db.Orders.FirstOrDefaultAsync(o => o.OrderNo == result.OrderNo);
if (order != null)
if (order != null && order.Status == OrderStatus.Unpaid)
{
Console.WriteLine($"[微信支付] 支付成功: {result.OrderNo}, 金额: {result.TotalAmount}分");
order.Status = OrderStatus.Pending;
await db.SaveChangesAsync();
Console.WriteLine($"[微信支付] 支付成功,订单已上架: {result.OrderNo}, 金额: {result.TotalAmount}分");
}
}
return Results.Json(new { code = "SUCCESS", message = "OK" });
}).AllowAnonymous();
// 取消订单(仅单主可取消待接单订单)
app.MapPost("/api/orders/{id}/cancel", async (int id, HttpContext httpContext, AppDbContext db) =>
// 前端支付成功后确认订单(本地开发用,生产环境由回调处理)
app.MapPost("/api/orders/{id}/pay-confirm", async (int id, HttpContext httpContext, AppDbContext db) =>
{
var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
if (userIdClaim == null) return Results.Unauthorized();
var userId = int.Parse(userIdClaim.Value);
var order = await db.Orders.FirstOrDefaultAsync(o => o.Id == id && o.OwnerId == userId);
if (order == null)
return Results.NotFound(new { code = 404, message = "订单不存在" });
if (order.Status == OrderStatus.Unpaid)
{
order.Status = OrderStatus.Pending;
await db.SaveChangesAsync();
}
return Results.Ok(new { id = order.Id, status = order.Status.ToString() });
}).RequireAuthorization();
// 取消订单(仅单主可取消待支付或待接单订单,自动退款)
app.MapPost("/api/orders/{id}/cancel", async (int id, HttpContext httpContext, AppDbContext db, WxPayService wxPay) =>
{
var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
if (userIdClaim == null) return Results.Unauthorized();
@ -572,13 +594,31 @@ public static class OrderEndpoints
if (order.OwnerId != userId)
return Results.BadRequest(new { code = 400, message = "仅单主可取消订单" });
if (order.Status != OrderStatus.Pending)
return Results.BadRequest(new { code = 400, message = "仅待接单状态的订单可取消" });
if (order.Status != OrderStatus.Pending && order.Status != OrderStatus.Unpaid)
return Results.BadRequest(new { code = 400, message = "仅待支付或待接单状态的订单可取消" });
var wasPaid = order.Status == OrderStatus.Pending; // 已支付的才需要退款
order.Status = OrderStatus.Cancelled;
await db.SaveChangesAsync();
return Results.Ok(new { id = order.Id, orderNo = order.OrderNo, status = order.Status.ToString() });
// 已支付的订单发起微信退款(原路返回)
var refundSuccess = false;
if (wasPaid)
{
try
{
var totalFen = (int)(order.TotalAmount * 100);
var refundNo = $"R{order.OrderNo}";
refundSuccess = await wxPay.Refund(order.OrderNo, refundNo, totalFen, totalFen, "用户取消订单");
Console.WriteLine($"[订单取消] {order.OrderNo} 退款{(refundSuccess ? "" : "")}");
}
catch (Exception ex)
{
Console.WriteLine($"[订单取消] {order.OrderNo} 退款异常: {ex.Message}");
}
}
return Results.Ok(new { id = order.Id, orderNo = order.OrderNo, status = order.Status.ToString(), refundSuccess });
}).RequireAuthorization();
// 跑腿提交完成
@ -915,7 +955,7 @@ public static class OrderEndpoints
}).RequireAuthorization();
// 响应改价(同意或拒绝)
app.MapPut("/api/orders/{id}/price-change/{changeId}", async (int id, int changeId, RespondPriceChangeRequest request, HttpContext httpContext, AppDbContext db) =>
app.MapPut("/api/orders/{id}/price-change/{changeId}", async (int id, int changeId, RespondPriceChangeRequest request, HttpContext httpContext, AppDbContext db, WxPayService wxPay) =>
{
var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
if (userIdClaim == null) return Results.Unauthorized();
@ -931,11 +971,9 @@ public static class OrderEndpoints
if (priceChange.Status != PriceChangeStatus.Pending)
return Results.BadRequest(new { code = 400, message = "该改价申请已处理" });
// 不能自己响应自己的改价申请
if (priceChange.InitiatorId == userId)
return Results.BadRequest(new { code = 400, message = "不能响应自己的改价申请" });
// 仅订单相关方可响应
var order = priceChange.Order!;
if (userId != order.OwnerId && userId != order.RunnerId)
return Results.BadRequest(new { code = 400, message = "仅订单相关方可响应改价" });
@ -945,35 +983,78 @@ public static class OrderEndpoints
return Results.BadRequest(new { code = 400, message = "操作不合法,仅支持 Accepted 或 Rejected" });
priceChange.Status = action;
var difference = priceChange.NewPrice - priceChange.OriginalPrice;
object? paymentParams = null;
var refundSuccess = false;
// 同意改价时更新订单金额
if (action == PriceChangeStatus.Accepted)
{
var oldTotal = order.TotalAmount;
if (priceChange.ChangeType == PriceChangeType.Commission)
{
order.Commission = priceChange.NewPrice;
}
else
{
order.GoodsAmount = priceChange.NewPrice;
}
// 重新计算支付总金额
if (order.OrderType == OrderType.Help || order.OrderType == OrderType.Purchase || order.OrderType == OrderType.Food)
{
order.TotalAmount = (order.GoodsAmount ?? 0) + order.Commission;
}
else
{
order.TotalAmount = order.Commission;
var totalDiff = order.TotalAmount - oldTotal;
// 需要补价:创建微信支付订单
if (totalDiff > 0)
{
try
{
var owner = await db.Users.FindAsync(order.OwnerId);
if (owner != null && !string.IsNullOrEmpty(owner.OpenId))
{
var notifyUrl = $"{httpContext.Request.Scheme}://{httpContext.Request.Host}/api/pay/notify";
var payOrderNo = $"P{order.OrderNo}{DateTime.UtcNow:mmss}";
var payResult = await wxPay.CreateJsapiOrder(payOrderNo, totalDiff, "校园跑腿-改价补缴", owner.OpenId, notifyUrl);
if (payResult.Success)
{
paymentParams = new
{
timeStamp = payResult.PaymentParams!.TimeStamp,
nonceStr = payResult.PaymentParams.NonceStr,
package_ = payResult.PaymentParams.Package,
signType = payResult.PaymentParams.SignType,
paySign = payResult.PaymentParams.PaySign
};
}
}
}
catch (Exception ex)
{
Console.WriteLine($"[改价] 补价支付创建失败: {ex.Message}");
}
}
// 需要退款:自动退款
else if (totalDiff < 0)
{
try
{
var refundAmount = Math.Abs(totalDiff);
var refundFen = (int)(refundAmount * 100);
var totalFen = (int)(oldTotal * 100);
var refundNo = $"RP{order.OrderNo}{DateTime.UtcNow:mmss}";
refundSuccess = await wxPay.Refund(order.OrderNo, refundNo, totalFen, refundFen, "改价退款");
Console.WriteLine($"[改价] 退款{(refundSuccess ? "" : "")}: ¥{refundAmount}");
}
catch (Exception ex)
{
Console.WriteLine($"[改价] 退款失败: {ex.Message}");
}
}
}
await db.SaveChangesAsync();
var difference = priceChange.NewPrice - priceChange.OriginalPrice;
return Results.Ok(new PriceChangeResponse
return Results.Ok(new
{
Id = priceChange.Id,
OrderId = priceChange.OrderId,
@ -983,7 +1064,9 @@ public static class OrderEndpoints
NewPrice = priceChange.NewPrice,
Difference = difference,
Status = priceChange.Status.ToString(),
CreatedAt = priceChange.CreatedAt
CreatedAt = priceChange.CreatedAt,
PaymentParams = paymentParams,
RefundSuccess = refundSuccess
});
}).RequireAuthorization();

View File

@ -193,13 +193,29 @@ public static class ShopEndpoints
return Results.NotFound(new { code = 404, message = "门店不存在" });
}
// 级联删除菜品和门店 Banner
db.Dishes.RemoveRange(shop.Dishes);
db.ShopBanners.RemoveRange(shop.ShopBanners);
db.Shops.Remove(shop);
await db.SaveChangesAsync();
try
{
// 先删除订单中引用该门店的菜品项
var dishIds = shop.Dishes.Select(d => d.Id).ToList();
db.FoodOrderItems.RemoveRange(db.FoodOrderItems.Where(f => f.ShopId == id || dishIds.Contains(f.DishId)));
await db.SaveChangesAsync();
return Results.NoContent();
// 再删除菜品和门店 Banner
db.Dishes.RemoveRange(shop.Dishes);
db.ShopBanners.RemoveRange(shop.ShopBanners);
await db.SaveChangesAsync();
// 最后删除门店
db.Shops.Remove(shop);
await db.SaveChangesAsync();
return Results.NoContent();
}
catch (Exception ex)
{
Console.WriteLine($"[管理端] 删除门店失败: {ex.InnerException?.Message ?? ex.Message}");
return Results.BadRequest(new { code = 400, message = $"删除失败: {ex.InnerException?.Message ?? ex.Message}" });
}
}).RequireAuthorization("AdminOnly");
// 获取门店 Banner 列表
@ -351,10 +367,22 @@ public static class ShopEndpoints
return Results.NotFound(new { code = 404, message = "菜品不存在" });
}
db.Dishes.Remove(dish);
await db.SaveChangesAsync();
try
{
// 先删除订单中引用该菜品的记录
db.FoodOrderItems.RemoveRange(db.FoodOrderItems.Where(f => f.DishId == dishId));
await db.SaveChangesAsync();
return Results.NoContent();
db.Dishes.Remove(dish);
await db.SaveChangesAsync();
return Results.NoContent();
}
catch (Exception ex)
{
Console.WriteLine($"[管理端] 删除菜品失败: {ex.InnerException?.Message ?? ex.Message}");
return Results.BadRequest(new { code = 400, message = $"删除失败: {ex.InnerException?.Message ?? ex.Message}" });
}
}).RequireAuthorization("AdminOnly");
}
}

View File

@ -188,4 +188,19 @@ public static class BusinessHelpers
Console.WriteLine($"[定时任务] 自动确认了 {expiredOrders.Count} 个超时订单");
}
}
/// <summary>
/// 生成唯一的随机6位数字UID
/// </summary>
internal static async Task<string> GenerateUniqueUid(AppDbContext db)
{
for (var i = 0; i < 100; i++)
{
var uid = Random.Shared.Next(100000, 999999).ToString();
var exists = await db.Users.AnyAsync(u => u.Uid == uid);
if (!exists) return uid;
}
// 极端情况用7位
return Random.Shared.Next(1000000, 9999999).ToString();
}
}

View File

@ -0,0 +1,912 @@
// <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("20260401032529_AddUserUid")]
partial class AddUserUid
{
/// <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.Property<string>("Uid")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
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,30 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CampusErrand.Migrations
{
/// <inheritdoc />
public partial class AddUserUid : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Uid",
table: "Users",
type: "nvarchar(10)",
maxLength: 10,
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Uid",
table: "Users");
}
}
}

View File

@ -669,6 +669,11 @@ namespace CampusErrand.Migrations
b.Property<int>("RunnerScore")
.HasColumnType("int");
b.Property<string>("Uid")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.HasKey("Id");
b.HasIndex("OpenId")

View File

@ -48,6 +48,7 @@ public class LoginResponse
public class UserInfo
{
public int Id { get; set; }
public string Uid { get; set; } = string.Empty;
public string Phone { get; set; } = string.Empty;
public string Nickname { get; set; } = string.Empty;
public string AvatarUrl { get; set; } = string.Empty;

View File

@ -27,6 +27,7 @@ public enum OrderType
/// </summary>
public enum OrderStatus
{
Unpaid = -1, // 待支付
Pending = 0, // 待接单
InProgress = 1, // 进行中
WaitConfirm = 2, // 待确认

View File

@ -10,6 +10,10 @@ public class User
[Key]
public int Id { get; set; }
/// <summary>展示用 UID随机6位数字</summary>
[MaxLength(10)]
public string Uid { get; set; } = string.Empty;
/// <summary>微信 OpenID</summary>
[MaxLength(128)]
public string OpenId { get; set; } = string.Empty;

View File

@ -73,12 +73,13 @@ public class TencentIMService
Peer_Account = toUserId,
MaxCnt = maxCnt,
MinTime = minTime,
MaxTime = maxTime
MaxTime = maxTime > 0 ? maxTime : DateTimeOffset.UtcNow.ToUnixTimeSeconds()
};
var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync(url, content);
var json = await response.Content.ReadAsStringAsync();
Console.WriteLine($"[IM] 拉取漫游消息: {fromUserId} -> {toUserId}, 响应: {json}");
return JsonSerializer.Deserialize<JsonElement>(json);
}
}