bug修复
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
18631081161 2026-04-21 04:08:37 +08:00
parent 843e22ea1d
commit fa0cf7e41c
38 changed files with 1368 additions and 414 deletions

View File

@ -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 } })
}

View File

@ -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: '用户协议' },

View File

@ -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

View File

@ -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'">

View File

@ -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>

View File

@ -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 });

View File

@ -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>

View File

@ -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();

View File

@ -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 } });

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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

View 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;
}
}

View File

@ -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; }

View File

@ -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>
/// 自动更新过期优惠券状态

View File

@ -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 }

View 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
}
}
}

View File

@ -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");
}
}
}

View File

@ -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)");

View File

@ -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));
}
}

View File

@ -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 })
}

View File

@ -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和语言请求头统一处理响应和错误

View 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>

View File

@ -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: {

View File

@ -9,7 +9,11 @@ export default {
networkError: '网络连接失败,请检查网络设置',
timeout: '网络连接超时,请重试',
serverError: '服务器繁忙,请稍后重试',
retry: '重试'
retry: '重试',
emptyNoCoupons: '暂无优惠券',
emptyNoRedeemable: '暂无可兑换优惠券',
emptyNoPoints: '暂无积分记录',
emptyNoStamps: '暂无印花优惠券'
},
// TabBar
tabBar: {

View File

@ -9,7 +9,11 @@ export default {
networkError: '網路連線失敗,請檢查網路設定',
timeout: '網路連線逾時,請重試',
serverError: '伺服器忙碌,請稍後重試',
retry: '重試'
retry: '重試',
emptyNoCoupons: '暫無優惠券',
emptyNoRedeemable: '暫無可兌換優惠券',
emptyNoPoints: '暫無積分記錄',
emptyNoStamps: '暫無印花優惠券'
},
// TabBar
tabBar: {

View File

@ -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",

View File

@ -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;
}

View File

@ -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>

View File

@ -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 {

View File

@ -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()
// BannerURL
//
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>

View File

@ -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;

View File

@ -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>

View File

@ -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" />

View File

@ -2,7 +2,7 @@
## 基本信息
- 基础地址:`https://your-domain.com/api/vending`
- 基础地址:`https://api.tty.shhmkjgs.cn/api/vending`
- 数据格式JSON
- 字符编码UTF-8
- 所有请求需在 Header 中携带 `X-Machine-Id`(贩卖机唯一标识)