This commit is contained in:
parent
843e22ea1d
commit
fa0cf7e41c
|
|
@ -8,3 +8,8 @@ export function getPointsConfig() {
|
|||
export function updatePointsConfig(data: { conversionRate: number }) {
|
||||
return request.put('/pointsconfig', data)
|
||||
}
|
||||
|
||||
// 积分变化记录
|
||||
export function getPointsRecords(page: number, size: number, search?: string, type?: string) {
|
||||
return request.get('/pointsconfig/records', { params: { page, size, search, type } })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -144,9 +144,9 @@ const bannerList = ref<BannerItem[]>([])
|
|||
// 移动端内部页面列表
|
||||
const internalPages = [
|
||||
{ path: '/pages/index/index', label: '首页' },
|
||||
{ path: '/pages/membership/index', label: '会员' },
|
||||
{ path: '/pages/stamp/index', label: '节日印花' },
|
||||
{ path: '/pages/points/index', label: '我的积分' },
|
||||
{ path: '/pages/membership/membership', label: '会员' },
|
||||
{ path: '/pages/stamps/stamps', label: '节日印花' },
|
||||
{ path: '/pages/points/points', label: '我的积分' },
|
||||
{ path: '/pages/coupons/index', label: '我的优惠券' },
|
||||
{ path: '/pages/profile/index', label: '我的' },
|
||||
{ path: '/pages/agreement/index', label: '用户协议' },
|
||||
|
|
|
|||
|
|
@ -22,50 +22,53 @@
|
|||
<el-tabs v-model="activeLang" type="border-card">
|
||||
<!-- 简体中文 -->
|
||||
<el-tab-pane label="简体中文" name="zhCn">
|
||||
<div class="editor-wrapper">
|
||||
<el-input
|
||||
v-model="form.contentZhCn"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 12, maxRows: 30 }"
|
||||
placeholder="请输入简体中文内容(支持 HTML)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="form.contentZhCn" class="preview-section">
|
||||
<el-divider>预览</el-divider>
|
||||
<div class="preview-content" v-html="form.contentZhCn" />
|
||||
</div>
|
||||
<template v-if="isImageType">
|
||||
<ImageUpload v-model="form.contentZhCn" />
|
||||
<el-image v-if="form.contentZhCn" :src="form.contentZhCn" style="max-width: 300px; margin-top: 12px" fit="contain" :preview-src-list="[form.contentZhCn]" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="editor-wrapper">
|
||||
<el-input v-model="form.contentZhCn" type="textarea" :autosize="{ minRows: 12, maxRows: 30 }" placeholder="请输入简体中文内容(支持 HTML)" />
|
||||
</div>
|
||||
<div v-if="form.contentZhCn" class="preview-section">
|
||||
<el-divider>预览</el-divider>
|
||||
<div class="preview-content" v-html="form.contentZhCn" />
|
||||
</div>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 繁体中文 -->
|
||||
<el-tab-pane label="繁體中文" name="zhTw">
|
||||
<div class="editor-wrapper">
|
||||
<el-input
|
||||
v-model="form.contentZhTw"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 12, maxRows: 30 }"
|
||||
placeholder="請輸入繁體中文內容(支持 HTML)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="form.contentZhTw" class="preview-section">
|
||||
<el-divider>預覽</el-divider>
|
||||
<div class="preview-content" v-html="form.contentZhTw" />
|
||||
</div>
|
||||
<template v-if="isImageType">
|
||||
<ImageUpload v-model="form.contentZhTw" />
|
||||
<el-image v-if="form.contentZhTw" :src="form.contentZhTw" style="max-width: 300px; margin-top: 12px" fit="contain" :preview-src-list="[form.contentZhTw]" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="editor-wrapper">
|
||||
<el-input v-model="form.contentZhTw" type="textarea" :autosize="{ minRows: 12, maxRows: 30 }" placeholder="請輸入繁體中文內容(支持 HTML)" />
|
||||
</div>
|
||||
<div v-if="form.contentZhTw" class="preview-section">
|
||||
<el-divider>預覽</el-divider>
|
||||
<div class="preview-content" v-html="form.contentZhTw" />
|
||||
</div>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 英文 -->
|
||||
<el-tab-pane label="English" name="en">
|
||||
<div class="editor-wrapper">
|
||||
<el-input
|
||||
v-model="form.contentEn"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 12, maxRows: 30 }"
|
||||
placeholder="Enter English content (HTML supported)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="form.contentEn" class="preview-section">
|
||||
<el-divider>Preview</el-divider>
|
||||
<div class="preview-content" v-html="form.contentEn" />
|
||||
</div>
|
||||
<template v-if="isImageType">
|
||||
<ImageUpload v-model="form.contentEn" />
|
||||
<el-image v-if="form.contentEn" :src="form.contentEn" style="max-width: 300px; margin-top: 12px" fit="contain" :preview-src-list="[form.contentEn]" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="editor-wrapper">
|
||||
<el-input v-model="form.contentEn" type="textarea" :autosize="{ minRows: 12, maxRows: 30 }" placeholder="Enter English content (HTML supported)" />
|
||||
</div>
|
||||
<div v-if="form.contentEn" class="preview-section">
|
||||
<el-divider>Preview</el-divider>
|
||||
<div class="preview-content" v-html="form.contentEn" />
|
||||
</div>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
|
|
@ -76,12 +79,14 @@
|
|||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getContent, updateContent } from '@/api/content'
|
||||
import ImageUpload from '@/components/ImageUpload.vue'
|
||||
|
||||
// 可编辑的内容类型
|
||||
const contentTypes = [
|
||||
{ key: 'agreement', label: '用户协议' },
|
||||
{ key: 'privacy-policy', label: '隐私政策' },
|
||||
{ key: 'coupon-guide', label: '优惠券使用说明' },
|
||||
{ key: 'agreement', label: '用户协议', type: 'text' },
|
||||
{ key: 'privacy-policy', label: '隐私政策', type: 'text' },
|
||||
{ key: 'coupon-guide', label: '优惠券使用说明', type: 'text' },
|
||||
{ key: 'membership-banner', label: '会员背景长图', type: 'image' },
|
||||
]
|
||||
|
||||
const activeKey = ref('agreement')
|
||||
|
|
@ -100,6 +105,11 @@ const currentLabel = computed(() => {
|
|||
return contentTypes.find(t => t.key === activeKey.value)?.label || ''
|
||||
})
|
||||
|
||||
// 当前是否为图片类型
|
||||
const isImageType = computed(() => {
|
||||
return contentTypes.find(t => t.key === activeKey.value)?.type === 'image'
|
||||
})
|
||||
|
||||
// 加载内容
|
||||
async function loadContent(key: string) {
|
||||
loading.value = true
|
||||
|
|
|
|||
|
|
@ -36,6 +36,12 @@
|
|||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="创建时间" width="170" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.createdAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.isActive ? 'success' : 'info'">
|
||||
|
|
|
|||
|
|
@ -1,70 +1,158 @@
|
|||
<template>
|
||||
<div class="points-config">
|
||||
<el-card shadow="never" style="max-width: 500px">
|
||||
<template #header>
|
||||
<span>积分转换比配置</span>
|
||||
</template>
|
||||
<el-form :model="form" label-width="140px">
|
||||
<el-form-item label="金额→积分转换比">
|
||||
<el-input-number
|
||||
v-model="form.conversionRate"
|
||||
:min="0.01"
|
||||
:precision="2"
|
||||
:step="0.1"
|
||||
<div class="points-manage">
|
||||
<el-tabs v-model="activeTab">
|
||||
<!-- 积分配置 -->
|
||||
<el-tab-pane label="积分配置" name="config">
|
||||
<el-card shadow="never" style="max-width: 500px">
|
||||
<el-form :model="form" label-width="140px">
|
||||
<el-form-item label="金额→积分转换比">
|
||||
<el-input-number
|
||||
v-model="form.conversionRate"
|
||||
:min="0.01"
|
||||
:precision="2"
|
||||
:step="0.1"
|
||||
style="width: 200px"
|
||||
/>
|
||||
<el-text type="info" size="small" style="margin-left: 12px">
|
||||
即 1 元 = {{ form.conversionRate }} 积分
|
||||
</el-text>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">保存配置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 积分记录 -->
|
||||
<el-tab-pane label="积分变化记录" name="records">
|
||||
<div class="toolbar">
|
||||
<el-input
|
||||
v-model="searchText"
|
||||
placeholder="搜索用户 UID"
|
||||
clearable
|
||||
style="width: 200px"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<el-text type="info" size="small" style="margin-left: 12px">
|
||||
即 1 元 = {{ form.conversionRate }} 积分
|
||||
</el-text>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">保存配置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-select v-model="filterType" placeholder="全部类型" clearable style="width: 140px" @change="handleSearch">
|
||||
<el-option label="获取" value="Earn" />
|
||||
<el-option label="消费" value="Spend" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="records" border style="width: 100%" v-loading="loadingRecords">
|
||||
<el-table-column label="用户UID" prop="userId" width="140" />
|
||||
<el-table-column label="类型" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.type === 'Earn' ? 'success' : 'danger'" size="small">
|
||||
{{ row.type === 'Earn' ? '获取' : '消费' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="积分" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: row.type === 'Earn' ? '#67c23a' : '#f56c6c' }">
|
||||
{{ row.type === 'Earn' ? '+' : '-' }}{{ row.amount }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源" prop="source" min-width="200" />
|
||||
<el-table-column label="时间" width="180" align="center">
|
||||
<template #default="{ row }">{{ formatDate(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="loadRecords"
|
||||
/>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getPointsConfig, updatePointsConfig } from '@/api/points'
|
||||
import { getPointsConfig, updatePointsConfig, getPointsRecords } from '@/api/points'
|
||||
|
||||
const activeTab = ref('config')
|
||||
|
||||
// 积分配置
|
||||
const form = ref({ conversionRate: 1 })
|
||||
const saving = ref(false)
|
||||
|
||||
// 加载当前配置
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const res: any = await getPointsConfig()
|
||||
if (res.data) {
|
||||
form.value.conversionRate = res.data.conversionRate ?? 1
|
||||
}
|
||||
} catch {
|
||||
// 错误已由拦截器处理
|
||||
}
|
||||
if (res.data) form.value.conversionRate = res.data.conversionRate ?? 1
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
await updatePointsConfig({ conversionRate: form.value.conversionRate })
|
||||
ElMessage.success('配置已保存')
|
||||
} catch {
|
||||
// 错误已由拦截器处理
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
} catch {}
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadConfig()
|
||||
// 积分记录
|
||||
const records = ref<any[]>([])
|
||||
const loadingRecords = ref(false)
|
||||
const searchText = ref('')
|
||||
const filterType = ref('')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = 20
|
||||
const total = ref(0)
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
async function loadRecords() {
|
||||
loadingRecords.value = true
|
||||
try {
|
||||
const res: any = await getPointsRecords(currentPage.value, pageSize, searchText.value || undefined, filterType.value || undefined)
|
||||
records.value = res.data?.items || []
|
||||
total.value = res.data?.total || 0
|
||||
} catch {}
|
||||
finally { loadingRecords.value = false }
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
currentPage.value = 1
|
||||
loadRecords()
|
||||
}
|
||||
|
||||
// 切换到记录tab时自动加载
|
||||
watch(activeTab, (val) => {
|
||||
if (val === 'records' && records.value.length === 0) loadRecords()
|
||||
})
|
||||
|
||||
onMounted(() => { loadConfig() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.points-config {
|
||||
.points-manage {
|
||||
padding: 20px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -27,10 +27,21 @@ public class AdminCouponController : ControllerBase
|
|||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll([FromQuery] int page = 1, [FromQuery] int size = 20)
|
||||
{
|
||||
// 自动下架已过期的优惠券
|
||||
var now = DateTime.UtcNow;
|
||||
var expired = await _db.CouponTemplates
|
||||
.Where(c => c.IsActive && c.ExpireAt < now)
|
||||
.ToListAsync();
|
||||
if (expired.Count > 0)
|
||||
{
|
||||
foreach (var c in expired) c.IsActive = false;
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var query = _db.CouponTemplates.Where(c => !c.IsStamp);
|
||||
var total = await query.CountAsync();
|
||||
var items = await query
|
||||
.OrderByDescending(c => c.ExpireAt)
|
||||
.OrderByDescending(c => c.CreatedAt)
|
||||
.Skip((page - 1) * size)
|
||||
.Take(size)
|
||||
.ToListAsync();
|
||||
|
|
@ -96,6 +107,10 @@ public class AdminCouponController : ControllerBase
|
|||
if (coupon == null)
|
||||
return NotFound(new { success = false, message = "优惠券不存在" });
|
||||
|
||||
// 已过期的优惠券不允许上架
|
||||
if (!coupon.IsActive && coupon.ExpireAt < DateTime.UtcNow)
|
||||
return Ok(new { success = false, message = "当前日期已超出到期时间,上架失败" });
|
||||
|
||||
coupon.IsActive = !coupon.IsActive;
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(new { success = true, data = coupon });
|
||||
|
|
|
|||
|
|
@ -56,6 +56,45 @@ public class AdminPointsConfigController : ControllerBase
|
|||
await _db.SaveChangesAsync();
|
||||
return Ok(new { success = true, data = config });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取所有用户积分变化记录(分页、搜索)
|
||||
/// </summary>
|
||||
[HttpGet("records")]
|
||||
public async Task<IActionResult> GetRecords(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int size = 20,
|
||||
[FromQuery] string? search = null,
|
||||
[FromQuery] string? type = null)
|
||||
{
|
||||
var query = _db.PointRecords.AsQueryable();
|
||||
|
||||
// 按用户UID搜索
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
query = query.Where(r => r.UserId.Contains(search));
|
||||
|
||||
// 按类型筛选
|
||||
if (!string.IsNullOrWhiteSpace(type) && Enum.TryParse<PointRecordType>(type, true, out var parsedType))
|
||||
query = query.Where(r => r.Type == parsedType);
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var items = await query
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.Skip((page - 1) * size)
|
||||
.Take(size)
|
||||
.Select(r => new
|
||||
{
|
||||
r.Id,
|
||||
r.UserId,
|
||||
Type = r.Type.ToString(),
|
||||
r.Amount,
|
||||
r.Source,
|
||||
r.CreatedAt
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new { success = true, data = new { items, total, page, size } });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,17 @@ public class AdminStampController : ControllerBase
|
|||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll([FromQuery] int page = 1, [FromQuery] int size = 20)
|
||||
{
|
||||
// 自动下架已过期的印花优惠券
|
||||
var now = DateTime.UtcNow;
|
||||
var expired = await _db.CouponTemplates
|
||||
.Where(c => c.IsStamp && c.IsActive && c.ExpireAt < now)
|
||||
.ToListAsync();
|
||||
if (expired.Count > 0)
|
||||
{
|
||||
foreach (var c in expired) c.IsActive = false;
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var query = _db.CouponTemplates.Where(c => c.IsStamp);
|
||||
var total = await query.CountAsync();
|
||||
var items = await query
|
||||
|
|
@ -96,6 +107,10 @@ public class AdminStampController : ControllerBase
|
|||
if (stamp == null || !stamp.IsStamp)
|
||||
return NotFound(new { success = false, message = "印花优惠券不存在" });
|
||||
|
||||
// 已过期的优惠券不允许上架
|
||||
if (!stamp.IsActive && stamp.ExpireAt < DateTime.UtcNow)
|
||||
return Ok(new { success = false, message = "当前日期已超出到期时间,上架失败" });
|
||||
|
||||
stamp.IsActive = !stamp.IsActive;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using VendingMachine.Domain.Entities;
|
||||
using VendingMachine.Infrastructure.Data;
|
||||
|
||||
namespace VendingMachine.Api.Controllers;
|
||||
|
|
@ -104,7 +105,22 @@ public class AdminUserController : ControllerBase
|
|||
if (user == null)
|
||||
return NotFound(new { success = false, message = "用户不存在" });
|
||||
|
||||
var oldBalance = user.PointsBalance;
|
||||
var diff = request.Points - oldBalance;
|
||||
user.PointsBalance = request.Points;
|
||||
|
||||
// 记录积分变化
|
||||
if (diff != 0)
|
||||
{
|
||||
_db.PointRecords.Add(new PointRecord
|
||||
{
|
||||
UserId = uid,
|
||||
Type = diff > 0 ? PointRecordType.Earn : PointRecordType.Spend,
|
||||
Amount = Math.Abs(diff),
|
||||
Source = "管理员调整"
|
||||
});
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return Ok(new { success = true, message = "积分已更新", data = new { pointsBalance = user.PointsBalance } });
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ public class CouponController : ControllerBase
|
|||
if (string.IsNullOrEmpty(uid))
|
||||
return Unauthorized(ApiResponse.Fail("未授权"));
|
||||
|
||||
var result = await _couponService.RedeemCouponAsync(uid, couponId);
|
||||
var result = await _couponService.RedeemCouponAsync(uid, couponId, GetLanguage());
|
||||
return result.Success ? Ok(result) : BadRequest(result);
|
||||
}
|
||||
|
||||
|
|
@ -86,7 +86,7 @@ public class CouponController : ControllerBase
|
|||
if (string.IsNullOrEmpty(uid))
|
||||
return Unauthorized(ApiResponse.Fail("未授权"));
|
||||
|
||||
var result = await _couponService.RedeemStampCouponAsync(uid, stampCouponId);
|
||||
var result = await _couponService.RedeemStampCouponAsync(uid, stampCouponId, GetLanguage());
|
||||
return result.Success ? Ok(result) : BadRequest(result);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
using System.Security.Claims;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using VendingMachine.Infrastructure.Data;
|
||||
|
||||
namespace VendingMachine.Api.Middleware;
|
||||
|
||||
public class LanguageMiddleware
|
||||
|
|
@ -10,7 +14,7 @@ public class LanguageMiddleware
|
|||
_next = next;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
public async Task InvokeAsync(HttpContext context, AppDbContext db)
|
||||
{
|
||||
var lang = context.Request.Headers.AcceptLanguage.FirstOrDefault();
|
||||
|
||||
|
|
@ -20,6 +24,19 @@ public class LanguageMiddleware
|
|||
}
|
||||
|
||||
context.Items["Language"] = lang;
|
||||
|
||||
// 已登录用户:如果请求语言与数据库中存储的不一致,则自动同步
|
||||
var uid = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (!string.IsNullOrEmpty(uid))
|
||||
{
|
||||
var user = await db.Users.FirstOrDefaultAsync(u => u.Uid == uid);
|
||||
if (user != null && user.Language != lang)
|
||||
{
|
||||
user.Language = lang;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,9 +91,9 @@ if (!app.Environment.IsDevelopment())
|
|||
}
|
||||
app.UseCors();
|
||||
app.UseMiddleware<ExceptionHandlingMiddleware>();
|
||||
app.UseMiddleware<LanguageMiddleware>();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseMiddleware<LanguageMiddleware>();
|
||||
app.MapControllers();
|
||||
|
||||
// 初始化默认管理员账号
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
50
backend/src/VendingMachine.Application/Common/Messages.cs
Normal file
50
backend/src/VendingMachine.Application/Common/Messages.cs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
namespace VendingMachine.Application.Common;
|
||||
|
||||
/// <summary>
|
||||
/// 多语言消息辅助类
|
||||
/// </summary>
|
||||
public static class Messages
|
||||
{
|
||||
private static readonly Dictionary<string, Dictionary<string, string>> _messages = new()
|
||||
{
|
||||
["UserNotFound"] = new() { ["zh-CN"] = "用户不存在", ["zh-TW"] = "用戶不存在", ["en"] = "User not found" },
|
||||
["CouponNotFound"] = new() { ["zh-CN"] = "优惠券不存在", ["zh-TW"] = "優惠券不存在", ["en"] = "Coupon not found" },
|
||||
["CouponOffline"] = new() { ["zh-CN"] = "优惠券已下架", ["zh-TW"] = "優惠券已下架", ["en"] = "Coupon is no longer available" },
|
||||
["CouponExpired"] = new() { ["zh-CN"] = "优惠券已过期", ["zh-TW"] = "優惠券已過期", ["en"] = "Coupon has expired" },
|
||||
["InsufficientPoints"] = new() { ["zh-CN"] = "积分不足,无法兑换", ["zh-TW"] = "積分不足,無法兌換", ["en"] = "Insufficient points" },
|
||||
["RedeemSuccess"] = new() { ["zh-CN"] = "兑换成功", ["zh-TW"] = "兌換成功", ["en"] = "Redeemed successfully" },
|
||||
["RedeemFailed"] = new() { ["zh-CN"] = "兑换失败,请稍后重试", ["zh-TW"] = "兌換失敗,請稍後重試", ["en"] = "Redemption failed, please try again" },
|
||||
["StampNotFound"] = new() { ["zh-CN"] = "印花优惠券不存在", ["zh-TW"] = "印花優惠券不存在", ["en"] = "Stamp coupon not found" },
|
||||
["StampOffline"] = new() { ["zh-CN"] = "印花优惠券已下架", ["zh-TW"] = "印花優惠券已下架", ["en"] = "Stamp coupon is no longer available" },
|
||||
["StampExpired"] = new() { ["zh-CN"] = "印花优惠券已过期", ["zh-TW"] = "印花優惠券已過期", ["en"] = "Stamp coupon has expired" },
|
||||
["StampAlreadyRedeemed"] = new() { ["zh-CN"] = "该印花优惠券已兑换,每人限兑1次", ["zh-TW"] = "該印花優惠券已兌換,每人限兌1次", ["en"] = "Already redeemed, limit 1 per user" },
|
||||
["NotMember"] = new() { ["zh-CN"] = "非会员用户无法兑换印花优惠券", ["zh-TW"] = "非會員用戶無法兌換印花優惠券", ["en"] = "Membership required to redeem stamp coupons" },
|
||||
["AlreadyMonthly"] = new() { ["zh-CN"] = "已购买单月会员,请选择订阅会员", ["zh-TW"] = "已購買單月會員,請選擇訂閱會員", ["en"] = "Already a monthly member, please choose subscription" },
|
||||
["AlreadySubscribed"] = new() { ["zh-CN"] = "已购买订阅会员", ["zh-TW"] = "已購買訂閱會員", ["en"] = "Already subscribed" },
|
||||
["ProductNotFound"] = new() { ["zh-CN"] = "商品不存在", ["zh-TW"] = "商品不存在", ["en"] = "Product not found" },
|
||||
["NotMonthlyProduct"] = new() { ["zh-CN"] = "该商品不是单月会员", ["zh-TW"] = "該商品不是單月會員", ["en"] = "Not a monthly membership product" },
|
||||
["NotSubscriptionProduct"] = new() { ["zh-CN"] = "该商品不是订阅会员", ["zh-TW"] = "該商品不是訂閱會員", ["en"] = "Not a subscription product" },
|
||||
["PurchaseFailed"] = new() { ["zh-CN"] = "购买失败,请稍后重试", ["zh-TW"] = "購買失敗,請稍後重試", ["en"] = "Purchase failed, please try again" },
|
||||
["InvalidAreaCode"] = new() { ["zh-CN"] = "无效的手机区号", ["zh-TW"] = "無效的手機區號", ["en"] = "Invalid area code" },
|
||||
["InvalidPhone"] = new() { ["zh-CN"] = "无效的手机号格式", ["zh-TW"] = "無效的手機號格式", ["en"] = "Invalid phone number" },
|
||||
["CodeSent"] = new() { ["zh-CN"] = "验证码已发送", ["zh-TW"] = "驗證碼已發送", ["en"] = "Verification code sent" },
|
||||
["CodeInvalid"] = new() { ["zh-CN"] = "验证码错误或已过期", ["zh-TW"] = "驗證碼錯誤或已過期", ["en"] = "Invalid or expired verification code" },
|
||||
["AgreementRequired"] = new() { ["zh-CN"] = "请阅读并同意协议", ["zh-TW"] = "請閱讀並同意協議", ["en"] = "Please read and agree to the terms" },
|
||||
["LoggedOut"] = new() { ["zh-CN"] = "已退出登录", ["zh-TW"] = "已退出登錄", ["en"] = "Logged out" },
|
||||
["AccountDeleted"] = new() { ["zh-CN"] = "已注销", ["zh-TW"] = "已註銷", ["en"] = "Account deleted" },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 获取多语言消息
|
||||
/// </summary>
|
||||
public static string Get(string key, string lang = "zh-CN")
|
||||
{
|
||||
if (_messages.TryGetValue(key, out var langMap))
|
||||
{
|
||||
if (langMap.TryGetValue(lang, out var msg))
|
||||
return msg;
|
||||
return langMap["zh-CN"];
|
||||
}
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ public class UserCouponDto
|
|||
public string Type { get; set; } = string.Empty;
|
||||
public decimal? ThresholdAmount { get; set; }
|
||||
public decimal DiscountAmount { get; set; }
|
||||
public int PointsCost { get; set; }
|
||||
public string Status { get; set; } = string.Empty;
|
||||
public DateTime ExpireAt { get; set; }
|
||||
public DateTime? UsedAt { get; set; }
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ public interface ICouponService
|
|||
/// <summary>
|
||||
/// 兑换优惠券(积分检查、下架检查)
|
||||
/// </summary>
|
||||
Task<ApiResponse<RedeemResult>> RedeemCouponAsync(string uid, string couponTemplateId);
|
||||
Task<ApiResponse<RedeemResult>> RedeemCouponAsync(string uid, string couponTemplateId, string lang);
|
||||
|
||||
/// <summary>
|
||||
/// 获取用户优惠券列表,可按状态筛选
|
||||
|
|
@ -28,7 +28,7 @@ public interface ICouponService
|
|||
/// <summary>
|
||||
/// 兑换印花优惠券(会员检查、每人限兑1次)
|
||||
/// </summary>
|
||||
Task<ApiResponse<RedeemResult>> RedeemStampCouponAsync(string uid, string stampCouponId);
|
||||
Task<ApiResponse<RedeemResult>> RedeemStampCouponAsync(string uid, string stampCouponId, string lang);
|
||||
|
||||
/// <summary>
|
||||
/// 自动更新过期优惠券状态
|
||||
|
|
|
|||
|
|
@ -13,6 +13,6 @@ public class CouponTemplate
|
|||
public DateTime ExpireAt { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public bool IsStamp { get; set; }
|
||||
}
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;}
|
||||
|
||||
public enum CouponType { ThresholdDiscount, DirectDiscount }
|
||||
|
|
|
|||
464
backend/src/VendingMachine.Infrastructure/Data/Migrations/20260420194649_AddCouponCreatedAt.Designer.cs
generated
Normal file
464
backend/src/VendingMachine.Infrastructure/Data/Migrations/20260420194649_AddCouponCreatedAt.Designer.cs
generated
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using VendingMachine.Infrastructure.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VendingMachine.Infrastructure.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260420194649_AddCouponCreatedAt")]
|
||||
partial class AddCouponCreatedAt
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.5")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("VendingMachine.Domain.Entities.AdminUser", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Username")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AdminUsers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("VendingMachine.Domain.Entities.Banner", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("ImageUrlEn")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ImageUrlZhCn")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ImageUrlZhTw")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("LinkType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<string>("LinkUrl")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Banners");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("VendingMachine.Domain.Entities.ContentConfig", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("ContentEn")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ContentZhCn")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ContentZhTw")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("ContentConfigs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("VendingMachine.Domain.Entities.CouponTemplate", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<decimal>("DiscountAmount")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<DateTime>("ExpireAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsStamp")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("NameEn")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("NameZhCn")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("NameZhTw")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("PointsCost")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal?>("ThresholdAmount")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("CouponTemplates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("VendingMachine.Domain.Entities.HomeEntry", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("ImageUrlEn")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ImageUrlZhCn")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ImageUrlZhTw")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("HomeEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("VendingMachine.Domain.Entities.MembershipProduct", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("AppleProductId")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Currency")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("nvarchar(10)");
|
||||
|
||||
b.Property<string>("DescriptionEn")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("DescriptionZhCn")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("DescriptionZhTw")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("DurationDays")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("GoogleProductId")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<decimal>("Price")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("MembershipProducts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("VendingMachine.Domain.Entities.PointRecord", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<int>("Amount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(12)
|
||||
.HasColumnType("nvarchar(12)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("PointRecords");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("VendingMachine.Domain.Entities.PointsConfig", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<decimal>("ConversionRate")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("PointsConfigs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("VendingMachine.Domain.Entities.TestAccount", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Phone")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("TestAccounts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("VendingMachine.Domain.Entities.User", b =>
|
||||
{
|
||||
b.Property<string>("Uid")
|
||||
.HasMaxLength(12)
|
||||
.HasColumnType("nvarchar(12)");
|
||||
|
||||
b.Property<string>("AreaCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("nvarchar(10)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsMember")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("nvarchar(10)");
|
||||
|
||||
b.Property<DateTime?>("MembershipExpireAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int>("MembershipType")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Nickname")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<int>("PointsBalance")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("PointsExpireAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.HasKey("Uid");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("VendingMachine.Domain.Entities.UserCoupon", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("CouponTemplateId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTime>("ExpireAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("UsedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(12)
|
||||
.HasColumnType("nvarchar(12)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CouponTemplateId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("UserCoupons");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("VendingMachine.Domain.Entities.VendingPaymentRecord", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("MachineId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<decimal>("PaymentAmount")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("PaymentStatus")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<string>("TransactionId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("UsedCouponId")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(12)
|
||||
.HasColumnType("nvarchar(12)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("VendingPaymentRecords");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("VendingMachine.Domain.Entities.UserCoupon", b =>
|
||||
{
|
||||
b.HasOne("VendingMachine.Domain.Entities.CouponTemplate", "CouponTemplate")
|
||||
.WithMany()
|
||||
.HasForeignKey("CouponTemplateId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("CouponTemplate");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VendingMachine.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCouponCreatedAt : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "CreatedAt",
|
||||
table: "CouponTemplates",
|
||||
type: "datetime2",
|
||||
nullable: false,
|
||||
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CreatedAt",
|
||||
table: "CouponTemplates");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -117,6 +117,9 @@ namespace VendingMachine.Infrastructure.Data.Migrations
|
|||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<decimal>("DiscountAmount")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
|
|
|||
|
|
@ -41,30 +41,27 @@ public class CouponService : ICouponService
|
|||
return ApiResponse<List<RedeemableCouponDto>>.Ok(result);
|
||||
}
|
||||
|
||||
public async Task<ApiResponse<RedeemResult>> RedeemCouponAsync(string uid, string couponTemplateId)
|
||||
public async Task<ApiResponse<RedeemResult>> RedeemCouponAsync(string uid, string couponTemplateId, string lang)
|
||||
{
|
||||
await using var transaction = await _db.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
var user = await _db.Users.FirstOrDefaultAsync(u => u.Uid == uid);
|
||||
if (user == null)
|
||||
return ApiResponse<RedeemResult>.Fail("用户不存在");
|
||||
return ApiResponse<RedeemResult>.Fail(Messages.Get("UserNotFound", lang));
|
||||
|
||||
var template = await _db.CouponTemplates.FirstOrDefaultAsync(c => c.Id == couponTemplateId);
|
||||
if (template == null)
|
||||
return ApiResponse<RedeemResult>.Fail("优惠券不存在");
|
||||
return ApiResponse<RedeemResult>.Fail(Messages.Get("CouponNotFound", lang));
|
||||
|
||||
// 下架检查
|
||||
if (!template.IsActive)
|
||||
return ApiResponse<RedeemResult>.Fail("优惠券已下架");
|
||||
return ApiResponse<RedeemResult>.Fail(Messages.Get("CouponOffline", lang));
|
||||
|
||||
// 过期检查
|
||||
if (template.ExpireAt <= DateTime.UtcNow)
|
||||
return ApiResponse<RedeemResult>.Fail("优惠券已过期");
|
||||
return ApiResponse<RedeemResult>.Fail(Messages.Get("CouponExpired", lang));
|
||||
|
||||
// 积分检查
|
||||
if (user.PointsBalance < template.PointsCost)
|
||||
return ApiResponse<RedeemResult>.Fail("积分不足,无法兑换");
|
||||
return ApiResponse<RedeemResult>.Fail(Messages.Get("InsufficientPoints", lang));
|
||||
|
||||
// 扣减积分
|
||||
user.PointsBalance -= template.PointsCost;
|
||||
|
|
@ -101,13 +98,13 @@ public class CouponService : ICouponService
|
|||
{
|
||||
Redeemed = true,
|
||||
UserCouponId = userCoupon.Id
|
||||
}, "兑换成功");
|
||||
}, Messages.Get("RedeemSuccess", lang));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
_logger.LogError(ex, "兑换优惠券失败");
|
||||
return ApiResponse<RedeemResult>.Fail("兑换失败,请稍后重试");
|
||||
return ApiResponse<RedeemResult>.Fail(Messages.Get("RedeemFailed", lang));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -137,6 +134,7 @@ public class CouponService : ICouponService
|
|||
Type = uc.CouponTemplate.Type.ToString().ToLowerInvariant(),
|
||||
ThresholdAmount = uc.CouponTemplate.ThresholdAmount,
|
||||
DiscountAmount = uc.CouponTemplate.DiscountAmount,
|
||||
PointsCost = uc.CouponTemplate.PointsCost,
|
||||
Status = uc.Status.ToString().ToLowerInvariant(),
|
||||
ExpireAt = uc.ExpireAt,
|
||||
UsedAt = uc.UsedAt,
|
||||
|
|
@ -177,38 +175,35 @@ public class CouponService : ICouponService
|
|||
return ApiResponse<List<StampCouponDto>>.Ok(result);
|
||||
}
|
||||
|
||||
public async Task<ApiResponse<RedeemResult>> RedeemStampCouponAsync(string uid, string stampCouponId)
|
||||
public async Task<ApiResponse<RedeemResult>> RedeemStampCouponAsync(string uid, string stampCouponId, string lang)
|
||||
{
|
||||
await using var transaction = await _db.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
var user = await _db.Users.FirstOrDefaultAsync(u => u.Uid == uid);
|
||||
if (user == null)
|
||||
return ApiResponse<RedeemResult>.Fail("用户不存在");
|
||||
return ApiResponse<RedeemResult>.Fail(Messages.Get("UserNotFound", lang));
|
||||
|
||||
// 会员检查
|
||||
if (!user.IsMember)
|
||||
return ApiResponse<RedeemResult>.Fail("非会员用户无法兑换印花优惠券");
|
||||
return ApiResponse<RedeemResult>.Fail(Messages.Get("NotMember", lang));
|
||||
|
||||
var template = await _db.CouponTemplates.FirstOrDefaultAsync(c => c.Id == stampCouponId && c.IsStamp);
|
||||
if (template == null)
|
||||
return ApiResponse<RedeemResult>.Fail("印花优惠券不存在");
|
||||
return ApiResponse<RedeemResult>.Fail(Messages.Get("StampNotFound", lang));
|
||||
|
||||
if (!template.IsActive)
|
||||
return ApiResponse<RedeemResult>.Fail("印花优惠券已下架");
|
||||
return ApiResponse<RedeemResult>.Fail(Messages.Get("StampOffline", lang));
|
||||
|
||||
if (template.ExpireAt <= DateTime.UtcNow)
|
||||
return ApiResponse<RedeemResult>.Fail("印花优惠券已过期");
|
||||
return ApiResponse<RedeemResult>.Fail(Messages.Get("StampExpired", lang));
|
||||
|
||||
// 每人限兑1次检查
|
||||
var alreadyRedeemed = await _db.UserCoupons
|
||||
.AnyAsync(uc => uc.UserId == uid && uc.CouponTemplateId == stampCouponId);
|
||||
if (alreadyRedeemed)
|
||||
return ApiResponse<RedeemResult>.Fail("该印花优惠券已兑换,每人限兑1次");
|
||||
return ApiResponse<RedeemResult>.Fail(Messages.Get("StampAlreadyRedeemed", lang));
|
||||
|
||||
// 积分检查(印花可能需要0积分或少量积分)
|
||||
if (user.PointsBalance < template.PointsCost)
|
||||
return ApiResponse<RedeemResult>.Fail("积分不足,无法兑换");
|
||||
return ApiResponse<RedeemResult>.Fail(Messages.Get("InsufficientPoints", lang));
|
||||
|
||||
// 扣减积分
|
||||
user.PointsBalance -= template.PointsCost;
|
||||
|
|
@ -244,13 +239,13 @@ public class CouponService : ICouponService
|
|||
{
|
||||
Redeemed = true,
|
||||
UserCouponId = userCoupon.Id
|
||||
}, "兑换成功");
|
||||
}, Messages.Get("RedeemSuccess", lang));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
_logger.LogError(ex, "兑换印花优惠券失败");
|
||||
return ApiResponse<RedeemResult>.Fail("兑换失败,请稍后重试");
|
||||
return ApiResponse<RedeemResult>.Fail(Messages.Get("RedeemFailed", lang));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,5 +33,5 @@ export function getSpendRecords(params) {
|
|||
* @returns {Promise<{success: boolean, data: any, message: string}>}
|
||||
*/
|
||||
export function giftPoints(uid, amount) {
|
||||
return post('/api/points/gift', { uid, amount })
|
||||
return post('/api/points/gift', { targetUid: uid, amount })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import { getStorage, removeStorage, TOKEN_KEY, LOCALE_KEY } from '../utils/stora
|
|||
|
||||
// 后端API基础地址
|
||||
// MuMu模拟器需使用宿主机局域网IP,生产环境替换为正式域名
|
||||
// export const BASE_URL = 'http://192.168.21.7:5082'
|
||||
export const BASE_URL = 'https://api.tty.shhmkjgs.cn'
|
||||
export const BASE_URL = 'http://192.168.21.9:5082'
|
||||
// export const BASE_URL = 'https://api.tty.shhmkjgs.cn'
|
||||
|
||||
/**
|
||||
* 统一请求封装,自动注入Token和语言请求头,统一处理响应和错误
|
||||
|
|
|
|||
86
mobile/components/CustomTabBar.vue
Normal file
86
mobile/components/CustomTabBar.vue
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<template>
|
||||
<view class="custom-tabbar" :style="{ paddingBottom: safeBottom + 'px' }">
|
||||
<view class="tab-item" v-for="item in tabs" :key="item.path" @click="switchTab(item.path)">
|
||||
<image class="tab-icon" :src="current === item.path ? item.selectedIcon : item.icon" mode="aspectFit" />
|
||||
<text class="tab-label" :class="{ active: current === item.path }">{{ item.text }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
ref
|
||||
} from 'vue'
|
||||
|
||||
defineProps({
|
||||
current: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const tabs = [{
|
||||
path: '/pages/index/index',
|
||||
text: '首页',
|
||||
icon: '/static/tab/ic_home.png',
|
||||
selectedIcon: '/static/tab/ic_home_s.png'
|
||||
},
|
||||
{
|
||||
path: '/pages/mine/mine',
|
||||
text: '我的',
|
||||
icon: '/static/tab/ic_me.png',
|
||||
selectedIcon: '/static/tab/ic_me_s.png'
|
||||
}
|
||||
]
|
||||
|
||||
// 安全区域底部距离
|
||||
const safeBottom = ref(0)
|
||||
try {
|
||||
const sysInfo = uni.getSystemInfoSync()
|
||||
safeBottom.value = sysInfo.safeAreaInsets?.bottom || 0
|
||||
} catch (e) {}
|
||||
|
||||
function switchTab(path) {
|
||||
uni.switchTab({
|
||||
url: path
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-tabbar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
background-color: #ffffff;
|
||||
border-top: 1rpx solid #f0f0f0;
|
||||
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10rpx 0 8rpx;
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.tab-label {
|
||||
font-size: 24rpx;
|
||||
color: #AEAEAE;
|
||||
}
|
||||
|
||||
.tab-label.active {
|
||||
color: #363636;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -9,7 +9,11 @@ export default {
|
|||
networkError: 'Network connection failed, please check your network',
|
||||
timeout: 'Network timeout, please try again',
|
||||
serverError: 'Server is busy, please try again later',
|
||||
retry: 'Retry'
|
||||
retry: 'Retry',
|
||||
emptyNoCoupons: 'No coupons',
|
||||
emptyNoRedeemable: 'No redeemable coupons',
|
||||
emptyNoPoints: 'No points records',
|
||||
emptyNoStamps: 'No stamp coupons'
|
||||
},
|
||||
// TabBar
|
||||
tabBar: {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,11 @@ export default {
|
|||
networkError: '网络连接失败,请检查网络设置',
|
||||
timeout: '网络连接超时,请重试',
|
||||
serverError: '服务器繁忙,请稍后重试',
|
||||
retry: '重试'
|
||||
retry: '重试',
|
||||
emptyNoCoupons: '暂无优惠券',
|
||||
emptyNoRedeemable: '暂无可兑换优惠券',
|
||||
emptyNoPoints: '暂无积分记录',
|
||||
emptyNoStamps: '暂无印花优惠券'
|
||||
},
|
||||
// TabBar
|
||||
tabBar: {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,11 @@ export default {
|
|||
networkError: '網路連線失敗,請檢查網路設定',
|
||||
timeout: '網路連線逾時,請重試',
|
||||
serverError: '伺服器忙碌,請稍後重試',
|
||||
retry: '重試'
|
||||
retry: '重試',
|
||||
emptyNoCoupons: '暫無優惠券',
|
||||
emptyNoRedeemable: '暫無可兌換優惠券',
|
||||
emptyNoPoints: '暫無積分記錄',
|
||||
emptyNoStamps: '暫無印花優惠券'
|
||||
},
|
||||
// TabBar
|
||||
tabBar: {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
{
|
||||
"path": "pages/membership/membership",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "会员服务"
|
||||
}
|
||||
},
|
||||
|
|
@ -77,6 +78,7 @@
|
|||
"backgroundColor": "#F8F8F8"
|
||||
},
|
||||
"tabBar": {
|
||||
"custom": true,
|
||||
"color": "#999999",
|
||||
"selectedColor": "#007aff",
|
||||
"borderStyle": "black",
|
||||
|
|
|
|||
|
|
@ -14,15 +14,15 @@
|
|||
<view class="page-body" :style="{ paddingTop: (statusBarHeight + 44) + 'px' }">
|
||||
<!-- APP信息 -->
|
||||
<view class="app-info">
|
||||
<image class="logo" src="/static/logo.png" mode="aspectFit" />
|
||||
<text class="version">版本 {{ appVersion }}</text>
|
||||
<view class="logo-wrapper">
|
||||
<image class="logo" src="/static/logo.png" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="version">版本号 {{ appVersion }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 注销账号按钮(已登录时显示) -->
|
||||
<!-- 底部注销账号 -->
|
||||
<view v-if="userStore.isLoggedIn" class="delete-section">
|
||||
<view class="delete-btn" @click="showDeleteConfirm = true">
|
||||
<text class="delete-text">{{ t('about.deleteAccount') }}</text>
|
||||
</view>
|
||||
<text class="delete-text" @click="showDeleteConfirm = true">{{ t('about.deleteAccount') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
|
|
@ -79,7 +79,6 @@ async function handleDelete() {
|
|||
try {
|
||||
await userStore.deleteAccount()
|
||||
uni.showToast({ title: t('about.deleteSuccess'), icon: 'none' })
|
||||
// 返回首页
|
||||
uni.switchTab({ url: '/pages/index/index' })
|
||||
} catch (e) {
|
||||
/* 错误已统一处理 */
|
||||
|
|
@ -90,16 +89,18 @@ async function handleDelete() {
|
|||
<style scoped>
|
||||
.about-page {
|
||||
min-height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
background-color: #ffffff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 导航栏 */
|
||||
.custom-nav {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0;
|
||||
z-index: 100;
|
||||
background-color: #DBDBDB;
|
||||
background-color: #ffffff;
|
||||
border-bottom: 1rpx solid #e5e5e5;
|
||||
}
|
||||
.nav-inner {
|
||||
height: 44px;
|
||||
|
|
@ -127,72 +128,67 @@ async function handleDelete() {
|
|||
.nav-placeholder {
|
||||
width: 60rpx;
|
||||
}
|
||||
|
||||
/* 页面主体 */
|
||||
.page-body {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* APP信息区域 */
|
||||
.app-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 80rpx 0 40rpx;
|
||||
padding-top: 120rpx;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 160rpx;
|
||||
height: 160rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.version {
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.delete-section {
|
||||
width: 100%;
|
||||
padding: 60rpx 24rpx 0;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background-color: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
height: 88rpx;
|
||||
.logo-wrapper {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
border: 2rpx solid #ccc;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
.logo {
|
||||
width: 140rpx;
|
||||
height: 140rpx;
|
||||
}
|
||||
.version {
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 底部注销 */
|
||||
.delete-section {
|
||||
padding-bottom: 80rpx;
|
||||
}
|
||||
.delete-text {
|
||||
font-size: 30rpx;
|
||||
color: #ff3b30;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 弹窗样式 */
|
||||
.popup-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.popup-content {
|
||||
width: 600rpx;
|
||||
background-color: #ffffff;
|
||||
border-radius: 24rpx;
|
||||
padding: 40rpx;
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
font-size: 32rpx;
|
||||
color: #333;
|
||||
|
|
@ -200,12 +196,10 @@ async function handleDelete() {
|
|||
display: block;
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.popup-actions {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
height: 80rpx;
|
||||
|
|
@ -214,23 +208,18 @@ async function handleDelete() {
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
background-color: #ff3b30;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.cancel-text {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.confirm-text {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@
|
|||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<EmptyState v-if="!loading && coupons.length === 0" text="暂无优惠券" />
|
||||
<EmptyState v-if="!loading && coupons.length === 0" :text="t('common.emptyNoCoupons')" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
|
|
|||
|
|
@ -21,26 +21,29 @@
|
|||
|
||||
<!-- 成为会员入口 -->
|
||||
<view class="membership-entry" @click="onEntryClick({ key: 'membership' })">
|
||||
<view class="membership-entry-left">
|
||||
<view class="membership-icon-wrap">
|
||||
<image class="membership-icon-img" src="/static/ic_vip.png" mode="aspectFit" />
|
||||
<image v-if="getEntryImage('membership')" class="membership-entry-img" :src="resolveImageUrl(getEntryImage('membership'))" mode="aspectFill" />
|
||||
<template v-else>
|
||||
<view class="membership-entry-left">
|
||||
<view class="membership-icon-wrap">
|
||||
<image class="membership-icon-img" src="/static/ic_vip.png" mode="aspectFit" />
|
||||
</view>
|
||||
<view class="membership-text">
|
||||
<text class="membership-title">{{ t('home.membership') }}</text>
|
||||
<text class="membership-sub">Unlock VIP Benefits</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="membership-text">
|
||||
<text class="membership-title">{{ t('home.membership') }}</text>
|
||||
<text class="membership-sub">Unlock VIP Benefits</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="membership-arrow">›</text>
|
||||
<text class="membership-arrow">›</text>
|
||||
</template>
|
||||
</view>
|
||||
|
||||
<!-- 功能入口(节日印花 + 会员二维码) -->
|
||||
<view class="entry-row">
|
||||
<view class="entry-card" @click="onEntryClick({ key: 'stamps' })">
|
||||
<image class="entry-icon" src="/static/ic_stamp.png" mode="aspectFit" />
|
||||
<image class="entry-icon" :src="getEntryImage('stamp') ? resolveImageUrl(getEntryImage('stamp')) : '/static/ic_stamp.png'" mode="aspectFit" />
|
||||
<text class="entry-label">{{ t('home.stamps') }}</text>
|
||||
</view>
|
||||
<view class="entry-card" @click="onEntryClick({ key: 'qrcode' })">
|
||||
<image class="entry-icon" src="/static/ic_qrcode.png" mode="aspectFit" />
|
||||
<image class="entry-icon" :src="getEntryImage('qrcode') ? resolveImageUrl(getEntryImage('qrcode')) : '/static/ic_qrcode.png'" mode="aspectFit" />
|
||||
<text class="entry-label">{{ t('home.qrcode') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
|
@ -81,7 +84,7 @@
|
|||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<EmptyState v-if="coupons.length === 0" text="暂无可兑换优惠券" />
|
||||
<EmptyState v-if="coupons.length === 0" :text="t('common.emptyNoRedeemable')" />
|
||||
|
||||
<!-- 使用说明弹窗 -->
|
||||
<CouponGuidePopup :visible="showGuide" :content="guideContent" @close="showGuide = false" />
|
||||
|
|
@ -93,9 +96,22 @@
|
|||
<!-- 会员二维码弹窗 -->
|
||||
<QrcodePopup :visible="showQrcode" @close="showQrcode = false" />
|
||||
</view>
|
||||
|
||||
<!-- 自定义底部导航 -->
|
||||
<CustomTabBar current="/pages/index/index" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
onShow() {
|
||||
uni.hideTabBar({ animation: false })
|
||||
// 通知 setup 刷新数据
|
||||
uni.$emit('index:refresh')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
ref,
|
||||
|
|
@ -126,6 +142,7 @@
|
|||
import CouponGuidePopup from '../../components/CouponGuidePopup.vue'
|
||||
import QrcodePopup from '../../components/QrcodePopup.vue'
|
||||
import EmptyState from '../../components/EmptyState.vue'
|
||||
import CustomTabBar from '../../components/CustomTabBar.vue'
|
||||
|
||||
const {
|
||||
t
|
||||
|
|
@ -151,6 +168,12 @@
|
|||
const selectedCoupon = ref(null)
|
||||
const showQrcode = ref(false)
|
||||
|
||||
// 获取入口图片(根据type匹配)
|
||||
function getEntryImage(type) {
|
||||
const entry = entries.value.find(e => e.type === type)
|
||||
return entry?.imageUrl || ''
|
||||
}
|
||||
|
||||
// Banner点击导航
|
||||
function onBannerClick(banner) {
|
||||
navigateBanner(banner)
|
||||
|
|
@ -273,11 +296,17 @@
|
|||
} catch (e) {}
|
||||
}
|
||||
|
||||
// 首次加载
|
||||
onMounted(() => {
|
||||
loadBanners()
|
||||
loadEntries()
|
||||
loadCoupons()
|
||||
})
|
||||
|
||||
// tab切换回来时刷新
|
||||
uni.$on('index:refresh', () => {
|
||||
loadCoupons()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -305,6 +334,7 @@
|
|||
.home-page {
|
||||
min-height: 100vh;
|
||||
background-color: #f5f0e8;
|
||||
padding-bottom: 120rpx;
|
||||
}
|
||||
|
||||
/* Banner */
|
||||
|
|
@ -330,15 +360,24 @@
|
|||
margin: 24rpx 24rpx 0;
|
||||
background: linear-gradient(135deg, #4a5d4a, #3d4f3d);
|
||||
border-radius: 20rpx;
|
||||
padding: 30rpx 32rpx;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
overflow: hidden;
|
||||
min-height: 140rpx;
|
||||
}
|
||||
|
||||
.membership-entry-img {
|
||||
width: 100%;
|
||||
height: 140rpx;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
|
||||
.membership-entry-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 30rpx 32rpx;
|
||||
}
|
||||
|
||||
.membership-icon-wrap {
|
||||
|
|
|
|||
|
|
@ -1,60 +1,54 @@
|
|||
<template>
|
||||
<view class="membership-page">
|
||||
<!-- 会员宣传长图 -->
|
||||
<image
|
||||
v-if="bannerUrl"
|
||||
class="membership-banner"
|
||||
:src="bannerUrl"
|
||||
mode="widthFix"
|
||||
/>
|
||||
|
||||
<!-- 会员商品区域 -->
|
||||
<view class="products-section" v-if="products.length">
|
||||
<view
|
||||
v-for="product in products"
|
||||
:key="product.productId"
|
||||
class="product-card"
|
||||
>
|
||||
<view class="product-info">
|
||||
<text class="product-name">{{ product.name }}</text>
|
||||
<text class="product-price">¥{{ product.price }}</text>
|
||||
<!-- 自定义导航栏 -->
|
||||
<view class="custom-nav" :style="{ paddingTop: statusBarHeight + 'px' }">
|
||||
<view class="nav-inner">
|
||||
<view class="nav-back" @click="goBack">
|
||||
<image class="back-icon" src="/static/ic_back2.png" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="nav-title">{{ t('membership.title') }}</text>
|
||||
<view class="nav-placeholder" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 按钮状态逻辑 -->
|
||||
<!-- 可滚动内容区域 -->
|
||||
<view class="scroll-body" :style="{ paddingTop: (statusBarHeight + 44) + 'px', paddingBottom: '200rpx' }">
|
||||
<!-- 会员宣传长图 -->
|
||||
<image
|
||||
v-if="bannerUrl"
|
||||
class="membership-banner"
|
||||
:src="bannerUrl"
|
||||
mode="widthFix"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 底部悬浮按钮区域 -->
|
||||
<view class="fixed-bottom">
|
||||
<view v-for="product in products" :key="product.productId" class="bottom-btn-wrap">
|
||||
<template v-if="product.type === 'monthly'">
|
||||
<!-- 单月会员:已开通时隐藏 -->
|
||||
<button
|
||||
v-if="!isMember"
|
||||
class="buy-btn"
|
||||
<view
|
||||
v-if="!isMember || membershipType !== 'monthly'"
|
||||
class="bottom-btn monthly-btn"
|
||||
@click="onPurchase(product)"
|
||||
>
|
||||
{{ t('membership.joinBtn') }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="buy-btn disabled"
|
||||
disabled
|
||||
>
|
||||
{{ t('membership.joinedBtn') }}
|
||||
</button>
|
||||
<text class="bottom-btn-text">{{ product.name || t('membership.joinBtn') }}</text>
|
||||
</view>
|
||||
<view v-else class="bottom-btn disabled-btn">
|
||||
<text class="bottom-btn-text">{{ t('membership.joinedBtn') }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<template v-if="product.type === 'subscription'">
|
||||
<!-- 订阅会员 -->
|
||||
<button
|
||||
<view
|
||||
v-if="!isSubscribed"
|
||||
class="buy-btn subscribe-btn"
|
||||
class="bottom-btn subscribe-btn"
|
||||
@click="onSubscribe(product)"
|
||||
>
|
||||
{{ t('membership.subscribeBtn') }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="buy-btn disabled"
|
||||
disabled
|
||||
>
|
||||
{{ t('membership.subscribedBtn') }}
|
||||
</button>
|
||||
<text class="bottom-btn-text">{{ product.name || t('membership.subscribeBtn') }}</text>
|
||||
<text class="bottom-btn-sub">每月支付 长期有效</text>
|
||||
</view>
|
||||
<view v-else class="bottom-btn disabled-btn">
|
||||
<text class="bottom-btn-text">{{ t('membership.subscribedBtn') }}</text>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
|
|
@ -71,41 +65,37 @@ import { resolveImageUrl } from '../../utils/image.js'
|
|||
const { t } = useI18n()
|
||||
const membershipStore = useMembershipStore()
|
||||
|
||||
// 宣传Banner图URL
|
||||
// 状态栏高度
|
||||
const statusBarHeight = ref(0)
|
||||
const safeBottom = ref(0)
|
||||
try {
|
||||
const sysInfo = uni.getSystemInfoSync()
|
||||
statusBarHeight.value = sysInfo.statusBarHeight || 0
|
||||
safeBottom.value = sysInfo.safeAreaInsets?.bottom || 0
|
||||
} catch (e) {}
|
||||
|
||||
const bannerUrl = ref('')
|
||||
|
||||
// 商品列表
|
||||
const products = computed(() => membershipStore.products)
|
||||
const isMember = computed(() => !!membershipStore.membershipInfo?.isMember)
|
||||
const membershipType = computed(() => membershipStore.membershipInfo?.membershipType || '')
|
||||
const isSubscribed = computed(() => membershipType.value === 'subscription')
|
||||
|
||||
// 是否为会员
|
||||
const isMember = computed(() => {
|
||||
return !!membershipStore.membershipInfo?.isMember
|
||||
})
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
// 是否已订阅
|
||||
const isSubscribed = computed(() => {
|
||||
return !!membershipStore.subscriptionStatus?.isSubscribed
|
||||
})
|
||||
|
||||
/**
|
||||
* 购买单月会员
|
||||
*/
|
||||
async function onPurchase(product) {
|
||||
try {
|
||||
await membershipStore.purchase(product.productId, product.price)
|
||||
uni.showToast({ title: t('membership.joinedBtn'), icon: 'success' })
|
||||
} catch (e) {
|
||||
if (e.message === 'cancelled') return
|
||||
// 支付成功但后端确认失败
|
||||
if (e.message?.includes('purchase')) {
|
||||
uni.showToast({ title: t('membership.payConfirmPending'), icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅会员
|
||||
*/
|
||||
async function onSubscribe(product) {
|
||||
try {
|
||||
await membershipStore.subscribe(product.productId, product.price)
|
||||
|
|
@ -118,31 +108,17 @@ async function onSubscribe(product) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载页面数据
|
||||
*/
|
||||
async function loadData() {
|
||||
try {
|
||||
const res = await getMembershipBanner()
|
||||
bannerUrl.value = resolveImageUrl(res.data?.imageUrl || res.data || '')
|
||||
} catch (e) { /* 错误已统一处理 */ }
|
||||
|
||||
try {
|
||||
await membershipStore.fetchProducts()
|
||||
} catch (e) { /* 错误已统一处理 */ }
|
||||
|
||||
try {
|
||||
await membershipStore.fetchMembershipInfo()
|
||||
} catch (e) { /* 错误已统一处理 */ }
|
||||
|
||||
try {
|
||||
await membershipStore.fetchSubscriptionStatus()
|
||||
} catch (e) { /* 错误已统一处理 */ }
|
||||
} catch (e) {}
|
||||
try { await membershipStore.fetchProducts() } catch (e) {}
|
||||
try { await membershipStore.fetchMembershipInfo() } catch (e) {}
|
||||
try { await membershipStore.fetchSubscriptionStatus() } catch (e) {}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
onMounted(() => { loadData() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -151,59 +127,93 @@ onMounted(() => {
|
|||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
/* 自定义导航栏 */
|
||||
.custom-nav {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0;
|
||||
z-index: 100;
|
||||
background-color: #DBDBDB;
|
||||
}
|
||||
.nav-inner {
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16rpx;
|
||||
}
|
||||
.nav-back {
|
||||
width: 60rpx;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.back-icon {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
}
|
||||
.nav-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
.nav-placeholder {
|
||||
width: 60rpx;
|
||||
}
|
||||
|
||||
/* 宣传长图 */
|
||||
.membership-banner {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.products-section {
|
||||
padding: 30rpx 24rpx;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
/* 底部悬浮按钮 */
|
||||
.fixed-bottom {
|
||||
position: fixed;
|
||||
bottom: 0; left: 0; right: 0;
|
||||
z-index: 99;
|
||||
background-color: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20rpx 32rpx 48rpx;
|
||||
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.product-info {
|
||||
.bottom-btn-wrap {
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
.bottom-btn-wrap:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.bottom-btn {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
border-radius: 44rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 30rpx;
|
||||
color: #333;
|
||||
margin-bottom: 8rpx;
|
||||
.monthly-btn {
|
||||
background-color: #ff8c00;
|
||||
}
|
||||
|
||||
.product-price {
|
||||
font-size: 36rpx;
|
||||
color: #ff6600;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.buy-btn {
|
||||
min-width: 200rpx;
|
||||
height: 72rpx;
|
||||
line-height: 72rpx;
|
||||
text-align: center;
|
||||
background-color: #ff6600;
|
||||
color: #ffffff;
|
||||
border-radius: 36rpx;
|
||||
font-size: 28rpx;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.subscribe-btn {
|
||||
background-color: #6c5ce7;
|
||||
background-color: #ffffff;
|
||||
border: 2rpx solid #ff8c00;
|
||||
}
|
||||
|
||||
.buy-btn.disabled {
|
||||
.subscribe-btn .bottom-btn-text {
|
||||
color: #ff8c00;
|
||||
}
|
||||
.subscribe-btn .bottom-btn-sub {
|
||||
color: #999;
|
||||
font-size: 20rpx;
|
||||
margin-top: 2rpx;
|
||||
}
|
||||
.disabled-btn {
|
||||
background-color: #cccccc;
|
||||
color: #999999;
|
||||
}
|
||||
.bottom-btn-text {
|
||||
font-size: 30rpx;
|
||||
color: #ffffff;
|
||||
font-weight: 500;
|
||||
}
|
||||
.bottom-btn-sub {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,99 +1,111 @@
|
|||
<template>
|
||||
<view class="mine-page">
|
||||
<!-- 自定义头部 -->
|
||||
<view class="header" :style="{ paddingTop: statusBarHeight + 'px' }">
|
||||
<view class="header-inner">
|
||||
<text class="header-title">贩卖机</text>
|
||||
<!-- 固定标题栏 -->
|
||||
<view class="fixed-nav" :style="{ paddingTop: statusBarHeight + 'px' }">
|
||||
<view class="nav-inner">
|
||||
<text class="nav-title">贩卖机</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 用户信息区域 -->
|
||||
<view class="user-section" :style="{ marginTop: (statusBarHeight + 44) + 'px' }">
|
||||
<!-- 未登录状态 -->
|
||||
<view v-if="!userStore.isLoggedIn" class="user-row" @click="goLogin">
|
||||
<image class="avatar" src="/static/logo.png" mode="aspectFill" />
|
||||
<text class="login-btn-text">注册/登录 ></text>
|
||||
</view>
|
||||
<!-- 已登录状态 -->
|
||||
<view v-else class="user-row">
|
||||
<image class="avatar" src="/static/logo.png" mode="aspectFill" />
|
||||
<view class="user-info">
|
||||
<view class="name-row">
|
||||
<text class="nickname">{{ userStore.userInfo?.nickname || '' }}</text>
|
||||
<view v-if="userStore.userInfo?.isMember" class="vip-tag">
|
||||
<image class="vip-icon" src="/static/ic_vip.png" mode="aspectFit" />
|
||||
<text class="vip-text">会员</text>
|
||||
<!-- 可滚动内容 -->
|
||||
<view class="scroll-body" :style="{ paddingTop: (statusBarHeight + 44) + 'px' }">
|
||||
<!-- 灰色头部背景区域 -->
|
||||
<view class="header-bg">
|
||||
<!-- 用户信息 -->
|
||||
<view class="user-section">
|
||||
<!-- 未登录 -->
|
||||
<view v-if="!userStore.isLoggedIn" class="user-row" @click="goLogin">
|
||||
<view class="avatar-wrap">
|
||||
<image class="avatar-icon" src="/static/ic_me.png" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="login-btn-text">注册/登录 ></text>
|
||||
</view>
|
||||
<!-- 已登录 -->
|
||||
<view v-else class="user-row">
|
||||
<view class="avatar-wrap">
|
||||
<image class="avatar-icon" src="/static/ic_me.png" mode="aspectFit" />
|
||||
</view>
|
||||
<view class="user-info">
|
||||
<view class="name-row">
|
||||
<text class="nickname">{{ userStore.userInfo?.nickname || '' }}</text>
|
||||
<view v-if="userStore.userInfo?.isMember" class="vip-tag">
|
||||
<image class="vip-icon" src="/static/ic_vip.png" mode="aspectFit" />
|
||||
<text class="vip-text">会员</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="uid">UID:{{ userStore.userInfo?.uid || '' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="uid">UID:{{ userStore.userInfo?.uid || '' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 积分区域 -->
|
||||
<view v-if="userStore.isLoggedIn" class="points-card" @click="goPoints">
|
||||
<view class="points-left">
|
||||
<view class="points-icon-wrap">
|
||||
<image class="points-icon" src="/static/ic_integral.png" mode="aspectFit" />
|
||||
<!-- 内容区域 -->
|
||||
<view class="content-area">
|
||||
<!-- 积分卡片(上浮压在灰色头部上) -->
|
||||
<view class="points-card" @click="goPoints">
|
||||
<view class="points-left">
|
||||
<view class="points-icon-wrap">
|
||||
<image class="points-icon" src="/static/ic_integral.png" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="points-label">{{ t('mine.points') }}</text>
|
||||
</view>
|
||||
<text class="points-label">{{ t('mine.points') }}</text>
|
||||
<text class="points-value">{{ balance }}</text>
|
||||
</view>
|
||||
<text class="points-value">{{ balance }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 功能入口(赠送积分 + 我的优惠券) -->
|
||||
<view v-if="userStore.isLoggedIn" class="func-row">
|
||||
<view class="func-card" @click="showGiftPopup = true">
|
||||
<view class="func-icon-wrap gift-bg">
|
||||
<image class="func-icon" src="/static/ic_reward_points.png" mode="aspectFit" />
|
||||
<!-- 功能入口 -->
|
||||
<view class="func-row">
|
||||
<view class="func-card" @click="onFuncClick('gift')">
|
||||
<view class="func-icon-wrap">
|
||||
<image class="func-icon" src="/static/ic_reward_points.png" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="func-label">{{ t('mine.giftPoints') }}</text>
|
||||
</view>
|
||||
<text class="func-label">{{ t('mine.giftPoints') }}</text>
|
||||
</view>
|
||||
<view class="func-card" @click="goCoupons">
|
||||
<view class="func-icon-wrap coupon-bg">
|
||||
<image class="func-icon" src="/static/ic_my_coupon.png" mode="aspectFit" />
|
||||
<view class="func-card" @click="onFuncClick('coupons')">
|
||||
<view class="func-icon-wrap">
|
||||
<image class="func-icon" src="/static/ic_my_coupon.png" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="func-label">{{ t('mine.myCoupons') }}</text>
|
||||
</view>
|
||||
<text class="func-label">{{ t('mine.myCoupons') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 菜单列表 -->
|
||||
<view class="menu-card">
|
||||
<view class="menu-item" @click="showLangPicker = true">
|
||||
<view class="menu-icon-wrap">
|
||||
<image class="menu-icon" src="/static/ic_language.png" mode="aspectFit" />
|
||||
<!-- 菜单列表 -->
|
||||
<view class="menu-card">
|
||||
<view class="menu-item" @click="showLangPicker = true">
|
||||
<view class="menu-icon-wrap">
|
||||
<image class="menu-icon" src="/static/ic_language.png" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="menu-text">{{ t('mine.switchLang') }}</text>
|
||||
<text class="menu-extra">{{ currentLangLabel }}</text>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
<text class="menu-text">{{ t('mine.switchLang') }}</text>
|
||||
<text class="menu-extra">{{ currentLangLabel }}</text>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
<view class="menu-item" @click="goAgreement">
|
||||
<view class="menu-icon-wrap">
|
||||
<image class="menu-icon" src="/static/ic_agreement.png" mode="aspectFit" />
|
||||
<view class="menu-item" @click="goAgreement">
|
||||
<view class="menu-icon-wrap">
|
||||
<image class="menu-icon" src="/static/ic_agreement.png" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="menu-text">{{ t('mine.userAgreement') }}</text>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
<text class="menu-text">{{ t('mine.userAgreement') }}</text>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
<view class="menu-item" @click="goPrivacy">
|
||||
<view class="menu-icon-wrap">
|
||||
<image class="menu-icon" src="/static/ic_agreement2.png" mode="aspectFit" />
|
||||
<view class="menu-item" @click="goPrivacy">
|
||||
<view class="menu-icon-wrap">
|
||||
<image class="menu-icon" src="/static/ic_agreement2.png" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="menu-text">{{ t('mine.privacyPolicy') }}</text>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
<text class="menu-text">{{ t('mine.privacyPolicy') }}</text>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
<view class="menu-item" @click="goAbout">
|
||||
<view class="menu-icon-wrap">
|
||||
<image class="menu-icon" src="/static/ic_about.png" mode="aspectFit" />
|
||||
<view class="menu-item" @click="goAbout">
|
||||
<view class="menu-icon-wrap">
|
||||
<image class="menu-icon" src="/static/ic_about.png" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="menu-text">{{ t('mine.about') }}</text>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
<text class="menu-text">{{ t('mine.about') }}</text>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
<view v-if="userStore.isLoggedIn" class="menu-item" @click="showLogoutConfirm = true">
|
||||
<view class="menu-icon-wrap">
|
||||
<image class="menu-icon" src="/static/ic_exit.png" mode="aspectFit" />
|
||||
<view v-if="userStore.isLoggedIn" class="menu-item" @click="showLogoutConfirm = true">
|
||||
<view class="menu-icon-wrap">
|
||||
<image class="menu-icon" src="/static/ic_exit.png" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="menu-text">{{ t('mine.logout') }}</text>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
<text class="menu-text">{{ t('mine.logout') }}</text>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
|
|
@ -117,9 +129,22 @@
|
|||
|
||||
<!-- 语言选择器弹窗 -->
|
||||
<LanguagePicker :visible="showLangPicker" @close="showLangPicker = false" />
|
||||
</view>
|
||||
|
||||
<!-- 自定义底部导航 -->
|
||||
<CustomTabBar current="/pages/mine/mine" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
onShow() {
|
||||
uni.hideTabBar({ animation: false })
|
||||
uni.$emit('mine:refresh')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
|
@ -128,6 +153,7 @@ import { getBalance } from '../../api/points.js'
|
|||
import { getStorage, LOCALE_KEY } from '../../utils/storage.js'
|
||||
import GiftPointsPopup from '../../components/GiftPointsPopup.vue'
|
||||
import LanguagePicker from '../../components/LanguagePicker.vue'
|
||||
import CustomTabBar from '../../components/CustomTabBar.vue'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
|
|
@ -139,21 +165,16 @@ try {
|
|||
statusBarHeight.value = sysInfo.statusBarHeight || 0
|
||||
} catch (e) {}
|
||||
|
||||
// 积分余额
|
||||
const balance = ref(0)
|
||||
|
||||
// 弹窗状态
|
||||
const showGiftPopup = ref(false)
|
||||
const showLangPicker = ref(false)
|
||||
const showLogoutConfirm = ref(false)
|
||||
|
||||
// 当前语言标签
|
||||
const currentLangLabel = computed(() => {
|
||||
const map = { 'zh-CN': '简体中文', 'zh-TW': '繁體中文', 'en': 'English' }
|
||||
return map[locale.value] || '简体中文'
|
||||
})
|
||||
|
||||
// 加载积分余额
|
||||
async function loadBalance() {
|
||||
if (!userStore.isLoggedIn) return
|
||||
try {
|
||||
|
|
@ -162,18 +183,28 @@ async function loadBalance() {
|
|||
} catch (e) {}
|
||||
}
|
||||
|
||||
// 退出登录
|
||||
// 功能入口点击(未登录跳登录)
|
||||
function onFuncClick(type) {
|
||||
if (!userStore.isLoggedIn) {
|
||||
uni.navigateTo({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
if (type === 'gift') showGiftPopup.value = true
|
||||
else if (type === 'coupons') goCoupons()
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
showLogoutConfirm.value = false
|
||||
await userStore.logout()
|
||||
balance.value = 0
|
||||
// 清除所有页面栈,返回首页
|
||||
uni.reLaunch({ url: '/pages/index/index' })
|
||||
}
|
||||
|
||||
// 页面导航
|
||||
function goLogin() { uni.navigateTo({ url: '/pages/login/login' }) }
|
||||
function goPoints() { uni.navigateTo({ url: '/pages/points/points' }) }
|
||||
function goPoints() {
|
||||
if (!userStore.isLoggedIn) { uni.navigateTo({ url: '/pages/login/login' }); return }
|
||||
uni.navigateTo({ url: '/pages/points/points' })
|
||||
}
|
||||
function goCoupons() { uni.navigateTo({ url: '/pages/coupons/coupons' }) }
|
||||
function goAgreement() { uni.navigateTo({ url: '/pages/agreement/agreement' }) }
|
||||
function goPrivacy() { uni.navigateTo({ url: '/pages/privacy/privacy' }) }
|
||||
|
|
@ -185,47 +216,73 @@ onMounted(() => {
|
|||
loadBalance()
|
||||
}
|
||||
})
|
||||
|
||||
// tab切换回来时刷新
|
||||
uni.$on('mine:refresh', () => {
|
||||
if (userStore.isLoggedIn) {
|
||||
userStore.fetchUserInfo()
|
||||
loadBalance()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mine-page {
|
||||
min-height: 100vh;
|
||||
background-color: #f5f0e8;
|
||||
background-color: #f0f0f0;
|
||||
padding-bottom: 120rpx;
|
||||
}
|
||||
|
||||
/* 自定义头部 */
|
||||
.header {
|
||||
/* 固定标题栏 */
|
||||
.fixed-nav {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0;
|
||||
z-index: 100;
|
||||
background-color: #f5f0e8;
|
||||
z-index: 200;
|
||||
background-color: #DBDBDB;
|
||||
}
|
||||
.header-inner {
|
||||
.nav-inner {
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.header-title {
|
||||
.nav-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 灰色头部背景(跟随滚动) */
|
||||
.header-bg {
|
||||
background-color: #DBDBDB;
|
||||
border-bottom-left-radius: 60rpx;
|
||||
border-bottom-right-radius: 60rpx;
|
||||
padding: 16rpx 0 100rpx;
|
||||
}
|
||||
|
||||
/* 用户信息 */
|
||||
.user-section {
|
||||
padding: 30rpx 30rpx 0;
|
||||
padding: 16rpx 32rpx 0;
|
||||
}
|
||||
.user-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.avatar {
|
||||
.avatar-wrap {
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
border-radius: 50rpx;
|
||||
border-radius: 50%;
|
||||
border: 2rpx solid #c9a96e;
|
||||
background-color: rgba(201, 169, 110, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 20rpx;
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
.avatar-icon {
|
||||
width: 52rpx;
|
||||
height: 52rpx;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.login-btn-text {
|
||||
font-size: 32rpx;
|
||||
|
|
@ -264,7 +321,15 @@ onMounted(() => {
|
|||
}
|
||||
.uid {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 内容区域 */
|
||||
.content-area {
|
||||
padding: 0 24rpx;
|
||||
margin-top: -50rpx;
|
||||
position: relative;
|
||||
z-index: 101;
|
||||
}
|
||||
|
||||
/* 积分卡片 */
|
||||
|
|
@ -273,9 +338,12 @@ onMounted(() => {
|
|||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background-color: #ffffff;
|
||||
margin: 24rpx 24rpx 0;
|
||||
padding: 28rpx 32rpx;
|
||||
border-radius: 20rpx;
|
||||
margin-bottom: 24rpx;
|
||||
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.08);
|
||||
position: relative;
|
||||
z-index: 101;
|
||||
}
|
||||
.points-left {
|
||||
display: flex;
|
||||
|
|
@ -305,11 +373,11 @@ onMounted(() => {
|
|||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 功能入口行 */
|
||||
/* 功能入口 */
|
||||
.func-row {
|
||||
display: flex;
|
||||
padding: 24rpx 24rpx 0;
|
||||
gap: 24rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
.func-card {
|
||||
flex: 1;
|
||||
|
|
@ -324,17 +392,12 @@ onMounted(() => {
|
|||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 18rpx;
|
||||
background-color: #f5f0e8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
.gift-bg {
|
||||
background-color: #f5f0e8;
|
||||
}
|
||||
.coupon-bg {
|
||||
background-color: #f5f0e8;
|
||||
}
|
||||
.func-icon {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
|
|
@ -347,7 +410,6 @@ onMounted(() => {
|
|||
/* 菜单列表 */
|
||||
.menu-card {
|
||||
background-color: #ffffff;
|
||||
margin: 24rpx 24rpx 0;
|
||||
border-radius: 20rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
@ -389,7 +451,7 @@ onMounted(() => {
|
|||
color: #ccc;
|
||||
}
|
||||
|
||||
/* 弹窗样式 */
|
||||
/* 弹窗 */
|
||||
.popup-mask {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@
|
|||
<view v-if="loading" class="loading-tip">
|
||||
<text>{{ t('common.loading') }}</text>
|
||||
</view>
|
||||
<EmptyState v-if="!loading && records.length === 0" text="暂无积分记录" />
|
||||
<EmptyState v-if="!loading && records.length === 0" :text="t('common.emptyNoPoints')" />
|
||||
<view v-if="noMore && records.length > 0" class="loading-tip">
|
||||
<text>{{ t('common.noMore') }}</text>
|
||||
</view>
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@
|
|||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<EmptyState v-if="stampCoupons.length === 0" text="暂无印花优惠券" />
|
||||
<EmptyState v-if="stampCoupons.length === 0" :text="t('common.emptyNoStamps')" />
|
||||
|
||||
<!-- 使用说明弹窗 -->
|
||||
<CouponGuidePopup :visible="showGuide" :content="guideContent" @close="showGuide = false" />
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## 基本信息
|
||||
|
||||
- 基础地址:`https://your-domain.com/api/vending`
|
||||
- 基础地址:`https://api.tty.shhmkjgs.cn/api/vending`
|
||||
- 数据格式:JSON
|
||||
- 字符编码:UTF-8
|
||||
- 所有请求需在 Header 中携带 `X-Machine-Id`(贩卖机唯一标识)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user