bug修改

This commit is contained in:
18631081161 2026-03-11 19:39:07 +08:00
parent 01ada8da2b
commit 0d793d3f75
31 changed files with 676 additions and 225 deletions

View File

@ -16,12 +16,15 @@
<el-table-column label="抵扣金额" width="90">
<template #default="{ row }">{{ row.couponType === 'discount' ? `¥${row.discountAmount}` : '-' }}</template>
</el-table-column>
<el-table-column label="适用门店" width="120">
<template #default="{ row }">{{ row.store?.name || '-' }}</template>
<el-table-column label="适用门店" min-width="150">
<template #default="{ row }">{{ row.storeNames || '-' }}</template>
</el-table-column>
<el-table-column label="数量" width="100">
<template #default="{ row }">{{ row.remainingCount }}/{{ row.totalCount }}</template>
</el-table-column>
<el-table-column label="有效期" min-width="180">
<template #default="{ row }">{{ formatValidity(row) }}</template>
</el-table-column>
<el-table-column prop="pointsCost" label="所需积分" width="90" />
<el-table-column label="来源" width="90">
<template #default="{ row }">{{ row.source === 'platform' ? '平台券' : '驿公里券' }}</template>
@ -47,7 +50,7 @@
<el-input v-model="form.name" placeholder="请输入名称" />
</el-form-item>
<el-form-item label="适用门店">
<el-select v-model="form.storeId" placeholder="请选择门店" style="width:100%">
<el-select v-model="form.storeIds" multiple placeholder="请选择门店(可多选)" style="width:100%">
<el-option v-for="s in stores" :key="s.id" :label="s.name" :value="s.id" />
</el-select>
</el-form-item>
@ -61,7 +64,10 @@
<el-input-number v-model="form.discountAmount" :min="0" :precision="2" />
</el-form-item>
<el-form-item label="有效期">
<el-date-picker v-model="dateRange" type="daterange" start-placeholder="开始日期" end-placeholder="结束日期" style="width:100%" />
<div style="display:flex;flex-direction:column;gap:8px;width:100%">
<el-checkbox v-model="form.isPermanent">永久有效</el-checkbox>
<el-date-picker v-if="!form.isPermanent" v-model="dateRange" type="daterange" start-placeholder="开始" end-placeholder="结束" style="max-width:380px" />
</div>
</el-form-item>
<el-form-item label="总数量">
<el-input-number v-model="form.totalCount" :min="1" />
@ -75,7 +81,7 @@
<el-option label="驿公里券" value="ygl" />
</el-select>
</el-form-item>
<el-form-item v-if="form.source === 'ygl'" label="驿公里券包码">
<el-form-item v-if="form.source === 'ygl'" label="驿公里券包码" required>
<el-input v-model="form.yglTicketPackageCode" placeholder="请输入券包码" />
</el-form-item>
<el-form-item label="状态">
@ -91,7 +97,7 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getCouponTemplates, createCouponTemplate, updateCouponTemplate, deleteCouponTemplate, getStores } from '../api'
@ -104,9 +110,9 @@ const isEdit = ref(false)
const editId = ref(null)
const defaultForm = () => ({
name: '', couponType: 'free', discountAmount: 0, storeId: null,
name: '', couponType: 'free', discountAmount: 0, storeIds: [],
totalCount: 100, pointsCost: 0, source: 'platform',
yglTicketPackageCode: '', isActive: true
yglTicketPackageCode: '', isActive: true, isPermanent: false
})
const form = ref(defaultForm())
const dateRange = ref([])
@ -120,17 +126,32 @@ const fetchData = async () => {
try { const res = await getCouponTemplates(); list.value = res.data } catch {} finally { loading.value = false }
}
// >=2099
const isPermanent = (row) => {
if (!row.validEnd) return false
return new Date(row.validEnd).getFullYear() >= 2099
}
//
const formatValidity = (row) => {
if (isPermanent(row)) return '永久有效'
const fmt = (d) => d ? d.substring(0, 10) : ''
return `${fmt(row.validStart)}${fmt(row.validEnd)}`
}
const openDialog = (row) => {
fetchStores()
if (row) {
isEdit.value = true
editId.value = row.id
const permanent = isPermanent(row)
form.value = {
name: row.name, couponType: row.couponType, discountAmount: row.discountAmount,
storeId: row.storeId, totalCount: row.totalCount, pointsCost: row.pointsCost,
source: row.source, yglTicketPackageCode: row.yglTicketPackageCode || '', isActive: row.isActive
storeIds: row.storeIds || [], totalCount: row.totalCount, pointsCost: row.pointsCost,
source: row.source, yglTicketPackageCode: row.yglTicketPackageCode || '', isActive: row.isActive,
isPermanent: permanent
}
dateRange.value = [new Date(row.validStart), new Date(row.validEnd)]
dateRange.value = permanent ? [] : [new Date(row.validStart), new Date(row.validEnd)]
} else {
isEdit.value = false
editId.value = null
@ -141,8 +162,24 @@ const openDialog = (row) => {
}
const handleSubmit = async () => {
if (!form.value.storeIds.length) {
ElMessage.warning('请至少选择一个门店')
return
}
if (form.value.source === 'ygl' && !form.value.yglTicketPackageCode?.trim()) {
ElMessage.warning('驿公里券来源必须填写券包码')
return
}
if (!form.value.isPermanent && (!dateRange.value || dateRange.value.length < 2)) {
ElMessage.warning('请选择有效期或勾选永久有效')
return
}
const data = { ...form.value }
if (dateRange.value?.length === 2) {
delete data.isPermanent
if (form.value.isPermanent) {
data.validStart = new Date('2000-01-01')
data.validEnd = new Date('2099-12-31')
} else {
data.validStart = dateRange.value[0]
data.validEnd = dateRange.value[1]
}

View File

@ -1,33 +1,29 @@
<template>
<div>
<el-card>
<template #header>优惠券发放记录</template>
<!-- 筛选栏 -->
<el-form :inline="true" :model="filters" style="margin-bottom:16px">
<el-form-item label="用户手机号">
<el-input v-model="filters.userPhone" placeholder="手机号" clearable @clear="fetchData" />
</el-form-item>
<el-form-item label="门店">
<el-select v-model="filters.storeId" placeholder="全部门店" clearable @change="fetchData">
<el-option v-for="s in stores" :key="s.id" :label="s.name" :value="s.id" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部状态" clearable @change="fetchData">
<el-option label="未使用" value="unused" />
<el-option label="已使用" value="used" />
<el-option label="已过期" value="expired" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="fetchData">查询</el-button>
</el-form-item>
</el-form>
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center">
<span>优惠券发放记录</span>
<div style="display:flex;gap:8px;align-items:center">
<el-input v-model="filters.userPhone" placeholder="用户手机号/UID" clearable style="width:160px" @clear="fetchData" @keyup.enter="fetchData" />
<el-select v-model="filters.storeId" placeholder="全部门店" clearable style="width:140px" @change="fetchData">
<el-option v-for="s in stores" :key="s.id" :label="s.name" :value="s.id" />
</el-select>
<el-select v-model="filters.status" placeholder="全部状态" clearable style="width:120px" @change="fetchData">
<el-option label="未使用" value="unused" />
<el-option label="已使用" value="used" />
<el-option label="已过期" value="expired" />
</el-select>
<el-button type="primary" @click="fetchData">查询</el-button>
</div>
</div>
</template>
<el-table :data="list" v-loading="loading">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="code" label="券码" width="120" />
<el-table-column prop="templateName" label="优惠券名称" />
<el-table-column prop="userUid" label="用户UID" width="90" />
<el-table-column prop="userPhone" label="用户手机号" width="130" />
<el-table-column prop="storeName" label="门店" />
<el-table-column label="状态" width="90">

View File

@ -209,6 +209,14 @@ onMounted(fetchData)
.store-uploader:hover {
border-color: #409eff;
}
.store-uploader :deep(.el-upload) {
width: 120px;
height: 120px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.uploader-icon {
font-size: 28px;
color: #8c939d;

View File

@ -1,5 +1,5 @@
<script>
import { isLoggedIn } from '@/utils/auth'
import { isLoggedIn, getRole } from '@/utils/auth'
// 访
const AUTH_PAGES = [
@ -21,6 +21,10 @@
export default {
onLaunch: function() {
console.log('App Launch')
//
if (isLoggedIn() && getRole() === 'merchant') {
uni.reLaunch({ url: '/pages/merchant/index' })
}
},
onShow: function() {
console.log('App Show')

View File

@ -34,3 +34,11 @@ export function getRecords() {
export function getMerchantRecords() {
return get('/api/verify/merchant-records')
}
/**
* 根据券码查询优惠券信息核销前预览
* @param {string} code - 券码
*/
export function previewCoupon(code) {
return get('/api/verify/preview', { code })
}

View File

@ -4,7 +4,7 @@
<view class="dialog-title" v-if="title">{{ title }}</view>
<view class="dialog-message">{{ message }}</view>
<view class="dialog-actions">
<button class="btn-cancel" @click="cancel">{{ cancelText }}</button>
<button v-if="cancelText" class="btn-cancel" @click="cancel">{{ cancelText }}</button>
<button class="btn-confirm" @click="confirm">{{ confirmText }}</button>
</view>
</view>

View File

@ -1,27 +1,42 @@
<template>
<view class="coupon-card">
<view class="coupon-info">
<view class="coupon-name">{{ coupon.name }}</view>
<view class="coupon-store">适用门店{{ coupon.storeName }}</view>
<view class="coupon-code">券码{{ coupon.code }}</view>
<view class="coupon-expire">有效期{{ coupon.validStart }} ~ {{ coupon.validEnd }}</view>
<!-- 背景图 -->
<image class="coupon-card-bg" src="/static/item_bg2.png" mode="scaleToFill" />
<!-- 左侧券类型 -->
<view class="coupon-left">
<template v-if="coupon.couponType === 'discount'">
<text class="coupon-price">¥{{ coupon.discountAmount }}</text>
<text class="coupon-type-label">折扣券</text>
</template>
<template v-else>
<text class="coupon-price">免费</text>
<text class="coupon-type-label">洗车券</text>
</template>
</view>
<view class="coupon-action">
<!-- 平台券显示二维码按钮 -->
<button
v-if="coupon.source === 'platform'"
class="btn btn-primary"
size="mini"
@click="$emit('showQrcode', coupon)"
>二维码</button>
<!-- 驿公里券显示跳转按钮 -->
<view v-else-if="coupon.source === 'ygl'" class="ygl-action">
<text class="ygl-tip">需在驿公里使用</text>
<button
class="btn btn-ygl"
size="mini"
@click="$emit('gotoYgl', coupon)"
>去使用</button>
<!-- 右侧信息 -->
<view class="coupon-right">
<text class="coupon-name">{{ coupon.storeName }}</text>
<text class="coupon-info">券码{{ coupon.code }}</text>
<text class="coupon-info">有效期{{ formatValidity(coupon) }}</text>
<!-- 操作按钮 -->
<view class="coupon-bottom">
<view v-if="coupon.source === 'ygl'" class="ygl-tip-text">
<text class="coupon-info">需在驿公里洗车小程序中使用</text>
</view>
<view
v-if="coupon.source === 'platform'"
class="action-btn"
@click.stop="$emit('showQrcode', coupon)"
>
<text class="action-btn-text">二维码</text>
</view>
<view
v-else-if="coupon.source === 'ygl'"
class="action-btn"
@click.stop="$emit('gotoYgl', coupon)"
>
<text class="action-btn-text">去使用</text>
</view>
</view>
</view>
</view>
@ -31,66 +46,105 @@
export default {
name: 'CouponCard',
props: {
coupon: {
type: Object,
required: true
}
coupon: { type: Object, required: true }
},
emits: ['showQrcode', 'gotoYgl']
emits: ['showQrcode', 'gotoYgl'],
methods: {
formatValidity(coupon) {
if (!coupon.validStart && !coupon.validEnd) return '-'
const endYear = new Date(coupon.validEnd).getFullYear()
if (endYear >= 2099) return '永久有效'
const fmt = (d) => d ? d.substring(0, 10) : ''
return `${fmt(coupon.validStart)}${fmt(coupon.validEnd)}`
}
}
}
</script>
<style scoped>
.coupon-card {
display: flex;
justify-content: space-between;
align-items: center;
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08);
overflow: hidden;
position: relative;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
}
.coupon-info {
flex: 1;
.coupon-card-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
}
.coupon-name {
font-size: 32rpx;
font-weight: bold;
color: #333;
margin-bottom: 8rpx;
}
.coupon-store,
.coupon-code,
.coupon-expire {
font-size: 24rpx;
color: #999;
margin-bottom: 4rpx;
}
.coupon-action {
margin-left: 20rpx;
flex-shrink: 0;
}
.btn {
border-radius: 8rpx;
font-size: 24rpx;
}
.btn-primary {
background: #007aff;
color: #fff;
}
.btn-ygl {
background: #ff9500;
color: #fff;
}
.ygl-action {
/* 左侧标签 */
.coupon-left {
width: 160rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24rpx 0 24rpx 20rpx;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.ygl-tip {
font-size: 20rpx;
color: #999;
.coupon-price {
font-size: 65rpx;
font-weight: bold;
color: #E8C7AA;
line-height: 1.2;
}
.coupon-type-label {
font-size: 32rpx;
color: #E8C7AA;
margin-top: 6rpx;
}
/* 右侧信息 */
.coupon-right {
flex: 1;
padding: 24rpx 24rpx 20rpx;
display: flex;
margin-left: 20rpx;
flex-direction: column;
min-width: 0;
position: relative;
z-index: 1;
}
.coupon-name {
font-size: 36rpx;
font-weight: bold;
color: #FFFFFF;
margin-bottom: 8rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.coupon-info {
font-size: 26rpx;
color: #FFFFFF;
line-height: 1.6;
}
.coupon-bottom {
display: flex;
justify-content: flex-end;
align-items: center;
margin-top: 12rpx;
}
.ygl-tip-text {
flex: 1;
}
.action-btn {
background: #FFF8E8;
border-radius: 10rpx;
padding: 10rpx 28rpx;
}
.action-btn:active {
opacity: 0.8;
}
.action-btn-text {
font-size: 24rpx;
color: #E8C7AA;
font-weight: 500;
}
</style>

View File

@ -1,5 +1,5 @@
{
"name" : "黄岩车积分",
"name" : "黄岩智惠停",
"appid" : "__UNI__F1854F8",
"description" : "黄岩停车积分兑换微信小程序",
"versionName" : "1.0.0",

View File

@ -1,7 +1,7 @@
<template>
<view class="coupons-page">
<NavBar title="我的优惠券" />
<!-- 状态标签页 -->
<!-- 状态标签页 - 胶囊样式 -->
<view class="tabs">
<view
class="tab-item"
@ -21,7 +21,20 @@
</view>
<!-- 筛选栏 -->
<FilterBar :filters="filters" @change="onFilterChange" />
<view class="filter-row">
<picker :range="storeOptions" range-key="label" @change="onStoreChange">
<view class="filter-item">
<text class="filter-text">适用门店{{ currentStoreName }}</text>
<text class="filter-arrow"></text>
</view>
</picker>
<picker :range="typeOptions" range-key="label" @change="onTypeChange">
<view class="filter-item">
<text class="filter-text">类型{{ currentTypeName }}</text>
<text class="filter-arrow"></text>
</view>
</picker>
</view>
<!-- 优惠券列表 -->
<view class="coupon-list">
@ -49,37 +62,26 @@
import { getStores } from '@/api/store'
import CouponCard from '@/components/CouponCard.vue'
import QrcodePopup from '@/components/QrcodePopup.vue'
import FilterBar from '@/components/FilterBar.vue'
import NavBar from '@/components/NavBar.vue'
export default {
components: { CouponCard, QrcodePopup, FilterBar, NavBar },
components: { CouponCard, QrcodePopup, NavBar },
data() {
return {
loading: false,
currentStatus: 'unused',
currentStoreId: '',
currentStoreName: '全部',
currentType: '',
currentTypeName: '全部',
coupons: [],
qrcodeVisible: false,
currentCoupon: {},
filters: [
{
label: '门店',
key: 'storeId',
options: [{ label: '全部门店', value: '' }],
selectedIndex: 0
},
{
label: '类型',
key: 'type',
options: [
{ label: '全部类型', value: '' },
{ label: '免费券', value: 'free' },
{ label: '抵扣券', value: 'discount' }
],
selectedIndex: 0
}
storeOptions: [{ label: '全部', value: '' }],
typeOptions: [
{ label: '全部', value: '' },
{ label: '免费券', value: 'free' },
{ label: '抵扣券', value: 'discount' }
]
}
},
@ -93,8 +95,8 @@
try {
const res = await getStores()
const stores = res.data || res || []
this.filters[0].options = [
{ label: '全部门店', value: '' },
this.storeOptions = [
{ label: '全部', value: '' },
...stores.map(s => ({ label: s.name, value: s.id }))
]
} catch (err) {
@ -124,14 +126,21 @@
this.loadCoupons()
},
/** 筛选变更 */
onFilterChange({ key, value, filterIndex, selectedIndex }) {
if (key === 'storeId') {
this.currentStoreId = value
} else if (key === 'type') {
this.currentType = value
}
this.filters[filterIndex].selectedIndex = selectedIndex
/** 门店筛选变更 */
onStoreChange(e) {
const idx = e.detail.value
const opt = this.storeOptions[idx]
this.currentStoreId = opt.value
this.currentStoreName = opt.label
this.loadCoupons()
},
/** 类型筛选变更 */
onTypeChange(e) {
const idx = e.detail.value
const opt = this.typeOptions[idx]
this.currentType = opt.value
this.currentTypeName = opt.label
this.loadCoupons()
},
@ -146,7 +155,7 @@
uni.navigateToMiniProgram({
appId: 'wx8c943e2e64e04284',
fail: () => {
uni.showToast({ title: '跳转驿公里失败', icon: 'none' })
uni.showToast({ title: '跳转驿公里洗车小程序失败', icon: 'none' })
}
})
}
@ -156,33 +165,57 @@
<style scoped>
.coupons-page {
padding: 20rpx;
padding: 20rpx 24rpx;
background: #f5f5f5;
min-height: 100vh;
}
/* 胶囊标签 */
.tabs {
display: flex;
background: #fff;
border-radius: 12rpx;
margin-bottom: 16rpx;
overflow: hidden;
gap: 16rpx;
margin-bottom: 20rpx;
}
.tab-item {
flex: 1;
text-align: center;
padding: 20rpx 0;
padding: 12rpx 32rpx;
font-size: 28rpx;
color: #666;
background: #fff;
border-radius: 40rpx;
border: 2rpx solid #eee;
}
.tab-item.active {
color: #007aff;
color: #C88A00;
font-weight: bold;
border-bottom: 4rpx solid #007aff;
background: #FFF8E1;
border-color: #FFCC00;
}
/* 筛选行 */
.filter-row {
display: flex;
justify-content: space-between;
margin-bottom: 20rpx;
}
.filter-item {
display: flex;
align-items: center;
gap: 6rpx;
}
.filter-text {
font-size: 28rpx;
color: #333;
font-weight: 500;
}
.filter-arrow {
font-size: 24rpx;
color: #999;
}
/* 列表 */
.coupon-list {
padding-bottom: 20rpx;
}
.empty-tip {
text-align: center;
padding: 60rpx 0;
padding: 100rpx 0;
color: #999;
font-size: 28rpx;
}

View File

@ -28,7 +28,7 @@
<text class="coupon-info">券码{{ coupon.code }}</text>
<text
class="coupon-info">有效期{{ formatDate(coupon.exchangedAt) }}{{ formatDate(coupon.expiredAt) }}</text>
<text class="coupon-info" v-if="coupon.source === 'ygl'">使用方式请在驿公里App中使用</text>
<text class="coupon-info" v-if="coupon.source === 'ygl'">使用方式请在驿公里洗车小程序中使用</text>
<view class="coupon-bottom">
<view v-if="coupon.source === 'platform'" class="action-btn" @click="openQrcode(coupon)">
<text class="action-btn-text">二维码</text>
@ -113,7 +113,7 @@
appId: 'wx8c943e2e64e04284',
fail: () => {
uni.showToast({
title: '跳转驿公里失败',
title: '跳转驿公里洗车小程序失败',
icon: 'none'
})
}

View File

@ -165,9 +165,14 @@
font-weight: 500;
border-radius: 48rpx;
border: none;
outline: none;
margin-bottom: 28rpx;
}
.btn-login::after {
border: none;
}
.btn-login[disabled] {
opacity: 0.6;
}

View File

@ -37,7 +37,7 @@
uni.reLaunch({ url: '/pages/merchant/index' })
} else {
//
uni.switchTab({ url: '/pages/home/index' })
uni.switchTab({ url: '/pages/index/index' })
}
}
}

View File

@ -28,6 +28,15 @@
<text class="record-arrow"></text>
</view>
<!-- 退出登录 -->
<view class="record-entry logout-entry" @click="handleLogout">
<view style="display:flex;align-items:center">
<image src="/static/login_out.png" class="logout-icon" />
<text class="record-label">退出登录</text>
</view>
<text class="record-arrow"></text>
</view>
<!-- 券码输入弹窗 -->
<view class="dialog-mask" v-if="showCodeInput" @click="closeCodeInput">
<view class="code-input-dialog" @click.stop>
@ -73,8 +82,8 @@
</template>
<script>
import { scanVerify, codeVerify } from '@/api/verify'
import { getUserInfo, setLoginInfo, getToken } from '@/utils/auth'
import { scanVerify, codeVerify, previewCoupon } from '@/api/verify'
import { getUserInfo, setLoginInfo, getToken, clearAuth } from '@/utils/auth'
import { checkMerchant } from '@/api/auth'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import NavBar from '@/components/NavBar.vue'
@ -134,17 +143,13 @@
uni.scanCode({
onlyFromCamera: true,
scanType: ['qrCode'],
success: (res) => {
success: async (res) => {
const code = res.result
if (!code) {
this.showResult(false, '未识别到有效二维码')
return
}
//
this.pendingCode = code
this.pendingType = 'scan'
this.confirmMessage = `确认核销券码:${code}`
this.confirmVisible = true
await this.previewAndConfirm(code, 'scan')
},
fail: (err) => {
//
@ -162,7 +167,7 @@
},
/** 提交券码 */
submitCode() {
async submitCode() {
const code = this.inputCode.trim()
if (!code) {
uni.showToast({ title: '请输入券码', icon: 'none' })
@ -173,10 +178,27 @@
return
}
this.showCodeInput = false
this.pendingCode = code
this.pendingType = 'code'
this.confirmMessage = `确认核销券码:${code}`
this.confirmVisible = true
await this.previewAndConfirm(code, 'code')
},
/** 查询券码信息并弹出确认 */
async previewAndConfirm(code, type) {
uni.showLoading({ title: '查询中...' })
try {
const res = await previewCoupon(code)
const info = res.data || res || {}
const validEnd = info.validEnd ? info.validEnd.substring(0, 10) : '未知'
const isPermanent = info.validEnd && new Date(info.validEnd).getFullYear() >= 2099
this.pendingCode = code
this.pendingType = type
this.confirmMessage = `优惠券名称:${info.couponName || '未知'}\n券码${code}\n有效期至${isPermanent ? '永久有效' : validEnd}\n\n确认核销该优惠券`
this.confirmVisible = true
} catch (err) {
const msg = (err && err.message) || '查询券码失败'
this.showResult(false, msg)
} finally {
uni.hideLoading()
}
},
/** 执行核销 */
@ -213,6 +235,20 @@
/** 跳转核销记录页 */
gotoRecords() {
uni.navigateTo({ url: '/pages/merchant/records' })
},
/** 退出登录 */
handleLogout() {
uni.showModal({
title: '提示',
content: '确定要退出登录吗?',
success: (res) => {
if (res.confirm) {
clearAuth()
uni.reLaunch({ url: '/pages/index/index' })
}
}
})
}
}
}
@ -288,6 +324,14 @@
font-size: 36rpx;
color: #ccc;
}
.logout-entry {
margin-top: 20rpx;
}
.logout-icon {
width: 36rpx;
height: 36rpx;
margin-right: 12rpx;
}
/* 券码输入弹窗 */
.dialog-mask {
position: fixed;

View File

@ -48,11 +48,18 @@
<text class="menu-text">隐私政策</text>
<text class="arrow-right"></text>
</view>
<view v-if="isLogin && userInfo.isMerchant" class="menu-item" @click="goRoleSelect">
<image class="menu-icon" src="/static/ic_pitch.png" mode="aspectFit" />
<text class="menu-text">切换身份</text>
<text class="arrow-right"></text>
</view>
<view v-if="isLogin" class="menu-item" @click="showLogoutConfirm">
<image class="menu-icon" src="/static/login_out.png" mode="aspectFit" />
<text class="menu-text">退出登录</text>
<text class="arrow-right"></text>
</view>
</view>
<!-- 退出登录 -->
<button v-if="isLogin" class="btn-logout" @click="showLogoutConfirm">退出登录</button>
<!-- 退出确认弹窗 -->
<ConfirmDialog
:visible="logoutVisible"
@ -132,6 +139,11 @@
uni.navigateTo({ url: `/pages/mine/agreement?type=${type}` })
},
/** 切换身份 */
goRoleSelect() {
uni.navigateTo({ url: '/pages/login/role-select' })
},
/** 显示退出确认弹窗 */
showLogoutConfirm() {
this.logoutVisible = true
@ -250,13 +262,6 @@
font-size: 30rpx;
color: #333;
}
.btn-logout {
background: #fff;
color: #ff3b30;
font-size: 30rpx;
border-radius: 16rpx;
margin-top: 20rpx;
}
.contact-btn {
position: absolute;
top: 0;

View File

@ -47,9 +47,9 @@
<view class="coupon-right">
<text class="coupon-name">{{ item.name }}</text>
<text
class="coupon-info">有效期{{ formatDate(item.validStart) }}{{ formatDate(item.validEnd) }}</text>
class="coupon-info">有效期{{ isPermanent(item) ? '永久有效' : formatDate(item.validStart) + '至' + formatDate(item.validEnd) }}</text>
<text class="coupon-info">剩余数量{{ item.remainingCount }} </text>
<text class="coupon-info" v-if="item.source === 'ygl'">使用方式请在驿公里App中使用</text>
<text class="coupon-info" v-if="item.source === 'ygl'">使用方式请在驿公里洗车小程序中使用</text>
<view class="coupon-bottom">
<view class="exchange-btn" @click="onExchange(item)"
:class="{ disabled: item.remainingCount <= 0 }">
@ -73,7 +73,7 @@
@cancel="confirmVisible = false" />
<!-- 驿公里券提示弹窗 -->
<ConfirmDialog :visible="yglTipVisible" title="温馨提示" message="该优惠券为驿公里券,兑换后需在驿公里APP/小程序中使用,确定兑换吗?"
<ConfirmDialog :visible="yglTipVisible" title="温馨提示" message="该优惠券为驿公里券,兑换后需在驿公里洗车小程序中使用,确定兑换吗?"
@confirm="doExchangeYgl" @cancel="yglTipVisible = false" />
</view>
</template>
@ -119,13 +119,20 @@
confirmMessage: '',
yglTipVisible: false,
currentItem: null,
selectedStoreId: null,
pointsBgUrl: ''
}
},
computed: {
filteredList() {
if (!this.currentStoreId) return this.availableList
return this.availableList.filter(item => item.storeId == this.currentStoreId)
//
return this.availableList.filter(item => {
if (item.stores && item.stores.length) {
return item.stores.some(s => s.id == this.currentStoreId)
}
return false
})
}
},
onShow() {
@ -136,6 +143,10 @@
if (!d) return ''
return d.substring(0, 10)
},
isPermanent(item) {
if (!item.validEnd) return false
return new Date(item.validEnd).getFullYear() >= 2099
},
async loadData() {
this.loading = true
try {
@ -197,6 +208,31 @@
return
}
this.currentItem = item
//
const stores = item.stores || []
if (stores.length === 0) {
uni.showToast({ title: '该优惠券未配置门店', icon: 'none' })
return
}
if (stores.length === 1) {
// 使
this.selectedStoreId = stores[0].id
this.showExchangeConfirm(item)
} else {
//
const names = stores.map(s => s.name)
uni.showActionSheet({
itemList: names,
success: (res) => {
this.selectedStoreId = stores[res.tapIndex].id
this.showExchangeConfirm(item)
}
})
}
},
showExchangeConfirm(item) {
if (item.source === 'ygl') {
this.yglTipVisible = true
} else {
@ -213,10 +249,11 @@
await this.executeExchange()
},
async executeExchange() {
if (!this.currentItem) return
if (!this.currentItem || !this.selectedStoreId) return
try {
await exchange({
templateId: this.currentItem.id
templateId: this.currentItem.id,
storeId: this.selectedStoreId
})
uni.showToast({
title: '兑换成功',

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@ -4,8 +4,8 @@
*/
// 后台 API 基础地址,按环境配置
// const BASE_URL = 'http://localhost:5030'
const BASE_URL = 'https://api.pli.shhmkjgs.cn'
const BASE_URL = 'http://localhost:5030'
// const BASE_URL = 'https://api.pli.shhmkjgs.cn'
// 请求超时时间(毫秒)
const TIMEOUT = 10000

View File

@ -176,20 +176,12 @@ public class AdminController : ControllerBase
store.Photo = request.Photo;
store.Address = request.Address;
store.Phone = request.Phone;
store.Latitude = request.Latitude;
store.Longitude = request.Longitude;
store.Type = request.Type;
store.IsActive = request.IsActive;
store.UpdatedAt = DateTime.UtcNow;
if (addressChanged && !string.IsNullOrEmpty(request.Address))
{
var location = await _geocodingService.GeocodeAsync(request.Address);
if (location is not null)
{
store.Latitude = location.Latitude;
store.Longitude = location.Longitude;
}
}
await _db.SaveChangesAsync();
return Ok(ApiResponse<Store>.Ok(store));
}
@ -292,13 +284,25 @@ public class AdminController : ControllerBase
/// <summary>获取优惠券模板列表</summary>
[HttpGet("coupon-templates")]
public async Task<ActionResult<ApiResponse<List<CouponTemplate>>>> GetCouponTemplates()
public async Task<ActionResult<ApiResponse<List<object>>>> GetCouponTemplates()
{
var templates = await _db.CouponTemplates
.Include(t => t.Store)
.Include(t => t.TemplateStores).ThenInclude(ts => ts.Store)
.OrderByDescending(t => t.CreatedAt)
.ToListAsync();
return Ok(ApiResponse<List<CouponTemplate>>.Ok(templates));
// 返回包含门店列表的响应
var result = templates.Select(t => new
{
t.Id, t.Name, t.CouponType, t.DiscountAmount,
t.ValidStart, t.ValidEnd, t.TotalCount, t.RemainingCount,
t.PointsCost, t.Source, t.YglTicketPackageCode, t.IsActive,
t.CreatedAt, t.UpdatedAt,
StoreIds = t.TemplateStores.Select(ts => ts.StoreId).ToList(),
StoreNames = string.Join("、", t.TemplateStores.Select(ts => ts.Store.Name))
}).ToList();
return Ok(ApiResponse<List<object>>.Ok(result.Cast<object>().ToList()));
}
/// <summary>创建优惠券模板</summary>
@ -310,11 +314,10 @@ public class AdminController : ControllerBase
Name = request.Name,
CouponType = request.CouponType,
DiscountAmount = request.DiscountAmount,
StoreId = request.StoreId,
ValidStart = request.ValidStart,
ValidEnd = request.ValidEnd,
TotalCount = request.TotalCount,
RemainingCount = request.TotalCount, // 初始剩余=总数
RemainingCount = request.TotalCount,
PointsCost = request.PointsCost,
Source = request.Source,
YglTicketPackageCode = request.YglTicketPackageCode,
@ -322,6 +325,18 @@ public class AdminController : ControllerBase
};
_db.CouponTemplates.Add(template);
await _db.SaveChangesAsync();
// 添加门店关联
foreach (var storeId in request.StoreIds)
{
_db.CouponTemplateStores.Add(new CouponTemplateStore
{
CouponTemplateId = template.Id,
StoreId = storeId
});
}
await _db.SaveChangesAsync();
return Ok(ApiResponse<CouponTemplate>.Ok(template));
}
@ -329,7 +344,9 @@ public class AdminController : ControllerBase
[HttpPut("coupon-templates/{id}")]
public async Task<ActionResult<ApiResponse<CouponTemplate>>> UpdateCouponTemplate(int id, [FromBody] AdminCouponTemplateRequest request)
{
var template = await _db.CouponTemplates.FindAsync(id);
var template = await _db.CouponTemplates
.Include(t => t.TemplateStores)
.FirstOrDefaultAsync(t => t.Id == id);
if (template is null)
return NotFound(ApiResponse<CouponTemplate>.Fail("优惠券模板不存在"));
@ -338,7 +355,6 @@ public class AdminController : ControllerBase
template.Name = request.Name;
template.CouponType = request.CouponType;
template.DiscountAmount = request.DiscountAmount;
template.StoreId = request.StoreId;
template.ValidStart = request.ValidStart;
template.ValidEnd = request.ValidEnd;
template.TotalCount = request.TotalCount;
@ -348,6 +364,18 @@ public class AdminController : ControllerBase
template.YglTicketPackageCode = request.YglTicketPackageCode;
template.IsActive = request.IsActive;
template.UpdatedAt = DateTime.UtcNow;
// 更新门店关联:先删后加
_db.CouponTemplateStores.RemoveRange(template.TemplateStores);
foreach (var storeId in request.StoreIds)
{
_db.CouponTemplateStores.Add(new CouponTemplateStore
{
CouponTemplateId = template.Id,
StoreId = storeId
});
}
await _db.SaveChangesAsync();
return Ok(ApiResponse<CouponTemplate>.Ok(template));
}
@ -462,7 +490,7 @@ public class AdminController : ControllerBase
.AsQueryable();
if (!string.IsNullOrEmpty(userPhone))
query = query.Where(c => c.User.Phone.Contains(userPhone));
query = query.Where(c => c.User.Phone.Contains(userPhone) || c.User.Uid.Contains(userPhone));
if (storeId.HasValue)
query = query.Where(c => c.StoreId == storeId.Value);
if (!string.IsNullOrEmpty(status))
@ -475,6 +503,7 @@ public class AdminController : ControllerBase
Id = c.Id,
Code = c.Code,
TemplateName = c.Template.Name,
UserUid = c.User.Uid,
UserPhone = c.User.Phone,
StoreName = c.Store.Name,
Status = c.Status,
@ -556,7 +585,22 @@ public class AdminController : ControllerBase
if (request.Nickname != null) user.Nickname = request.Nickname;
if (request.Phone != null) user.Phone = request.Phone;
if (request.Points.HasValue) user.Points = request.Points.Value;
// 积分变更时创建积分记录
if (request.Points.HasValue && request.Points.Value != user.Points)
{
var delta = request.Points.Value - user.Points;
_db.PointsRecords.Add(new Domain.Entities.PointsRecord
{
UserId = user.Id,
Amount = delta,
Type = delta > 0 ? "income" : "expense",
Reason = "管理员调整积分",
CreatedAt = DateTime.UtcNow
});
user.Points = request.Points.Value;
}
user.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();

View File

@ -35,8 +35,11 @@ public class CouponController : ControllerBase
Name = t.Name,
CouponType = t.CouponType,
DiscountAmount = t.DiscountAmount,
StoreName = t.Store.Name,
StoreId = t.StoreId,
Stores = t.TemplateStores.Select(ts => new CouponStoreInfo
{
Id = ts.Store.Id,
Name = ts.Store.Name
}).ToList(),
ValidStart = t.ValidStart,
ValidEnd = t.ValidEnd,
RemainingCount = t.RemainingCount,
@ -58,7 +61,7 @@ public class CouponController : ControllerBase
try
{
var coupon = await _couponService.ExchangeAsync(userId, request.TemplateId);
var coupon = await _couponService.ExchangeAsync(userId, request.TemplateId, request.StoreId);
var response = new UserCouponResponse
{
Id = coupon.Id,
@ -70,6 +73,8 @@ public class CouponController : ControllerBase
StoreId = coupon.StoreId,
Status = coupon.Status,
Source = coupon.Source,
ValidStart = coupon.Template?.ValidStart,
ValidEnd = coupon.Template?.ValidEnd,
ExchangedAt = coupon.ExchangedAt,
UsedAt = coupon.UsedAt,
ExpiredAt = coupon.ExpiredAt
@ -107,6 +112,8 @@ public class CouponController : ControllerBase
StoreId = c.StoreId,
Status = c.Status,
Source = c.Source,
ValidStart = c.Template?.ValidStart,
ValidEnd = c.Template?.ValidEnd,
ExchangedAt = c.ExchangedAt,
UsedAt = c.UsedAt,
ExpiredAt = c.ExpiredAt
@ -136,6 +143,8 @@ public class CouponController : ControllerBase
StoreId = c.StoreId,
Status = c.Status,
Source = c.Source,
ValidStart = c.Template?.ValidStart,
ValidEnd = c.Template?.ValidEnd,
ExchangedAt = c.ExchangedAt,
UsedAt = c.UsedAt,
ExpiredAt = c.ExpiredAt

View File

@ -1,8 +1,10 @@
using System.Security.Claims;
using HuangyanParking.Api.Models;
using HuangyanParking.Domain.Services;
using HuangyanParking.Infrastructure.Data;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace HuangyanParking.Api.Controllers;
@ -15,10 +17,41 @@ namespace HuangyanParking.Api.Controllers;
public class VerifyController : ControllerBase
{
private readonly IVerifyService _verifyService;
private readonly AppDbContext _db;
public VerifyController(IVerifyService verifyService)
public VerifyController(IVerifyService verifyService, AppDbContext db)
{
_verifyService = verifyService;
_db = db;
}
/// <summary>
/// 根据券码查询优惠券信息(核销前预览)
/// GET /api/verify/preview?code=xxx
/// </summary>
[HttpGet("preview")]
public async Task<ActionResult<ApiResponse<CouponPreviewResponse>>> Preview([FromQuery] string code)
{
if (string.IsNullOrWhiteSpace(code))
return BadRequest(ApiResponse<CouponPreviewResponse>.Fail("券码不能为空"));
var coupon = await _db.Coupons
.Include(c => c.Template)
.Include(c => c.Store)
.FirstOrDefaultAsync(c => c.Code == code);
if (coupon == null)
return NotFound(ApiResponse<CouponPreviewResponse>.Fail("未找到该券码对应的优惠券"));
return Ok(ApiResponse<CouponPreviewResponse>.Ok(new CouponPreviewResponse
{
CouponName = coupon.Template?.Name ?? string.Empty,
CouponCode = coupon.Code,
CouponType = coupon.Template?.CouponType ?? string.Empty,
DiscountAmount = coupon.Template?.DiscountAmount ?? 0,
StoreName = coupon.Store?.Name ?? string.Empty,
ValidEnd = coupon.Template?.ValidEnd
}));
}
/// <summary>

View File

@ -104,8 +104,8 @@ public class AdminCouponTemplateRequest
/// <summary>抵扣金额(抵扣券)</summary>
public decimal DiscountAmount { get; set; }
/// <summary>适用门店ID</summary>
public int StoreId { get; set; }
/// <summary>适用门店ID列表(多选)</summary>
public List<int> StoreIds { get; set; } = [];
/// <summary>有效期开始</summary>
public DateTime ValidStart { get; set; }
@ -179,6 +179,7 @@ public class AdminCouponResponse
public int Id { get; set; }
public string Code { get; set; } = string.Empty;
public string TemplateName { get; set; } = string.Empty;
public string UserUid { get; set; } = string.Empty;
public string UserPhone { get; set; } = string.Empty;
public string StoreName { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;

View File

@ -7,6 +7,9 @@ public class ExchangeCouponRequest
{
/// <summary>优惠券模板ID</summary>
public int TemplateId { get; set; }
/// <summary>选择的门店ID</summary>
public int StoreId { get; set; }
}
/// <summary>
@ -18,8 +21,8 @@ public class AvailableCouponResponse
public string Name { get; set; } = string.Empty;
public string CouponType { get; set; } = string.Empty;
public decimal DiscountAmount { get; set; }
public string StoreName { get; set; } = string.Empty;
public int StoreId { get; set; }
/// <summary>适用门店列表</summary>
public List<CouponStoreInfo> Stores { get; set; } = [];
public DateTime ValidStart { get; set; }
public DateTime ValidEnd { get; set; }
public int RemainingCount { get; set; }
@ -27,6 +30,15 @@ public class AvailableCouponResponse
public string Source { get; set; } = string.Empty;
}
/// <summary>
/// 优惠券关联门店信息
/// </summary>
public class CouponStoreInfo
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
}
/// <summary>
/// 用户优惠券响应
/// </summary>
@ -41,6 +53,8 @@ public class UserCouponResponse
public int StoreId { get; set; }
public string Status { get; set; } = string.Empty;
public string Source { get; set; } = string.Empty;
public DateTime? ValidStart { get; set; }
public DateTime? ValidEnd { get; set; }
public DateTime ExchangedAt { get; set; }
public DateTime? UsedAt { get; set; }
public DateTime ExpiredAt { get; set; }

View File

@ -32,3 +32,16 @@ public class VerifyRecordResponse
public string? UserPhone { get; set; }
public DateTime VerifiedAt { get; set; }
}
/// <summary>
/// 券码查询响应(核销前预览)
/// </summary>
public class CouponPreviewResponse
{
public string CouponName { get; set; } = string.Empty;
public string CouponCode { get; set; } = string.Empty;
public string CouponType { get; set; } = string.Empty;
public decimal DiscountAmount { get; set; }
public string StoreName { get; set; } = string.Empty;
public DateTime? ValidEnd { get; set; }
}

View File

@ -82,6 +82,77 @@ using (var scope = app.Services.CreateScope())
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.EnsureCreated();
// 数据库结构升级:优惠券模板支持多门店
try
{
// 创建中间表(如果不存在)
await db.Database.ExecuteSqlRawAsync(@"
IF NOT EXISTS (SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'coupon_template_stores')
BEGIN
CREATE TABLE coupon_template_stores (
CouponTemplateId int NOT NULL,
StoreId int NOT NULL,
CONSTRAINT PK_coupon_template_stores PRIMARY KEY (CouponTemplateId, StoreId),
CONSTRAINT FK_coupon_template_stores_coupon_templates FOREIGN KEY (CouponTemplateId) REFERENCES coupon_templates(Id) ON DELETE CASCADE,
CONSTRAINT FK_coupon_template_stores_stores FOREIGN KEY (StoreId) REFERENCES stores(Id) ON DELETE CASCADE
);
-- StoreId
IF EXISTS (SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'coupon_templates' AND COLUMN_NAME = 'StoreId')
BEGIN
INSERT INTO coupon_template_stores (CouponTemplateId, StoreId)
SELECT Id, StoreId FROM coupon_templates WHERE StoreId IS NOT NULL AND StoreId > 0;
END
END");
// 删除旧的外键和列(如果存在)
await db.Database.ExecuteSqlRawAsync(@"
IF EXISTS (SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'coupon_templates' AND COLUMN_NAME = 'StoreId')
BEGIN
--
DECLARE @fkName NVARCHAR(200);
SELECT @fkName = fk.name FROM sys.foreign_keys fk
JOIN sys.foreign_key_columns fkc ON fk.object_id = fkc.constraint_object_id
JOIN sys.columns c ON fkc.parent_column_id = c.column_id AND fkc.parent_object_id = c.object_id
WHERE OBJECT_NAME(fk.parent_object_id) = 'coupon_templates' AND c.name = 'StoreId';
IF @fkName IS NOT NULL
EXEC('ALTER TABLE coupon_templates DROP CONSTRAINT ' + @fkName);
--
DECLARE @ixName NVARCHAR(200);
SELECT @ixName = i.name FROM sys.indexes i
JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id
JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id
WHERE OBJECT_NAME(i.object_id) = 'coupon_templates' AND c.name = 'StoreId' AND i.is_primary_key = 0;
IF @ixName IS NOT NULL
EXEC('DROP INDEX ' + @ixName + ' ON coupon_templates');
--
ALTER TABLE coupon_templates DROP COLUMN StoreId;
END");
}
catch (Exception ex)
{
Log.Warning(ex, "数据库结构升级时出现异常(可忽略)");
}
// 删除MinSpend字段如果存在
try
{
await db.Database.ExecuteSqlRawAsync(@"
IF EXISTS (SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'coupon_templates' AND COLUMN_NAME = 'MinSpend')
BEGIN
DECLARE @dfName NVARCHAR(200);
SELECT @dfName = d.name FROM sys.default_constraints d
JOIN sys.columns c ON d.parent_column_id = c.column_id AND d.parent_object_id = c.object_id
WHERE OBJECT_NAME(d.parent_object_id) = 'coupon_templates' AND c.name = 'MinSpend';
IF @dfName IS NOT NULL
EXEC('ALTER TABLE coupon_templates DROP CONSTRAINT ' + @dfName);
ALTER TABLE coupon_templates DROP COLUMN MinSpend;
END");
}
catch (Exception ex)
{
Log.Warning(ex, "删除MinSpend字段时出现异常可忽略");
}
// 初始化默认管理员账号(如果不存在)
if (!db.Admins.Any())
{

View File

@ -16,9 +16,6 @@ public class CouponTemplate
/// <summary>抵扣金额(抵扣券)</summary>
public decimal DiscountAmount { get; set; }
/// <summary>适用门店</summary>
public int StoreId { get; set; }
/// <summary>有效期开始</summary>
public DateTime ValidStart { get; set; }
@ -46,7 +43,20 @@ public class CouponTemplate
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
// 导航属性
public Store Store { get; set; } = null!;
// 导航属性 - 多对多门店关联
public ICollection<CouponTemplateStore> TemplateStores { get; set; } = [];
public ICollection<Coupon> Coupons { get; set; } = [];
}
/// <summary>
/// 优惠券模板-门店 多对多中间表
/// </summary>
public class CouponTemplateStore
{
public int CouponTemplateId { get; set; }
public int StoreId { get; set; }
// 导航属性
public CouponTemplate CouponTemplate { get; set; } = null!;
public Store Store { get; set; } = null!;
}

View File

@ -35,6 +35,6 @@ public class Store
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
// 导航属性
public ICollection<CouponTemplate> CouponTemplates { get; set; } = [];
public ICollection<CouponTemplateStore> TemplateStores { get; set; } = [];
public ICollection<Coupon> Coupons { get; set; } = [];
}

View File

@ -11,7 +11,7 @@ public interface ICouponService
Task<List<CouponTemplate>> GetAvailableTemplatesAsync();
/// <summary>兑换优惠券(积分校验+扣减+券码生成+驿公里API调用</summary>
Task<Coupon> ExchangeAsync(int userId, int templateId);
Task<Coupon> ExchangeAsync(int userId, int templateId, int storeId);
/// <summary>获取用户优惠券列表(支持状态/门店/类型筛选)</summary>
Task<List<Coupon>> GetUserCouponsAsync(int userId, string? status = null, int? storeId = null, string? couponType = null);

View File

@ -15,6 +15,7 @@ public class AppDbContext : DbContext
public DbSet<User> Users => Set<User>();
public DbSet<Store> Stores => Set<Store>();
public DbSet<CouponTemplate> CouponTemplates => Set<CouponTemplate>();
public DbSet<CouponTemplateStore> CouponTemplateStores => Set<CouponTemplateStore>();
public DbSet<Coupon> Coupons => Set<Coupon>();
public DbSet<PointsRecord> PointsRecords => Set<PointsRecord>();
public DbSet<VerifyRecord> VerifyRecords => Set<VerifyRecord>();
@ -64,7 +65,15 @@ public class AppDbContext : DbContext
entity.Property(e => e.DiscountAmount).HasPrecision(10, 2);
entity.Property(e => e.Source).HasMaxLength(20).IsRequired();
entity.Property(e => e.YglTicketPackageCode).HasMaxLength(100);
entity.HasOne(e => e.Store).WithMany(s => s.CouponTemplates).HasForeignKey(e => e.StoreId).OnDelete(DeleteBehavior.Restrict);
});
// CouponTemplateStore 多对多中间表配置
modelBuilder.Entity<CouponTemplateStore>(entity =>
{
entity.ToTable("coupon_template_stores");
entity.HasKey(e => new { e.CouponTemplateId, e.StoreId });
entity.HasOne(e => e.CouponTemplate).WithMany(t => t.TemplateStores).HasForeignKey(e => e.CouponTemplateId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.Store).WithMany(s => s.TemplateStores).HasForeignKey(e => e.StoreId).OnDelete(DeleteBehavior.Cascade);
});
// Coupon 配置

View File

@ -34,7 +34,7 @@ public class CouponService : ICouponService
{
var now = DateTime.UtcNow;
return await _db.CouponTemplates
.Include(t => t.Store)
.Include(t => t.TemplateStores).ThenInclude(ts => ts.Store)
.Where(t => t.IsActive
&& t.RemainingCount > 0
&& t.ValidStart <= now
@ -44,7 +44,7 @@ public class CouponService : ICouponService
}
/// <summary>兑换优惠券</summary>
public async Task<Coupon> ExchangeAsync(int userId, int templateId)
public async Task<Coupon> ExchangeAsync(int userId, int templateId, int storeId)
{
// 使用事务确保一致性
await using var transaction = await _db.Database.BeginTransactionAsync();
@ -52,7 +52,7 @@ public class CouponService : ICouponService
try
{
var template = await _db.CouponTemplates
.Include(t => t.Store)
.Include(t => t.TemplateStores)
.FirstOrDefaultAsync(t => t.Id == templateId)
?? throw new InvalidOperationException("优惠券模板不存在");
@ -66,6 +66,10 @@ public class CouponService : ICouponService
if (now < template.ValidStart || now > template.ValidEnd)
throw new InvalidOperationException("优惠券不在有效期内");
// 校验门店是否属于该模板
if (!template.TemplateStores.Any(ts => ts.StoreId == storeId))
throw new InvalidOperationException("所选门店不适用于该优惠券");
// 校验积分
var user = await _db.Users.FindAsync(userId)
?? throw new InvalidOperationException("用户不存在");
@ -83,7 +87,7 @@ public class CouponService : ICouponService
Code = code,
TemplateId = templateId,
UserId = userId,
StoreId = template.StoreId,
StoreId = storeId,
Source = template.Source,
ExchangedAt = now,
ExpiredAt = template.ValidEnd,
@ -120,7 +124,7 @@ public class CouponService : ICouponService
userId, template.PointsCost,
$"兑换优惠券:{template.Name}",
couponId: coupon.Id,
storeId: template.StoreId);
storeId: storeId);
}
await transaction.CommitAsync();

View File

@ -1,4 +1,6 @@
using System.Net.Http.Json;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using HuangyanParking.Domain.Entities;
@ -29,7 +31,8 @@ public class YglService : IYglService
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
public YglService(
@ -161,6 +164,8 @@ public class YglService : IYglService
{
// 加密请求体
var bodyJson = JsonSerializer.Serialize(body, JsonOptions);
_logger.LogInformation("驿公里API请求原文: Path={Path}, Body={Body}", path, bodyJson);
var encryptedBody = _crypto.EncryptDes(bodyJson, _desKey);
// 对原文签名
@ -177,14 +182,21 @@ public class YglService : IYglService
body = encryptedBody
};
var response = await _httpClient.PostAsJsonAsync($"{_baseUrl}{path}", requestData, JsonOptions);
var requestJson = JsonSerializer.Serialize(requestData, JsonOptions);
_logger.LogInformation("驿公里API加密请求: {Request}", requestJson);
var content = new StringContent(requestJson, Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync($"{_baseUrl}{path}", content);
var responseJson = await response.Content.ReadAsStringAsync();
_logger.LogInformation("驿公里API响应: StatusCode={StatusCode}, Body={Body}",
response.StatusCode, responseJson);
var yglResponse = JsonSerializer.Deserialize<YglApiResponse<T>>(responseJson, JsonOptions);
if (yglResponse is null || !yglResponse.Success)
{
_logger.LogError("驿公里API请求失败: Path={Path}, ErrorMsg={ErrorMsg}",
path, yglResponse?.ErrorMsg);
_logger.LogError("驿公里API请求失败: Path={Path}, Response={Response}",
path, responseJson);
throw new InvalidOperationException($"驿公里API请求失败: {yglResponse?.ErrorMsg}");
}