1
This commit is contained in:
commit
7a15923221
1
.kiro/specs/vending-machine-app/.config.kiro
Normal file
1
.kiro/specs/vending-machine-app/.config.kiro
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"generationMode": "requirements-first"}
|
||||
896
.kiro/specs/vending-machine-app/design.md
Normal file
896
.kiro/specs/vending-machine-app/design.md
Normal file
|
|
@ -0,0 +1,896 @@
|
|||
# 设计文档
|
||||
|
||||
## 概述
|
||||
|
||||
本设计文档描述贩卖机配套移动端 App 及管理后台的技术架构和实现方案。App 采用跨平台框架 UniApp(基于 Vue 3)开发,一套代码同时编译为 Android 和 iOS 原生应用。后端采用 .NET 10 + ASP.NET Core Web API,数据库使用 SQL Server。管理后台采用 EleAdmin + Vue 3 前端框架。
|
||||
|
||||
核心设计目标:
|
||||
- 跨平台:一套代码支持 Android 和 iOS,降低开发和维护成本
|
||||
- 可配置:首页 Banner、优惠券、会员价格等内容均由管理后台动态配置
|
||||
- 安全性:会员二维码动态生成并设有时效限制,用户锁定机制防止并发操作
|
||||
- 可扩展:模块化设计,支持后续功能扩展
|
||||
- 管理后台:基于 EleAdmin + Vue 提供完整的内容管理、用户管理、数据配置能力
|
||||
|
||||
## 架构
|
||||
|
||||
### 整体架构
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph 客户端
|
||||
A[UniApp 移动端<br/>Vue 3 + TypeScript]
|
||||
AA[管理后台<br/>EleAdmin + Vue 3]
|
||||
end
|
||||
|
||||
subgraph 后端服务 .NET 10
|
||||
B[ASP.NET Core Web API]
|
||||
B1[用户模块]
|
||||
B2[会员模块]
|
||||
B3[积分模块]
|
||||
B4[优惠券模块]
|
||||
B5[支付模块]
|
||||
B6[内容配置模块]
|
||||
B7[贩卖机对接模块]
|
||||
B8[管理后台 API]
|
||||
end
|
||||
|
||||
subgraph 外部服务
|
||||
J[短信平台]
|
||||
K[Google Pay]
|
||||
L[Apple Pay]
|
||||
M[贩卖机硬件]
|
||||
end
|
||||
|
||||
subgraph 数据存储
|
||||
N[(SQL Server)]
|
||||
O[(Redis 缓存)]
|
||||
end
|
||||
|
||||
A --> B
|
||||
AA --> B8
|
||||
B --> B1 & B2 & B3 & B4 & B5 & B6 & B7
|
||||
B1 --> J
|
||||
B5 --> K & L
|
||||
B7 --> M
|
||||
B1 & B2 & B3 & B4 & B5 & B6 & B7 & B8 --> N & O
|
||||
```
|
||||
|
||||
### 客户端架构
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph UniApp 移动端
|
||||
A[页面层 Pages]
|
||||
B[组件层 Components]
|
||||
C[状态管理 Pinia Store]
|
||||
D[API 层 Services]
|
||||
E[工具层 Utils]
|
||||
F[国际化 i18n]
|
||||
end
|
||||
|
||||
subgraph 管理后台 EleAdmin + Vue 3
|
||||
G[页面视图 Views]
|
||||
H[EleAdmin 组件]
|
||||
I[API 层]
|
||||
J[路由与权限]
|
||||
end
|
||||
|
||||
A --> B & C
|
||||
B --> C
|
||||
C --> D
|
||||
D --> E
|
||||
A & B --> F
|
||||
|
||||
G --> H & I
|
||||
I --> J
|
||||
```
|
||||
|
||||
采用分层架构:
|
||||
- **页面层**:各业务页面(首页、会员页、我的页等)
|
||||
- **组件层**:可复用 UI 组件(弹窗、优惠券卡片、二维码等)
|
||||
- **状态管理**:Pinia 管理全局状态(用户信息、会员状态、积分等)
|
||||
- **API 层**:封装所有后端接口调用
|
||||
- **工具层**:通用工具函数(支付调用、二维码生成等)
|
||||
- **国际化**:vue-i18n 管理多语言文案
|
||||
|
||||
## 组件与接口
|
||||
|
||||
### 页面组件
|
||||
|
||||
| 页面 | 路径 | 职责 |
|
||||
|------|------|------|
|
||||
| 首页 | `/pages/home/index` | Banner 展示、功能入口、优惠券列表 |
|
||||
| 会员页 | `/pages/membership/index` | 会员介绍、购买单月/订阅会员 |
|
||||
| 节日印花页 | `/pages/stamp/index` | 印花优惠券展示与兑换 |
|
||||
| 登录页 | `/pages/login/index` | 手机号验证码登录 |
|
||||
| 我的页 | `/pages/profile/index` | 个人中心、功能入口 |
|
||||
| 我的积分页 | `/pages/points/index` | 积分获取/使用记录 |
|
||||
| 我的优惠券页 | `/pages/coupons/index` | 优惠券列表(可使用/已使用/已过期) |
|
||||
| 用户协议页 | `/pages/agreement/index` | 用户协议内容展示 |
|
||||
| 隐私政策页 | `/pages/privacy/index` | 隐私政策内容展示 |
|
||||
| 关于页 | `/pages/about/index` | LOGO、版本号、注销账号 |
|
||||
|
||||
### 管理后台页面(EleAdmin + Vue 3)
|
||||
|
||||
| 页面 | 职责 |
|
||||
|------|------|
|
||||
| 仪表盘 | 用户统计、会员统计、积分统计概览 |
|
||||
| Banner 管理 | 首页 Banner 的增删改查、排序、多语言图片配置 |
|
||||
| 入口图片管理 | 首页各功能入口图片的配置 |
|
||||
| 会员管理 | 会员商品配置、价格设置、宣传图配置 |
|
||||
| 优惠券管理 | 优惠券的创建、编辑、上下架、类型和兑换条件配置 |
|
||||
| 节日印花管理 | 印花优惠券的创建、编辑、Banner 图配置 |
|
||||
| 积分配置 | 金额与积分转换比配置 |
|
||||
| 用户管理 | 用户列表查看、会员状态查看 |
|
||||
| 内容管理 | 用户协议、隐私政策、优惠券使用说明的多语言编辑 |
|
||||
|
||||
### 弹窗组件
|
||||
|
||||
| 组件 | 职责 |
|
||||
|------|------|
|
||||
| `QrcodePopup` | 会员二维码弹窗,展示动态二维码 |
|
||||
| `CouponGuidePopup` | 优惠券使用说明弹窗 |
|
||||
| `RedeemConfirmPopup` | 确定兑换优惠券弹窗 |
|
||||
| `GiftPointsPopup` | 赠送积分弹窗 |
|
||||
| `LanguageSelectorPopup` | 多语言选择列表弹窗 |
|
||||
| `ConfirmPopup` | 通用确认弹窗(退出登录、注销账号等) |
|
||||
|
||||
### 核心 API 接口(.NET 10 ASP.NET Core Web API)
|
||||
|
||||
```csharp
|
||||
// === 移动端 API Controllers ===
|
||||
|
||||
// 用户控制器
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class UserController : ControllerBase
|
||||
{
|
||||
[HttpPost("send-code")]
|
||||
Task<IActionResult> SendVerificationCode(SendCodeRequest request);
|
||||
// request: { Phone, AreaCode }
|
||||
|
||||
[HttpPost("login")]
|
||||
Task<ActionResult<LoginResponse>> Login(LoginRequest request);
|
||||
// request: { Phone, AreaCode, Code }
|
||||
|
||||
[HttpGet("info")]
|
||||
Task<ActionResult<UserInfoResponse>> GetUserInfo();
|
||||
|
||||
[HttpPost("logout")]
|
||||
Task<IActionResult> Logout();
|
||||
|
||||
[HttpDelete("account")]
|
||||
Task<IActionResult> DeleteAccount();
|
||||
}
|
||||
|
||||
// 会员控制器
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class MembershipController : ControllerBase
|
||||
{
|
||||
[HttpGet("info")]
|
||||
Task<ActionResult<MembershipInfoResponse>> GetMembershipInfo();
|
||||
|
||||
[HttpGet("products")]
|
||||
Task<ActionResult<List<MembershipProductDto>>> GetProducts();
|
||||
|
||||
[HttpPost("purchase")]
|
||||
Task<ActionResult<PurchaseResult>> Purchase(PurchaseRequest request);
|
||||
// request: { ProductId, Receipt, Platform }
|
||||
|
||||
[HttpPost("subscribe")]
|
||||
Task<ActionResult<PurchaseResult>> Subscribe(SubscribeRequest request);
|
||||
|
||||
[HttpGet("subscription-status")]
|
||||
Task<ActionResult<SubscriptionStatusDto>> GetSubscriptionStatus();
|
||||
}
|
||||
|
||||
// 积分控制器
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class PointsController : ControllerBase
|
||||
{
|
||||
[HttpGet("balance")]
|
||||
Task<ActionResult<int>> GetBalance();
|
||||
|
||||
[HttpGet("earn-records")]
|
||||
Task<ActionResult<PagedResult<PointRecordDto>>> GetEarnRecords(int page, int size);
|
||||
|
||||
[HttpGet("spend-records")]
|
||||
Task<ActionResult<PagedResult<PointRecordDto>>> GetSpendRecords(int page, int size);
|
||||
|
||||
[HttpPost("gift")]
|
||||
Task<IActionResult> GiftPoints(GiftPointsRequest request);
|
||||
// request: { TargetUid, Amount }
|
||||
}
|
||||
|
||||
// 优惠券控制器
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class CouponController : ControllerBase
|
||||
{
|
||||
[HttpGet("redeemable")]
|
||||
Task<ActionResult<List<RedeemableCouponDto>>> GetRedeemableCoupons();
|
||||
|
||||
[HttpPost("redeem/{couponId}")]
|
||||
Task<ActionResult<RedeemResult>> RedeemCoupon(string couponId);
|
||||
|
||||
[HttpGet("my")]
|
||||
Task<ActionResult<List<UserCouponDto>>> GetMyCoupons(CouponStatus? status);
|
||||
|
||||
[HttpGet("stamps")]
|
||||
Task<ActionResult<List<StampCouponDto>>> GetStampCoupons();
|
||||
|
||||
[HttpPost("stamps/redeem/{stampCouponId}")]
|
||||
Task<ActionResult<RedeemResult>> RedeemStampCoupon(string stampCouponId);
|
||||
}
|
||||
|
||||
// 内容配置控制器
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class ContentController : ControllerBase
|
||||
{
|
||||
[HttpGet("banners")]
|
||||
Task<ActionResult<List<BannerDto>>> GetHomeBanners();
|
||||
|
||||
[HttpGet("entries")]
|
||||
Task<ActionResult<List<HomeEntryDto>>> GetHomeEntries();
|
||||
|
||||
[HttpGet("coupon-guide")]
|
||||
Task<ActionResult<string>> GetCouponGuide(string lang);
|
||||
|
||||
[HttpGet("membership-banner")]
|
||||
Task<ActionResult<string>> GetMembershipBanner(string lang);
|
||||
|
||||
[HttpGet("stamp-banner")]
|
||||
Task<ActionResult<string>> GetStampBanner(string lang);
|
||||
|
||||
[HttpGet("agreement")]
|
||||
Task<ActionResult<string>> GetAgreement(string lang);
|
||||
|
||||
[HttpGet("privacy-policy")]
|
||||
Task<ActionResult<string>> GetPrivacyPolicy(string lang);
|
||||
}
|
||||
|
||||
// 贩卖机对接控制器(供贩卖机调用)
|
||||
[ApiController]
|
||||
[Route("api/vending")]
|
||||
public class VendingMachineController : ControllerBase
|
||||
{
|
||||
[HttpPost("user-info")]
|
||||
Task<ActionResult<VendingUserInfoDto>> GetUserByQrcode(QrcodeRequest request);
|
||||
// request: { QrcodeToken }
|
||||
|
||||
[HttpPost("payment-callback")]
|
||||
Task<IActionResult> ReportPayment(VendingPaymentPayload payload);
|
||||
}
|
||||
|
||||
// === 管理后台 API Controllers ===
|
||||
|
||||
[ApiController]
|
||||
[Route("api/admin/[controller]")]
|
||||
public class AdminBannerController : ControllerBase
|
||||
{
|
||||
[HttpGet] Task<ActionResult<List<BannerDto>>> GetAll();
|
||||
[HttpPost] Task<ActionResult<BannerDto>> Create(CreateBannerRequest request);
|
||||
[HttpPut("{id}")] Task<IActionResult> Update(string id, UpdateBannerRequest request);
|
||||
[HttpDelete("{id}")] Task<IActionResult> Delete(string id);
|
||||
[HttpPut("sort")] Task<IActionResult> UpdateSortOrder(List<SortOrderItem> items);
|
||||
}
|
||||
|
||||
[ApiController]
|
||||
[Route("api/admin/[controller]")]
|
||||
public class AdminCouponController : ControllerBase
|
||||
{
|
||||
[HttpGet] Task<ActionResult<PagedResult<CouponDto>>> GetAll(int page, int size);
|
||||
[HttpPost] Task<ActionResult<CouponDto>> Create(CreateCouponRequest request);
|
||||
[HttpPut("{id}")] Task<IActionResult> Update(string id, UpdateCouponRequest request);
|
||||
[HttpPut("{id}/toggle")] Task<IActionResult> ToggleActive(string id);
|
||||
}
|
||||
|
||||
[ApiController]
|
||||
[Route("api/admin/[controller]")]
|
||||
public class AdminContentController : ControllerBase
|
||||
{
|
||||
[HttpGet("{key}")] Task<ActionResult<ContentDto>> GetContent(string key);
|
||||
[HttpPut("{key}")] Task<IActionResult> UpdateContent(string key, UpdateContentRequest request);
|
||||
// key: agreement, privacy-policy, coupon-guide, membership-banner, stamp-banner
|
||||
}
|
||||
|
||||
[ApiController]
|
||||
[Route("api/admin/[controller]")]
|
||||
public class AdminPointsConfigController : ControllerBase
|
||||
{
|
||||
[HttpGet] Task<ActionResult<PointsConfigDto>> GetConfig();
|
||||
[HttpPut] Task<IActionResult> UpdateConfig(UpdatePointsConfigRequest request);
|
||||
}
|
||||
|
||||
[ApiController]
|
||||
[Route("api/admin/[controller]")]
|
||||
public class AdminMembershipController : ControllerBase
|
||||
{
|
||||
[HttpGet("products")] Task<ActionResult<List<MembershipProductDto>>> GetProducts();
|
||||
[HttpPut("products/{id}")] Task<IActionResult> UpdateProduct(string id, UpdateProductRequest request);
|
||||
}
|
||||
|
||||
[ApiController]
|
||||
[Route("api/admin/[controller]")]
|
||||
public class AdminUserController : ControllerBase
|
||||
{
|
||||
[HttpGet] Task<ActionResult<PagedResult<UserDto>>> GetUsers(int page, int size, string? search);
|
||||
[HttpGet("{uid}")] Task<ActionResult<UserDetailDto>> GetUserDetail(string uid);
|
||||
}
|
||||
```
|
||||
|
||||
### 支付工具接口(客户端 TypeScript)
|
||||
|
||||
```typescript
|
||||
// UniApp 客户端支付封装
|
||||
interface PaymentService {
|
||||
// 根据平台自动选择 Google Pay 或 Apple Pay
|
||||
initPayment(productId: string): Promise<PaymentResult>
|
||||
// 验证支付凭证(调用后端接口)
|
||||
verifyReceipt(receipt: string): Promise<VerifyResult>
|
||||
// 检查订阅状态
|
||||
checkSubscription(): Promise<SubscriptionStatus>
|
||||
}
|
||||
```
|
||||
|
||||
## 数据模型
|
||||
|
||||
### 后端实体模型(.NET 10 + Entity Framework Core)
|
||||
|
||||
```csharp
|
||||
// 用户实体
|
||||
public class User
|
||||
{
|
||||
public string Uid { get; set; } = Guid.NewGuid().ToString("N")[..12];
|
||||
public string Phone { get; set; } = string.Empty;
|
||||
public string AreaCode { get; set; } = string.Empty;
|
||||
public string Nickname { get; set; } = string.Empty; // 默认: "用户" + 6位随机数字
|
||||
public bool IsMember { get; set; }
|
||||
public MembershipType MembershipType { get; set; } = MembershipType.None;
|
||||
public DateTime? MembershipExpireAt { get; set; }
|
||||
public int PointsBalance { get; set; }
|
||||
public DateTime? PointsExpireAt { get; set; } // 会员到期后3个月
|
||||
public string Language { get; set; } = "zh-CN";
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public bool IsDeleted { get; set; } // 软删除
|
||||
}
|
||||
|
||||
public enum MembershipType { None, Monthly, Subscription }
|
||||
|
||||
// 会员商品
|
||||
public class MembershipProduct
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public MembershipType Type { get; set; }
|
||||
public decimal Price { get; set; }
|
||||
public string Currency { get; set; } = "TWD";
|
||||
public int DurationDays { get; set; }
|
||||
public string GoogleProductId { get; set; } = string.Empty;
|
||||
public string AppleProductId { get; set; } = string.Empty;
|
||||
public string DescriptionZhCn { get; set; } = string.Empty;
|
||||
public string DescriptionZhTw { get; set; } = string.Empty;
|
||||
public string DescriptionEn { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// 积分记录
|
||||
public class PointRecord
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString();
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
public PointRecordType Type { get; set; }
|
||||
public int Amount { get; set; }
|
||||
public string Source { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public enum PointRecordType { Earn, Spend }
|
||||
|
||||
// 积分配置
|
||||
public class PointsConfig
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public decimal ConversionRate { get; set; } = 1m; // 1元=1积分
|
||||
}
|
||||
|
||||
// 优惠券模板(后台配置)
|
||||
public class CouponTemplate
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString();
|
||||
public string NameZhCn { get; set; } = string.Empty;
|
||||
public string NameZhTw { get; set; } = string.Empty;
|
||||
public string NameEn { get; set; } = string.Empty;
|
||||
public CouponType Type { get; set; }
|
||||
public decimal? ThresholdAmount { get; set; } // 满减门槛
|
||||
public decimal DiscountAmount { get; set; }
|
||||
public int PointsCost { get; set; }
|
||||
public DateTime ExpireAt { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public bool IsStamp { get; set; } // 是否为节日印花
|
||||
}
|
||||
|
||||
public enum CouponType { ThresholdDiscount, DirectDiscount }
|
||||
|
||||
// 用户优惠券
|
||||
public class UserCoupon
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString();
|
||||
public string CouponTemplateId { get; set; } = string.Empty;
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
public CouponStatus Status { get; set; } = CouponStatus.Available;
|
||||
public DateTime ExpireAt { get; set; }
|
||||
public DateTime? UsedAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public CouponTemplate CouponTemplate { get; set; } = null!;
|
||||
}
|
||||
|
||||
public enum CouponStatus { Available, Used, Expired }
|
||||
|
||||
// 会员二维码(Redis 存储)
|
||||
public class QrcodeData
|
||||
{
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
public string Token { get; set; } = string.Empty;
|
||||
public long CreatedAt { get; set; }
|
||||
public long ExpiresAt { get; set; }
|
||||
}
|
||||
|
||||
// 用户锁定(Redis 存储)
|
||||
public class UserLock
|
||||
{
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
public string MachineId { get; set; } = string.Empty;
|
||||
public DateTime LockedAt { get; set; }
|
||||
}
|
||||
|
||||
// 贩卖机支付回调
|
||||
public class VendingPaymentRecord
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString();
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
public string MachineId { get; set; } = string.Empty;
|
||||
public decimal PaymentAmount { get; set; }
|
||||
public string? UsedCouponId { get; set; }
|
||||
public string PaymentStatus { get; set; } = string.Empty; // success / failed
|
||||
public string TransactionId { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
// Banner
|
||||
public class Banner
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString();
|
||||
public string ImageUrlZhCn { get; set; } = string.Empty;
|
||||
public string ImageUrlZhTw { get; set; } = string.Empty;
|
||||
public string ImageUrlEn { get; set; } = string.Empty;
|
||||
public string LinkType { get; set; } = "none"; // internal / external / none
|
||||
public string? LinkUrl { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
|
||||
// 首页入口
|
||||
public class HomeEntry
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString();
|
||||
public string Type { get; set; } = string.Empty; // membership / stamp / qrcode
|
||||
public string ImageUrlZhCn { get; set; } = string.Empty;
|
||||
public string ImageUrlZhTw { get; set; } = string.Empty;
|
||||
public string ImageUrlEn { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// 内容配置(用户协议、隐私政策等)
|
||||
public class ContentConfig
|
||||
{
|
||||
public string Key { get; set; } = string.Empty; // agreement / privacy-policy / coupon-guide
|
||||
public string ContentZhCn { get; set; } = string.Empty;
|
||||
public string ContentZhTw { get; set; } = string.Empty;
|
||||
public string ContentEn { get; set; } = string.Empty;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
```
|
||||
|
||||
### 客户端数据模型(UniApp TypeScript)
|
||||
|
||||
```typescript
|
||||
interface User {
|
||||
uid: string
|
||||
phone: string
|
||||
areaCode: string
|
||||
nickname: string // 默认: "用户" + 6位随机数字
|
||||
isMember: boolean
|
||||
membershipType: 'none' | 'monthly' | 'subscription'
|
||||
membershipExpireAt: string | null
|
||||
pointsBalance: number
|
||||
pointsExpireAt: string | null // 会员到期后3个月
|
||||
createdAt: string
|
||||
language: 'zh-CN' | 'zh-TW' | 'en'
|
||||
}
|
||||
```
|
||||
|
||||
### 会员商品模型
|
||||
|
||||
```typescript
|
||||
interface MembershipProduct {
|
||||
id: string
|
||||
type: 'monthly' | 'subscription'
|
||||
price: number
|
||||
currency: string
|
||||
durationDays: number // 单月会员生效天数
|
||||
googleProductId: string // Google Play 商品 ID
|
||||
appleProductId: string // App Store 商品 ID
|
||||
description: Record<string, string> // 多语言描述
|
||||
}
|
||||
```
|
||||
|
||||
### 积分模型
|
||||
|
||||
```typescript
|
||||
interface PointRecord {
|
||||
id: string
|
||||
userId: string
|
||||
type: 'earn' | 'spend'
|
||||
amount: number
|
||||
source: string // 来源描述:贩卖机消费/兑换优惠券/赠送等
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface PointsConfig {
|
||||
conversionRate: number // 金额转积分比例,如 1 表示 1元=1积分
|
||||
}
|
||||
```
|
||||
|
||||
### 优惠券模型
|
||||
|
||||
```typescript
|
||||
type CouponType = 'threshold_discount' | 'direct_discount'
|
||||
// threshold_discount: 满减券(如满100减5)
|
||||
// direct_discount: 抵扣券(如无门槛5元)
|
||||
|
||||
type CouponStatus = 'available' | 'used' | 'expired'
|
||||
|
||||
interface RedeemableCoupon {
|
||||
id: string
|
||||
name: Record<string, string>
|
||||
type: CouponType
|
||||
thresholdAmount: number | null // 满减门槛,抵扣券为 null
|
||||
discountAmount: number
|
||||
pointsCost: number
|
||||
expireAt: string
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
interface UserCoupon {
|
||||
id: string
|
||||
couponId: string
|
||||
userId: string
|
||||
name: Record<string, string>
|
||||
type: CouponType
|
||||
thresholdAmount: number | null
|
||||
discountAmount: number
|
||||
status: CouponStatus
|
||||
expireAt: string
|
||||
usedAt: string | null
|
||||
}
|
||||
|
||||
interface StampCoupon {
|
||||
id: string
|
||||
name: Record<string, string>
|
||||
type: CouponType
|
||||
thresholdAmount: number | null
|
||||
discountAmount: number
|
||||
pointsCost: number // 可为 0
|
||||
expireAt: string
|
||||
isRedeemed: boolean // 当前用户是否已兑换
|
||||
}
|
||||
```
|
||||
|
||||
### 会员二维码模型
|
||||
|
||||
```typescript
|
||||
interface QrcodeData {
|
||||
userId: string
|
||||
token: string
|
||||
createdAt: number
|
||||
expiresAt: number // 时间戳,几分钟后失效
|
||||
}
|
||||
```
|
||||
|
||||
### 贩卖机对接模型
|
||||
|
||||
```typescript
|
||||
interface VendingUserInfo {
|
||||
userId: string
|
||||
isMember: boolean
|
||||
isLocked: boolean
|
||||
coupons: VendingCouponInfo[]
|
||||
}
|
||||
|
||||
interface VendingCouponInfo {
|
||||
couponId: string
|
||||
type: CouponType
|
||||
thresholdAmount: number | null
|
||||
discountAmount: number
|
||||
expireAt: string
|
||||
}
|
||||
|
||||
interface VendingPaymentPayload {
|
||||
userId: string
|
||||
machineId: string
|
||||
paymentAmount: number
|
||||
usedCouponId: string | null
|
||||
paymentStatus: 'success' | 'failed'
|
||||
transactionId: string
|
||||
}
|
||||
```
|
||||
|
||||
### 内容配置模型
|
||||
|
||||
```typescript
|
||||
interface Banner {
|
||||
id: string
|
||||
imageUrl: Record<string, string> // 多语言图片 URL
|
||||
linkType: 'internal' | 'external' | 'none'
|
||||
linkUrl: string | null
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
interface HomeEntry {
|
||||
id: string
|
||||
type: 'membership' | 'stamp' | 'qrcode'
|
||||
imageUrl: Record<string, string>
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## 正确性属性
|
||||
|
||||
*属性是指在系统所有有效执行中都应成立的特征或行为——本质上是关于系统应该做什么的形式化陈述。属性是人类可读规范与机器可验证正确性保证之间的桥梁。*
|
||||
|
||||
### Property 1:默认昵称格式
|
||||
|
||||
*对于任意* 新注册用户,其默认昵称应匹配格式 "用户" + 6位数字(正则:`^用户\d{6}$`)。
|
||||
|
||||
**验证: 需求 1.6**
|
||||
|
||||
### Property 2:协议未勾选阻止登录
|
||||
|
||||
*对于任意* 登录请求,如果用户未勾选同意协议,登录操作应被阻止且用户状态保持未登录。
|
||||
|
||||
**验证: 需求 1.4**
|
||||
|
||||
### Property 3:i18n 翻译完整性
|
||||
|
||||
*对于任意* i18n 翻译 key,三种语言(zh-CN、zh-TW、en)都应存在对应的非空翻译文本。
|
||||
|
||||
**验证: 需求 2.1, 2.3**
|
||||
|
||||
### Property 4:Banner 跳转路由正确性
|
||||
|
||||
*对于任意* Banner 配置项,如果 linkType 为 'internal' 则应跳转至 App 内部页面,如果为 'external' 则应打开外部链接,如果为 'none' 则不执行跳转。
|
||||
|
||||
**验证: 需求 3.2**
|
||||
|
||||
### Property 5:二维码入口行为取决于会员状态
|
||||
|
||||
*对于任意* 用户,点击会员二维码入口时:如果用户为会员则弹出二维码弹窗,如果用户非会员则跳转至会员页。
|
||||
|
||||
**验证: 需求 3.5, 3.6**
|
||||
|
||||
### Property 6:优惠券列表渲染完整性
|
||||
|
||||
*对于任意* 可兑换优惠券数据,渲染结果应包含优惠券名称、到期时间、兑换条件和兑换按钮。
|
||||
|
||||
**验证: 需求 3.8**
|
||||
|
||||
### Property 7:会员页按钮状态取决于会员类型
|
||||
|
||||
*对于任意* 用户的会员类型(none/monthly/subscription),会员页按钮应遵循以下规则:无会员时显示开通按钮;已购单月会员时隐藏单月按钮仅显示订阅按钮;已购订阅会员时按钮置灰不可点击。
|
||||
|
||||
**验证: 需求 4.3, 4.7, 4.8**
|
||||
|
||||
### Property 8:平台与支付方式映射
|
||||
|
||||
*对于任意* 设备平台,Android 应调用 Google Pay,iOS 应调用 Apple Pay。
|
||||
|
||||
**验证: 需求 4.2**
|
||||
|
||||
### Property 9:积分保留与清空规则
|
||||
|
||||
*对于任意* 会员到期的用户,如果当前时间距会员到期不超过3个月则积分保留,超过3个月则积分清空为0。
|
||||
|
||||
**验证: 需求 4.4, 4.5**
|
||||
|
||||
### Property 10:积分转换计算
|
||||
|
||||
*对于任意* 支付金额和转换比,计算出的积分应等于 `Math.floor(金额 * 转换比)`。
|
||||
|
||||
**验证: 需求 5.1**
|
||||
|
||||
### Property 11:积分记录渲染完整性
|
||||
|
||||
*对于任意* 积分记录,获取记录的渲染结果应包含来源、时间和增加数量;使用记录的渲染结果应包含使用方式、时间和减少数量。
|
||||
|
||||
**验证: 需求 5.4, 5.5**
|
||||
|
||||
### Property 12:赠送积分守恒
|
||||
|
||||
*对于任意* 积分赠送操作,如果发送方积分充足,则发送方减少的积分等于接收方增加的积分(总积分守恒);如果发送方积分不足,则双方积分均不变。
|
||||
|
||||
**验证: 需求 5.6, 5.7**
|
||||
|
||||
### Property 13:优惠券兑换正确性
|
||||
|
||||
*对于任意* 优惠券兑换操作,如果用户积分 >= 优惠券所需积分,则兑换成功且用户积分减少对应数量;如果用户积分 < 优惠券所需积分,则兑换失败且用户积分不变。
|
||||
|
||||
**验证: 需求 6.2, 6.3**
|
||||
|
||||
### Property 14:优惠券状态分类与标识
|
||||
|
||||
*对于任意* 用户优惠券列表,每张优惠券应被正确分类为"可使用""已使用"或"已过期",且已使用和已过期的优惠券应带有对应状态标识。
|
||||
|
||||
**验证: 需求 6.6, 6.7**
|
||||
|
||||
### Property 15:印花兑换权限取决于会员状态
|
||||
|
||||
*对于任意* 用户点击印花优惠券兑换按钮,会员用户应执行兑换流程,非会员用户应跳转至会员页。
|
||||
|
||||
**验证: 需求 7.3, 7.4**
|
||||
|
||||
### Property 16:印花兑换幂等性
|
||||
|
||||
*对于任意* 会员用户和印花优惠券,第一次兑换应成功,后续重复兑换应被拒绝,且按钮状态为"已兑换"不可点击。
|
||||
|
||||
**验证: 需求 7.5, 7.6**
|
||||
|
||||
### Property 17:支付错误处理
|
||||
|
||||
*对于任意* 支付错误,App 应展示错误提示信息且页面状态(用户会员状态、积分等)保持不变。
|
||||
|
||||
**验证: 需求 8.4**
|
||||
|
||||
### Property 18:会员二维码时效性
|
||||
|
||||
*对于任意* 生成的会员二维码,在有效期内应能被正常解析获取用户信息,过期后应被拒绝。
|
||||
|
||||
**验证: 需求 9.1**
|
||||
|
||||
### Property 19:用户锁定机制
|
||||
|
||||
*对于任意* 用户,当贩卖机扫码成功后该用户应被锁定,锁定期间其他贩卖机请求该用户信息应返回锁定状态。
|
||||
|
||||
**验证: 需求 9.2, 9.3, 9.4**
|
||||
|
||||
### Property 20:优惠券排序优先级
|
||||
|
||||
*对于任意* 优惠券列表,排序后应按到期时间升序排列,到期时间相同时按抵扣金额降序排列(优先使用快到期且抵扣最大的优惠券)。
|
||||
|
||||
**验证: 需求 9.6**
|
||||
|
||||
### Property 21:支付结束解除锁定
|
||||
|
||||
*对于任意* 支付结果(成功或失败),支付流程结束后用户锁定应被解除。
|
||||
|
||||
**验证: 需求 9.7**
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 网络错误
|
||||
- 所有 API 请求应设置超时时间(建议 15 秒)
|
||||
- 网络请求失败时展示统一的错误提示,并提供重试选项
|
||||
- 关键操作(支付、兑换)失败时保持当前页面状态不变
|
||||
|
||||
### 支付错误
|
||||
- Google Pay / Apple Pay 支付取消:静默处理,不展示错误
|
||||
- 支付失败:展示明确的错误信息,引导用户重试
|
||||
- 支付凭证验证失败:记录日志,提示用户联系客服
|
||||
- 订阅续费失败:通过推送通知提醒用户
|
||||
|
||||
### 业务逻辑错误
|
||||
- 优惠券已下架:关闭弹窗,提示"优惠券已下架"
|
||||
- 积分不足:提示"积分不足",不执行操作
|
||||
- 赠送目标 UID 不存在:提示"用户不存在"
|
||||
- 会员二维码过期:提示用户刷新二维码
|
||||
- 用户已被锁定:贩卖机端提示"已在其他机器扫码"
|
||||
|
||||
### 验证错误
|
||||
- 手机号格式不正确:输入框下方展示格式提示
|
||||
- 验证码错误或过期:提示"验证码错误或已过期"
|
||||
- 赠送积分数量非正整数:阻止提交
|
||||
|
||||
### 并发错误
|
||||
- 用户锁定冲突:通过 Redis 分布式锁保证原子性
|
||||
- 优惠券库存竞争:使用乐观锁或数据库事务保证一致性
|
||||
- 积分操作并发:使用数据库事务保证积分守恒
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 测试框架选择
|
||||
|
||||
- **后端单元测试**: xUnit(.NET 标准测试框架)
|
||||
- **后端属性测试**: FsCheck(.NET 属性测试库,兼容 xUnit)
|
||||
- **前端单元测试**: Vitest(与 Vue 3 生态兼容)
|
||||
- **前端属性测试**: fast-check(JavaScript/TypeScript 属性测试库)
|
||||
- **前端组件测试**: @vue/test-utils + Vitest
|
||||
- **E2E 测试**: 不在本 spec 范围内(需真机或模拟器)
|
||||
|
||||
### 双重测试方法
|
||||
|
||||
**单元测试**用于:
|
||||
- 特定示例和边界情况验证
|
||||
- 组件渲染正确性(前端)
|
||||
- API Controller 逻辑测试(后端)
|
||||
- Service 层业务逻辑测试(后端)
|
||||
- API 调用 mock 测试(前端)
|
||||
- 导航跳转逻辑(前端)
|
||||
|
||||
**属性测试**用于:
|
||||
- 验证设计文档中定义的所有正确性属性
|
||||
- 后端使用 FsCheck + xUnit,前端使用 fast-check + Vitest
|
||||
- 每个属性测试至少运行 100 次迭代
|
||||
- 每个属性测试必须通过注释引用设计文档中的属性编号
|
||||
- 注释格式:
|
||||
- 后端: `// Feature: vending-machine-app, Property N: 属性描述`
|
||||
- 前端: `// Feature: vending-machine-app, Property N: 属性描述`
|
||||
|
||||
### 测试覆盖重点
|
||||
|
||||
| 模块 | 单元测试 | 属性测试 |
|
||||
|------|---------|---------|
|
||||
| 用户认证 | 登录流程、注销流程 | Property 1, 2 |
|
||||
| 多语言 | 语言切换 UI | Property 3 |
|
||||
| 首页 | Banner 渲染、导航 | Property 4, 5, 6 |
|
||||
| 会员体系 | 支付流程 mock | Property 7, 8, 9 |
|
||||
| 积分系统 | 记录展示 | Property 10, 11, 12 |
|
||||
| 优惠券系统 | 兑换流程 | Property 13, 14 |
|
||||
| 节日印花 | 兑换流程 | Property 15, 16 |
|
||||
| 支付集成 | 错误处理 | Property 17 |
|
||||
| 贩卖机对接 | API 接口 | Property 18, 19, 20, 21 |
|
||||
|
||||
### 属性测试配置
|
||||
|
||||
**后端 (.NET 10 + FsCheck + xUnit)**:
|
||||
|
||||
```csharp
|
||||
using FsCheck;
|
||||
using FsCheck.Xunit;
|
||||
|
||||
// Feature: vending-machine-app, Property 10: 积分转换计算
|
||||
[Property(MaxTest = 100)]
|
||||
public Property PointsConversion_ShouldMatchFormula()
|
||||
{
|
||||
return Prop.ForAll(
|
||||
Arb.From(Gen.Choose(1, 1000000).Select(x => (decimal)x / 100)),
|
||||
Arb.From(Gen.Choose(1, 100).Select(x => (decimal)x / 10)),
|
||||
(amount, rate) =>
|
||||
{
|
||||
var points = PointsService.CalculatePoints(amount, rate);
|
||||
return points == (int)Math.Floor(amount * rate);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**前端 (fast-check + Vitest)**:
|
||||
|
||||
```typescript
|
||||
import fc from 'fast-check'
|
||||
|
||||
const PBT_CONFIG = { numRuns: 100 }
|
||||
|
||||
// Feature: vending-machine-app, Property 10: 积分转换计算
|
||||
test('积分转换计算', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.float({ min: 0.01, max: 10000, noNaN: true }),
|
||||
fc.float({ min: 0.1, max: 10, noNaN: true }),
|
||||
(amount, rate) => {
|
||||
const points = calculatePoints(amount, rate)
|
||||
return points === Math.floor(amount * rate)
|
||||
}
|
||||
),
|
||||
PBT_CONFIG
|
||||
)
|
||||
})
|
||||
```
|
||||
174
.kiro/specs/vending-machine-app/requirements.md
Normal file
174
.kiro/specs/vending-machine-app/requirements.md
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
# 需求文档
|
||||
|
||||
## 简介
|
||||
|
||||
本文档定义了贩卖机配套移动端 App 的功能需求。该 App 需同时支持 Android 和 iOS 双平台,上架 Google Play 和 App Store。核心功能包括:会员体系、积分系统、优惠券系统、多语言支持、移动支付集成,以及与贩卖机硬件的 API 对接。
|
||||
|
||||
## 术语表
|
||||
|
||||
- **App**: 贩卖机配套移动端应用程序,支持 Android 和 iOS 双平台
|
||||
- **用户**: 使用 App 的终端消费者
|
||||
- **会员**: 已购买会员服务的用户,享有积分获取、节日印花兑换等权益
|
||||
- **单月会员**: 一次性购买、固定时长生效的会员类型
|
||||
- **订阅会员**: 按月自动续费的会员类型
|
||||
- **积分**: 用户通过在贩卖机消费获取的虚拟货币,可用于兑换优惠券或赠送他人
|
||||
- **优惠券**: 用户可在贩卖机消费时使用的折扣凭证,包括满减券和抵扣券
|
||||
- **节日印花**: 在节假日期间向会员赠送的特殊优惠券,需手动兑换
|
||||
- **会员二维码**: 包含会员 ID 等信息的动态二维码,供贩卖机扫码识别用户身份,具有时效限制
|
||||
- **UID**: 用户唯一标识符
|
||||
- **贩卖机**: 与 App 对接的硬件设备,通过 API 与 App 后端通信
|
||||
- **后台**: App 的管理后台系统,用于配置内容、商品、价格等
|
||||
- **转换比**: 后台可配置的消费金额与积分之间的换算比例
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:用户认证与账号管理
|
||||
|
||||
**用户故事:** 作为用户,我希望通过手机号验证码登录 App,以便安全地访问我的账户和使用各项功能。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 用户在登录页输入手机号并请求验证码, THE App SHALL 调用第三方短信平台发送验证码至该手机号
|
||||
2. WHEN 用户在登录页选择手机区号, THE App SHALL 支持切换不同国家或地区的手机区号
|
||||
3. WHEN 用户输入正确的验证码并点击登录按钮, THE App SHALL 验证验证码正确性并完成登录,跳转至首页
|
||||
4. WHEN 用户点击登录按钮但未勾选同意协议, THE App SHALL 弹出系统提示"请阅读并同意协议"并阻止登录操作
|
||||
5. WHEN 用户未登录时访问我的页, THE App SHALL 展示默认头像和登录按钮
|
||||
6. WHEN 用户成功登录后访问我的页, THE App SHALL 展示默认用户昵称("用户" + 随机6位数字)和 UID
|
||||
7. WHEN 用户在关于页点击注销账号按钮并在弹窗中确认, THE App SHALL 注销该账号、退出登录状态、返回首页并弹出系统提示"已注销"
|
||||
8. WHEN 用户在我的页点击退出登录按钮并在弹窗中确认, THE App SHALL 退出当前登录状态
|
||||
|
||||
### 需求 2:多语言支持
|
||||
|
||||
**用户故事:** 作为用户,我希望 App 支持多种语言,以便使用我熟悉的语言操作 App。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE App SHALL 支持简体中文、繁体中文和英文三种语言
|
||||
2. WHEN 用户在我的页点击切换语言按钮, THE App SHALL 弹出多语言选择列表供用户选择
|
||||
3. WHEN 用户选择一种语言后, THE App SHALL 将所有界面文字切换为所选语言
|
||||
4. WHEN 后台配置文字或图片内容时, THE 后台 SHALL 支持为每项内容配置三种语言的版本
|
||||
|
||||
### 需求 3:首页展示与导航
|
||||
|
||||
**用户故事:** 作为用户,我希望在首页快速访问各项功能入口,以便高效地使用 App。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE App SHALL 在首页顶部展示后台可配置的 Banner 轮播图
|
||||
2. WHEN 用户点击 Banner 图片, THE App SHALL 根据后台配置跳转至内部指定页面或外部链接
|
||||
3. WHEN 用户点击成为会员入口图片, THE App SHALL 跳转至会员页
|
||||
4. WHEN 用户点击节日印花入口图片, THE App SHALL 跳转至节日印花页
|
||||
5. WHEN 用户点击会员二维码入口图片且用户为会员, THE App SHALL 弹出会员二维码弹窗
|
||||
6. WHEN 用户点击会员二维码入口图片且用户非会员, THE App SHALL 跳转至会员页
|
||||
7. WHEN 用户点击使用说明按钮, THE App SHALL 弹出优惠券使用说明弹窗,弹窗内容由后台配置
|
||||
8. THE App SHALL 在首页展示后台可配置的可兑换优惠券列表,每项展示优惠券名称、到期时间、兑换条件和兑换按钮
|
||||
|
||||
### 需求 4:会员体系
|
||||
|
||||
**用户故事:** 作为用户,我希望购买会员服务,以便享受积分获取和专属优惠等权益。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE App SHALL 在会员页展示后台可配置的会员宣传长图
|
||||
2. WHEN 用户在会员页点击开通会员按钮, THE App SHALL 根据设备平台拉起 Google Pay(Android)或 Apple Pay(iOS)支付流程
|
||||
3. WHEN 用户成功完成会员支付, THE App SHALL 刷新会员页并将按钮文字更改为"会员已开通",按钮置为灰色不可点击状态
|
||||
4. WHEN 会员身份到期后超过3个月, THE App SHALL 清空该用户的剩余积分
|
||||
5. WHILE 会员身份到期后3个月内, THE App SHALL 保留该用户的剩余积分
|
||||
6. WHEN 用户购买订阅会员后, THE App SHALL 每月自动续费
|
||||
7. WHEN 用户已购买单月会员后查看会员页, THE App SHALL 隐藏单月会员购买按钮,仅显示订阅会员购买按钮
|
||||
8. WHEN 用户已购买订阅会员后查看会员页, THE App SHALL 将购买按钮文字变更为"已购买订阅会员",按钮置为灰色不可点击状态
|
||||
|
||||
### 需求 5:积分系统
|
||||
|
||||
**用户故事:** 作为会员,我希望通过消费获取积分并管理积分,以便兑换优惠券或赠送给其他用户。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 用户在贩卖机完成支付, THE App SHALL 按照后台配置的转换比将支付金额转换为积分并添加至用户账户
|
||||
2. THE 后台 SHALL 支持配置金额与积分的转换比例
|
||||
3. WHEN 用户在我的页点击我的积分区域, THE App SHALL 跳转至我的积分页,展示获取记录和使用记录两个标签
|
||||
4. WHEN 用户查看积分获取记录, THE App SHALL 展示每条记录的积分来源、获取时间和增加数量
|
||||
5. WHEN 用户查看积分使用记录, THE App SHALL 展示每条记录的使用方式、使用时间和减少数量
|
||||
6. WHEN 用户在赠送积分弹窗中输入对方 UID 和赠送积分数量并点击赠送按钮且剩余积分充足, THE App SHALL 从当前用户扣除积分、为对方增加积分、关闭弹窗并弹出系统提示"积分已赠送"
|
||||
7. WHEN 用户在赠送积分弹窗中点击赠送按钮且剩余积分不足, THE App SHALL 弹出系统提示"剩余积分不足,无法赠送"
|
||||
|
||||
### 需求 6:优惠券系统
|
||||
|
||||
**用户故事:** 作为用户,我希望使用积分兑换优惠券并在贩卖机消费时使用,以便获得折扣优惠。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 用户在首页点击优惠券的兑换按钮, THE App SHALL 弹出确定兑换弹窗
|
||||
2. WHEN 用户在确定兑换弹窗中点击确定兑换按钮且积分充足, THE App SHALL 完成兑换、关闭弹窗并弹出系统提示"兑换成功"
|
||||
3. WHEN 用户在确定兑换弹窗中点击确定兑换按钮且积分不足, THE App SHALL 关闭弹窗并弹出系统提示"积分不足,无法兑换"
|
||||
4. IF 兑换期间优惠券被下架, THEN THE App SHALL 关闭弹窗并弹出系统提示"优惠券已下架"
|
||||
5. WHEN 用户在我的页点击我的优惠券按钮, THE App SHALL 跳转至我的优惠券页
|
||||
6. THE App SHALL 在我的优惠券页将优惠券分为"可使用""已使用""已过期"三种状态展示
|
||||
7. WHEN 优惠券状态为已使用或已过期, THE App SHALL 在优惠券上显示对应的"已使用"或"已过期"标识图标
|
||||
8. THE 后台 SHALL 支持配置优惠券的名称、类型(满减、抵扣)、到期时间和兑换条件
|
||||
|
||||
### 需求 7:节日印花系统
|
||||
|
||||
**用户故事:** 作为会员,我希望在节假日兑换专属印花优惠券,以便享受节日特惠。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE App SHALL 在节日印花页展示后台可配置的 Banner 图(点击无跳转)
|
||||
2. THE App SHALL 在节日印花页展示可兑换的印花优惠券列表,兑换条件可配置为0积分或少量积分
|
||||
3. WHEN 会员用户点击印花优惠券的兑换按钮, THE App SHALL 执行兑换流程
|
||||
4. WHEN 非会员用户点击印花优惠券的兑换按钮, THE App SHALL 跳转至会员页
|
||||
5. WHEN 会员成功兑换一张印花优惠券后, THE App SHALL 将该优惠券的兑换按钮替换为"已兑换",按钮置为灰色不可点击状态
|
||||
6. THE App SHALL 限制每张印花优惠券每位用户仅能兑换1次
|
||||
|
||||
### 需求 8:移动支付集成
|
||||
|
||||
**用户故事:** 作为用户,我希望通过 Google Pay 或 Apple Pay 完成支付,以便快捷安全地购买会员服务。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 用户在 Android 设备上发起会员购买, THE App SHALL 调用 Google Pay 支付接口完成支付流程
|
||||
2. WHEN 用户在 iOS 设备上发起会员购买, THE App SHALL 调用 Apple Pay 支付接口完成支付流程
|
||||
3. WHEN 支付成功完成, THE App SHALL 更新用户会员状态并刷新当前页面
|
||||
4. IF 支付过程中发生错误, THEN THE App SHALL 向用户展示明确的错误提示信息并保持当前页面状态不变
|
||||
5. THE App SHALL 在 Google Play 和 Apple 后台配置对应的会员商品和价格信息
|
||||
6. WHEN 订阅会员支付成功, THE App SHALL 激活自动续费功能并在每月到期时自动扣款
|
||||
|
||||
### 需求 9:贩卖机硬件 API 对接
|
||||
|
||||
**用户故事:** 作为会员,我希望在贩卖机上扫码后自动识别我的身份和优惠信息,以便享受会员权益和优惠券抵扣。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 用户在 App 中打开会员二维码, THE App SHALL 生成包含会员 ID 等信息的动态二维码,该二维码在数分钟后自动失效
|
||||
2. WHEN 贩卖机扫描会员二维码后, THE App 后端 SHALL 通过用户信息接口返回该用户的会员信息和优惠券信息
|
||||
3. WHEN 贩卖机请求用户信息且该用户已被其他贩卖机锁定, THE App 后端 SHALL 返回用户锁定状态,贩卖机提示"已在其他机器扫码"
|
||||
4. WHEN 贩卖机扫码成功获取用户信息后, THE App 后端 SHALL 锁定该用户,防止同时在多台贩卖机操作
|
||||
5. WHEN 用户在贩卖机完成支付后, THE 贩卖机 SHALL 调用支付中心接口将用户 ID、支付金额、使用的优惠券 ID 等信息发送至 App 后端
|
||||
6. WHEN 贩卖机收到用户的优惠券信息后, THE 贩卖机 SHALL 根据用户购买的物品和金额判断是否使用优惠券,优先使用快到期且抵扣金额最大的优惠券
|
||||
7. IF 支付流程结束(无论成功或失败), THEN THE App 后端 SHALL 通过支付中心接口记录支付结果并解除用户锁定
|
||||
|
||||
### 需求 10:个人中心与信息展示
|
||||
|
||||
**用户故事:** 作为用户,我希望在个人中心查看和管理我的账户信息,以便了解我的会员状态、积分和优惠券情况。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE App SHALL 在我的页展示用户的剩余积分数量
|
||||
2. WHEN 用户在我的页点击用户协议或隐私政策按钮, THE App SHALL 跳转至对应的协议页面,内容由后台配置
|
||||
3. WHEN 用户在我的页点击关于按钮, THE App SHALL 跳转至关于页,展示 LOGO、版本号和注销账号按钮
|
||||
4. THE App SHALL 在首页各入口图片支持后台动态配置和更新
|
||||
|
||||
### 需求 11:管理后台
|
||||
|
||||
**用户故事:** 作为运营人员,我希望通过管理后台配置和管理 App 的内容、商品和用户数据,以便灵活运营贩卖机业务。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 管理后台 SHALL 提供 Banner 管理功能,支持增删改查、排序和多语言图片配置
|
||||
2. THE 管理后台 SHALL 提供优惠券管理功能,支持创建、编辑、上下架优惠券,配置类型(满减、抵扣)、兑换条件和到期时间
|
||||
3. THE 管理后台 SHALL 提供节日印花管理功能,支持创建和编辑印花优惠券及 Banner 图配置
|
||||
4. THE 管理后台 SHALL 提供积分配置功能,支持设置金额与积分的转换比例
|
||||
5. THE 管理后台 SHALL 提供会员商品管理功能,支持配置会员价格、生效时长和宣传图
|
||||
6. THE 管理后台 SHALL 提供用户管理功能,支持查看用户列表和用户详情(会员状态、积分、优惠券)
|
||||
7. THE 管理后台 SHALL 提供内容管理功能,支持编辑用户协议、隐私政策和优惠券使用说明的多语言版本
|
||||
8. THE 管理后台 SHALL 提供首页入口图片管理功能,支持配置各功能入口的多语言图片
|
||||
284
.kiro/specs/vending-machine-app/tasks.md
Normal file
284
.kiro/specs/vending-machine-app/tasks.md
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
# 实现计划:贩卖机配套移动端 App
|
||||
|
||||
## 概述
|
||||
|
||||
本实现计划将设计文档拆分为可执行的编码任务。项目包含三个子系统:.NET 10 后端 API、UniApp 移动端、EleAdmin + Vue 3 管理后台。任务按模块递进,每个模块完成后进行检查点验证。
|
||||
|
||||
## 任务
|
||||
|
||||
- [x] 1. 后端项目初始化与基础架构
|
||||
- [x] 1.1 创建 .NET 10 ASP.NET Core Web API 项目结构
|
||||
- 创建解决方案和项目(API、Domain、Infrastructure、Application 分层)
|
||||
- 配置 SQL Server 连接字符串和 Entity Framework Core
|
||||
- 配置 Redis 连接
|
||||
- 添加 Swagger/OpenAPI 文档
|
||||
- _需求: 全局_
|
||||
|
||||
- [x] 1.2 创建数据库实体和 DbContext
|
||||
- 根据设计文档数据模型创建所有 Entity 类(User、MembershipProduct、PointRecord、CouponTemplate、UserCoupon、Banner、HomeEntry、ContentConfig、VendingPaymentRecord、PointsConfig)
|
||||
- 创建 AppDbContext 并配置实体映射关系
|
||||
- 创建初始数据库迁移
|
||||
- _需求: 全局_
|
||||
|
||||
- [x] 1.3 实现通用基础设施
|
||||
- 实现统一 API 响应格式(ApiResponse<T>)
|
||||
- 实现全局异常处理中间件
|
||||
- 实现 JWT 认证中间件
|
||||
- 实现多语言请求头解析(Accept-Language)
|
||||
- _需求: 全局_
|
||||
|
||||
- [x] 2. 用户认证模块
|
||||
- [x] 2.1 实现用户服务和控制器
|
||||
- 实现 UserService(发送验证码、登录、获取用户信息、登出、注销)
|
||||
- 实现 UserController 的所有端点
|
||||
- 实现手机号区号验证逻辑
|
||||
- 实现默认昵称生成("用户" + 6位随机数字)
|
||||
- _需求: 1.1, 1.2, 1.3, 1.5, 1.6, 1.7, 1.8_
|
||||
|
||||
- [x] 2.2 编写用户认证属性测试
|
||||
- **Property 1: 默认昵称格式**
|
||||
- **Property 2: 协议未勾选阻止登录**
|
||||
- **验证: 需求 1.4, 1.6**
|
||||
|
||||
- [x] 2.3 编写用户认证单元测试
|
||||
- 测试登录成功/失败流程
|
||||
- 测试注销账号流程
|
||||
- 测试验证码发送逻辑
|
||||
- _需求: 1.1, 1.3, 1.7_
|
||||
|
||||
- [x] 3. 检查点 - 用户认证模块
|
||||
- 确保所有测试通过,如有问题请向用户确认。
|
||||
|
||||
- [x] 4. 会员与支付模块
|
||||
- [x] 4.1 实现会员服务和控制器
|
||||
- 实现 MembershipService(获取会员信息、获取商品列表、购买会员、购买订阅、验证订阅状态)
|
||||
- 实现 MembershipController 的所有端点
|
||||
- 实现会员到期后积分保留/清空逻辑(3个月规则)
|
||||
- 实现 Google Pay / Apple Pay 支付凭证验证逻辑
|
||||
- _需求: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7, 4.8, 8.1, 8.2, 8.3, 8.5, 8.6_
|
||||
|
||||
- [x] 4.2 编写会员与支付属性测试
|
||||
- **Property 7: 会员页按钮状态取决于会员类型**
|
||||
- **Property 8: 平台与支付方式映射**
|
||||
- **Property 9: 积分保留与清空规则**
|
||||
- **Property 17: 支付错误处理**
|
||||
- **验证: 需求 4.2, 4.3, 4.4, 4.5, 4.7, 4.8, 8.4**
|
||||
|
||||
- [x] 5. 积分模块
|
||||
- [x] 5.1 实现积分服务和控制器
|
||||
- 实现 PointsService(获取余额、获取记录、赠送积分、消费积分转换)
|
||||
- 实现 PointsController 的所有端点
|
||||
- 实现积分转换计算逻辑(金额 * 转换比)
|
||||
- 实现赠送积分事务逻辑(扣减发送方、增加接收方,保证原子性)
|
||||
- _需求: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7_
|
||||
|
||||
- [x] 5.2 编写积分模块属性测试
|
||||
- **Property 10: 积分转换计算**
|
||||
- **Property 11: 积分记录渲染完整性**
|
||||
- **Property 12: 赠送积分守恒**
|
||||
- **验证: 需求 5.1, 5.4, 5.5, 5.6, 5.7**
|
||||
|
||||
- [x] 6. 优惠券模块
|
||||
- [x] 6.1 实现优惠券服务和控制器
|
||||
- 实现 CouponService(获取可兑换列表、兑换优惠券、获取用户优惠券、获取印花列表、兑换印花)
|
||||
- 实现 CouponController 的所有端点
|
||||
- 实现兑换逻辑(积分检查、库存检查、下架检查)
|
||||
- 实现印花兑换逻辑(会员检查、每人限兑1次)
|
||||
- 实现优惠券状态自动更新(过期检测)
|
||||
- _需求: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 6.7, 6.8, 7.1, 7.2, 7.3, 7.4, 7.5, 7.6_
|
||||
|
||||
- [x] 6.2 编写优惠券模块属性测试
|
||||
- **Property 13: 优惠券兑换正确性**
|
||||
- **Property 14: 优惠券状态分类与标识**
|
||||
- **Property 15: 印花兑换权限取决于会员状态**
|
||||
- **Property 16: 印花兑换幂等性**
|
||||
- **验证: 需求 6.2, 6.3, 6.6, 6.7, 7.3, 7.4, 7.5, 7.6**
|
||||
|
||||
- [x] 7. 检查点 - 核心业务模块
|
||||
- 确保所有测试通过,如有问题请向用户确认。
|
||||
|
||||
- [x] 8. 贩卖机对接模块
|
||||
- [x] 8.1 实现贩卖机对接服务和控制器
|
||||
- 实现 VendingMachineService(二维码生成与验证、用户信息查询、用户锁定/解锁、支付回调处理)
|
||||
- 实现 VendingMachineController 的所有端点
|
||||
- 实现动态二维码生成(包含会员 ID、token、过期时间),使用 Redis 存储
|
||||
- 实现用户锁定机制(Redis 分布式锁)
|
||||
- 实现支付回调处理(积分发放、优惠券核销、解除锁定)
|
||||
- 实现优惠券排序逻辑(按到期时间升序、抵扣金额降序)
|
||||
- _需求: 9.1, 9.2, 9.3, 9.4, 9.5, 9.6, 9.7_
|
||||
|
||||
- [x] 8.2 编写贩卖机对接属性测试
|
||||
- **Property 18: 会员二维码时效性**
|
||||
- **Property 19: 用户锁定机制**
|
||||
- **Property 20: 优惠券排序优先级**
|
||||
- **Property 21: 支付结束解除锁定**
|
||||
- **验证: 需求 9.1, 9.2, 9.3, 9.4, 9.6, 9.7**
|
||||
|
||||
- [x] 9. 内容配置模块
|
||||
- [x] 9.1 实现内容配置服务和控制器
|
||||
- 实现 ContentService(获取 Banner、入口图片、使用说明、协议内容等)
|
||||
- 实现 ContentController 的所有端点
|
||||
- 根据请求语言返回对应语言版本的内容
|
||||
- _需求: 3.1, 3.2, 3.7, 3.8, 10.2, 10.4_
|
||||
|
||||
- [x] 10. 检查点 - 后端 API 完成
|
||||
- 确保所有测试通过,如有问题请向用户确认。
|
||||
|
||||
- [x] 11. 管理后台 - 项目初始化
|
||||
- [x] 11.1 创建 EleAdmin + Vue 3 管理后台项目
|
||||
- 基于 EleAdmin 模板初始化项目
|
||||
- 配置路由、权限管理、API 基础封装
|
||||
- 配置与后端 Admin API 的对接
|
||||
- _需求: 11(全局)_
|
||||
|
||||
- [x] 12. 管理后台 - 内容管理页面
|
||||
- [x] 12.1 实现 Banner 管理页面
|
||||
- Banner 列表展示、新增、编辑、删除
|
||||
- 支持拖拽排序
|
||||
- 支持多语言图片上传
|
||||
- _需求: 11.1_
|
||||
|
||||
- [x] 12.2 实现入口图片管理页面
|
||||
- 首页各功能入口图片的配置和多语言图片上传
|
||||
- _需求: 11.8_
|
||||
|
||||
- [x] 12.3 实现内容编辑页面
|
||||
- 用户协议、隐私政策、优惠券使用说明的多语言富文本编辑
|
||||
- _需求: 11.7_
|
||||
|
||||
- [x] 13. 管理后台 - 业务管理页面
|
||||
- [x] 13.1 实现优惠券管理页面
|
||||
- 优惠券列表、创建、编辑表单(名称、类型、门槛、折扣、积分、到期时间)
|
||||
- 上下架操作
|
||||
- 多语言名称配置
|
||||
- _需求: 11.2_
|
||||
|
||||
- [x] 13.2 实现节日印花管理页面
|
||||
- 印花优惠券列表、创建、编辑
|
||||
- Banner 图配置
|
||||
- _需求: 11.3_
|
||||
|
||||
- [x] 13.3 实现会员商品管理页面
|
||||
- 会员商品列表、价格编辑、时长配置
|
||||
- 宣传图上传
|
||||
- Google/Apple 商品 ID 配置
|
||||
- _需求: 11.5_
|
||||
|
||||
- [x] 13.4 实现积分配置页面
|
||||
- 金额与积分转换比配置表单
|
||||
- _需求: 11.4_
|
||||
|
||||
- [x] 13.5 实现用户管理页面
|
||||
- 用户列表(搜索、分页)
|
||||
- 用户详情(会员状态、积分、优惠券)
|
||||
- _需求: 11.6_
|
||||
|
||||
- [x] 14. 检查点 - 管理后台完成
|
||||
- 确保所有测试通过,如有问题请向用户确认。
|
||||
|
||||
- [x] 15. UniApp 移动端 - 项目初始化
|
||||
- [x] 15.1 创建 UniApp + Vue 3 + TypeScript 项目
|
||||
- 初始化 UniApp 项目,配置 TypeScript
|
||||
- 配置 Pinia 状态管理
|
||||
- 配置 vue-i18n 多语言(zh-CN、zh-TW、en)
|
||||
- 封装 API 请求层(统一请求拦截、Token 管理、错误处理)
|
||||
- 配置页面路由(pages.json)
|
||||
- _需求: 2.1, 全局_
|
||||
|
||||
- [x] 15.2 编写多语言属性测试
|
||||
- **Property 3: i18n 翻译完整性**
|
||||
- **验证: 需求 2.1, 2.3**
|
||||
|
||||
- [x] 16. UniApp 移动端 - 登录与用户模块
|
||||
- [x] 16.1 实现登录页
|
||||
- 手机号输入、区号选择器、验证码输入
|
||||
- 协议勾选检查
|
||||
- 调用后端登录接口,Token 存储
|
||||
- _需求: 1.1, 1.2, 1.3, 1.4_
|
||||
|
||||
- [x] 16.2 实现我的页
|
||||
- 未登录/已登录状态展示
|
||||
- 积分展示、功能入口(我的积分、我的优惠券、切换语言、用户协议、隐私政策、关于)
|
||||
- 赠送积分弹窗
|
||||
- 退出登录弹窗
|
||||
- _需求: 1.5, 1.6, 1.8, 5.3, 5.6, 5.7, 6.5, 10.1_
|
||||
|
||||
- [x] 16.3 实现关于页
|
||||
- LOGO、版本号展示
|
||||
- 注销账号弹窗和流程
|
||||
- _需求: 1.7, 10.3_
|
||||
|
||||
- [x] 16.4 实现语言切换功能
|
||||
- 多语言选择列表弹窗
|
||||
- 切换后刷新所有界面文字
|
||||
- _需求: 2.2, 2.3_
|
||||
|
||||
- [x] 17. UniApp 移动端 - 首页模块
|
||||
- [x] 17.1 实现首页
|
||||
- Banner 轮播组件(支持内部/外部跳转)
|
||||
- 功能入口图片(成为会员、节日印花、会员二维码)
|
||||
- 会员二维码弹窗(动态二维码生成与展示)
|
||||
- 优惠券使用说明弹窗
|
||||
- 可兑换优惠券列表和兑换弹窗
|
||||
- _需求: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 6.1, 6.2, 6.3, 6.4_
|
||||
|
||||
- [x] 17.2 编写首页相关属性测试
|
||||
- **Property 4: Banner 跳转路由正确性**
|
||||
- **Property 5: 二维码入口行为取决于会员状态**
|
||||
- **Property 6: 优惠券列表渲染完整性**
|
||||
- **验证: 需求 3.2, 3.5, 3.6, 3.8**
|
||||
|
||||
- [x] 18. UniApp 移动端 - 会员与支付模块
|
||||
- [x] 18.1 实现会员页
|
||||
- 会员宣传长图展示
|
||||
- 单月会员/订阅会员购买按钮(根据会员状态动态显示)
|
||||
- Google Pay / Apple Pay 支付调用封装
|
||||
- 支付成功后页面刷新
|
||||
- _需求: 4.1, 4.2, 4.3, 4.6, 4.7, 4.8, 8.1, 8.2, 8.3, 8.4, 8.6_
|
||||
|
||||
- [x] 19. UniApp 移动端 - 积分与优惠券模块
|
||||
- [x] 19.1 实现我的积分页
|
||||
- 获取记录/使用记录标签切换
|
||||
- 积分记录列表展示(来源、时间、数量)
|
||||
- _需求: 5.3, 5.4, 5.5_
|
||||
|
||||
- [x] 19.2 实现我的优惠券页
|
||||
- 可使用/已使用/已过期标签切换
|
||||
- 优惠券卡片组件(状态标识图标)
|
||||
- _需求: 6.5, 6.6, 6.7_
|
||||
|
||||
- [x] 19.3 实现节日印花页
|
||||
- Banner 图展示
|
||||
- 印花优惠券列表
|
||||
- 兑换逻辑(会员检查、已兑换状态)
|
||||
- _需求: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6_
|
||||
|
||||
- [x] 20. UniApp 移动端 - 协议与内容页面
|
||||
- [x] 20.1 实现用户协议页和隐私政策页
|
||||
- 从后端获取多语言内容并展示
|
||||
- _需求: 10.2_
|
||||
|
||||
- [x] 21. 检查点 - UniApp 移动端完成
|
||||
- 确保所有测试通过,如有问题请向用户确认。
|
||||
|
||||
- [x] 22. 集成与联调
|
||||
- [x] 22.1 前后端接口联调
|
||||
- UniApp 移动端与后端 API 全流程联调
|
||||
- 管理后台与后端 Admin API 联调
|
||||
- _需求: 全局_
|
||||
|
||||
- [x] 22.2 编写集成测试
|
||||
- 测试完整的用户注册-登录-购买会员-获取积分-兑换优惠券流程
|
||||
- 测试贩卖机扫码-锁定-支付回调-解锁完整流程
|
||||
- _需求: 全局_
|
||||
|
||||
- [x] 23. 最终检查点
|
||||
- 确保所有测试通过,如有问题请向用户确认。
|
||||
|
||||
## 备注
|
||||
|
||||
- 所有测试任务均为必选,确保代码质量
|
||||
- 每个任务引用了具体的需求编号以确保可追溯性
|
||||
- 检查点用于阶段性验证,确保增量开发的正确性
|
||||
- 属性测试验证通用正确性属性,单元测试验证具体示例和边界情况
|
||||
- 后端使用 xUnit + FsCheck 进行测试,前端使用 Vitest + fast-check 进行测试
|
||||
9
.kiro/steering/language.md
Normal file
9
.kiro/steering/language.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
inclusion: always
|
||||
---
|
||||
|
||||
# 语言规则
|
||||
|
||||
- 始终使用中文回复用户的问题和对话。
|
||||
- 代码注释使用中文。
|
||||
- Git commit message 使用中文。
|
||||
2
.vscode/settings.json
vendored
Normal file
2
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
{
|
||||
}
|
||||
24
admin/.gitignore
vendored
Normal file
24
admin/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
3
admin/.vscode/extensions.json
vendored
Normal file
3
admin/.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
5
admin/README.md
Normal file
5
admin/README.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
10
admin/auto-imports.d.ts
vendored
Normal file
10
admin/auto-imports.d.ts
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
|
||||
}
|
||||
49
admin/components.d.ts
vendored
Normal file
49
admin/components.d.ts
vendored
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||
ElAside: typeof import('element-plus/es')['ElAside']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElDivider: typeof import('element-plus/es')['ElDivider']
|
||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElHeader: typeof import('element-plus/es')['ElHeader']
|
||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
ElImage: typeof import('element-plus/es')['ElImage']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||
ElMain: typeof import('element-plus/es')['ElMain']
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElText: typeof import('element-plus/es')['ElText']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
}
|
||||
7
admin/env.d.ts
vendored
Normal file
7
admin/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
13
admin/index.html
Normal file
13
admin/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>贩卖机管理后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
3135
admin/package-lock.json
generated
Normal file
3135
admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
admin/package.json
Normal file
30
admin/package.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "vending-machine-admin",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.9.0",
|
||||
"element-plus": "^2.9.8",
|
||||
"pinia": "^3.0.2",
|
||||
"sortablejs": "^1.15.7",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/sortablejs": "^1.15.9",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"sass": "^1.89.0",
|
||||
"typescript": "~5.8.3",
|
||||
"unplugin-auto-import": "^19.2.0",
|
||||
"unplugin-vue-components": "^28.5.0",
|
||||
"vite": "^6.3.5",
|
||||
"vue-tsc": "^2.2.8"
|
||||
}
|
||||
}
|
||||
1
admin/public/favicon.svg
Normal file
1
admin/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
admin/public/icons.svg
Normal file
24
admin/public/icons.svg
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
1
admin/public/vite.svg
Normal file
1
admin/public/vite.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">🏪</text></svg>
|
||||
|
After Width: | Height: | Size: 110 B |
14
admin/src/App.vue
Normal file
14
admin/src/App.vue
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
</style>
|
||||
22
admin/src/api/banner.ts
Normal file
22
admin/src/api/banner.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import request from '@/utils/request'
|
||||
|
||||
// Banner 管理 API
|
||||
export function getBanners() {
|
||||
return request.get('/banner')
|
||||
}
|
||||
|
||||
export function createBanner(data: any) {
|
||||
return request.post('/banner', data)
|
||||
}
|
||||
|
||||
export function updateBanner(id: string, data: any) {
|
||||
return request.put(`/banner/${id}`, data)
|
||||
}
|
||||
|
||||
export function deleteBanner(id: string) {
|
||||
return request.delete(`/banner/${id}`)
|
||||
}
|
||||
|
||||
export function updateBannerSort(items: { id: string; sortOrder: number }[]) {
|
||||
return request.put('/banner/sort', items)
|
||||
}
|
||||
10
admin/src/api/content.ts
Normal file
10
admin/src/api/content.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import request from '@/utils/request'
|
||||
|
||||
// 内容管理 API
|
||||
export function getContent(key: string) {
|
||||
return request.get(`/content/${key}`)
|
||||
}
|
||||
|
||||
export function updateContent(key: string, data: any) {
|
||||
return request.put(`/content/${key}`, data)
|
||||
}
|
||||
18
admin/src/api/coupon.ts
Normal file
18
admin/src/api/coupon.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import request from '@/utils/request'
|
||||
|
||||
// 优惠券管理 API
|
||||
export function getCoupons(page: number, size: number) {
|
||||
return request.get('/coupon', { params: { page, size } })
|
||||
}
|
||||
|
||||
export function createCoupon(data: any) {
|
||||
return request.post('/coupon', data)
|
||||
}
|
||||
|
||||
export function updateCoupon(id: string, data: any) {
|
||||
return request.put(`/coupon/${id}`, data)
|
||||
}
|
||||
|
||||
export function toggleCouponActive(id: string) {
|
||||
return request.put(`/coupon/${id}/toggle`)
|
||||
}
|
||||
10
admin/src/api/entry.ts
Normal file
10
admin/src/api/entry.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import request from '@/utils/request'
|
||||
|
||||
// 入口图片管理 API
|
||||
export function getEntries() {
|
||||
return request.get('/entry')
|
||||
}
|
||||
|
||||
export function updateEntry(id: string, data: any) {
|
||||
return request.put(`/entry/${id}`, data)
|
||||
}
|
||||
9
admin/src/api/index.ts
Normal file
9
admin/src/api/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// 管理后台 API 模块统一导出
|
||||
export * from './banner'
|
||||
export * from './coupon'
|
||||
export * from './content'
|
||||
export * from './entry'
|
||||
export * from './membership'
|
||||
export * from './points'
|
||||
export * from './stamp'
|
||||
export * from './user'
|
||||
10
admin/src/api/membership.ts
Normal file
10
admin/src/api/membership.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import request from '@/utils/request'
|
||||
|
||||
// 会员商品管理 API
|
||||
export function getMembershipProducts() {
|
||||
return request.get('/membership/products')
|
||||
}
|
||||
|
||||
export function updateMembershipProduct(id: string, data: any) {
|
||||
return request.put(`/membership/products/${id}`, data)
|
||||
}
|
||||
10
admin/src/api/points.ts
Normal file
10
admin/src/api/points.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import request from '@/utils/request'
|
||||
|
||||
// 积分配置 API
|
||||
export function getPointsConfig() {
|
||||
return request.get('/pointsconfig')
|
||||
}
|
||||
|
||||
export function updatePointsConfig(data: { conversionRate: number }) {
|
||||
return request.put('/pointsconfig', data)
|
||||
}
|
||||
22
admin/src/api/stamp.ts
Normal file
22
admin/src/api/stamp.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import request from '@/utils/request'
|
||||
|
||||
// 节日印花管理 API
|
||||
export function getStampCoupons(page: number, size: number) {
|
||||
return request.get('/stamp', { params: { page, size } })
|
||||
}
|
||||
|
||||
export function createStampCoupon(data: any) {
|
||||
return request.post('/stamp', data)
|
||||
}
|
||||
|
||||
export function updateStampCoupon(id: string, data: any) {
|
||||
return request.put(`/stamp/${id}`, data)
|
||||
}
|
||||
|
||||
export function getStampBanner() {
|
||||
return request.get('/stamp/banner')
|
||||
}
|
||||
|
||||
export function updateStampBanner(data: { imageUrlZhCn: string; imageUrlZhTw: string; imageUrlEn: string }) {
|
||||
return request.put('/stamp/banner', data)
|
||||
}
|
||||
10
admin/src/api/user.ts
Normal file
10
admin/src/api/user.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import request from '@/utils/request'
|
||||
|
||||
// 用户管理 API
|
||||
export function getUsers(page: number, size: number, search?: string) {
|
||||
return request.get('/user', { params: { page, size, search } })
|
||||
}
|
||||
|
||||
export function getUserDetail(uid: string) {
|
||||
return request.get(`/user/${uid}`)
|
||||
}
|
||||
87
admin/src/layout/AdminLayout.vue
Normal file
87
admin/src/layout/AdminLayout.vue
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<template>
|
||||
<el-container class="admin-layout">
|
||||
<!-- 侧边栏 -->
|
||||
<el-aside :width="isCollapse ? '64px' : '220px'" class="aside">
|
||||
<div class="logo">
|
||||
<span v-if="!isCollapse">贩卖机管理后台</span>
|
||||
<span v-else>VM</span>
|
||||
</div>
|
||||
<el-menu
|
||||
:default-active="currentRoute"
|
||||
:collapse="isCollapse"
|
||||
router
|
||||
background-color="#304156"
|
||||
text-color="#bfcbd9"
|
||||
active-text-color="#409eff"
|
||||
>
|
||||
<el-menu-item
|
||||
v-for="item in menuItems"
|
||||
:key="item.path"
|
||||
:index="item.path"
|
||||
>
|
||||
<el-icon><component :is="item.icon" /></el-icon>
|
||||
<template #title>{{ item.title }}</template>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<el-container>
|
||||
<!-- 顶部栏 -->
|
||||
<el-header class="header">
|
||||
<el-icon class="collapse-btn" @click="isCollapse = !isCollapse">
|
||||
<Fold v-if="!isCollapse" />
|
||||
<Expand v-else />
|
||||
</el-icon>
|
||||
<div class="header-right">
|
||||
<el-dropdown @command="handleCommand">
|
||||
<span class="admin-user">管理员</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<el-main>
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Fold, Expand } from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const isCollapse = ref(false)
|
||||
|
||||
const currentRoute = computed(() => route.path)
|
||||
|
||||
// 侧边栏菜单项
|
||||
const menuItems = [
|
||||
{ path: '/dashboard', title: '仪表盘', icon: 'Odometer' },
|
||||
{ path: '/banner', title: 'Banner 管理', icon: 'Picture' },
|
||||
{ path: '/entry', title: '入口图片管理', icon: 'Grid' },
|
||||
{ path: '/coupon', title: '优惠券管理', icon: 'Ticket' },
|
||||
{ path: '/stamp', title: '节日印花管理', icon: 'Present' },
|
||||
{ path: '/membership', title: '会员管理', icon: 'UserFilled' },
|
||||
{ path: '/points', title: '积分配置', icon: 'Coin' },
|
||||
{ path: '/user', title: '用户管理', icon: 'User' },
|
||||
{ path: '/content', title: '内容管理', icon: 'Document' },
|
||||
]
|
||||
|
||||
function handleCommand(command: string) {
|
||||
if (command === 'logout') {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
46
admin/src/layout/admin-layout.scss
Normal file
46
admin/src/layout/admin-layout.scss
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
.admin-layout {
|
||||
height: 100vh;
|
||||
|
||||
.aside {
|
||||
background-color: #304156;
|
||||
transition: width 0.3s;
|
||||
overflow: hidden;
|
||||
|
||||
.logo {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
background-color: #263445;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.el-menu {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
padding: 0 20px;
|
||||
|
||||
.collapse-btn {
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
.admin-user {
|
||||
cursor: pointer;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
admin/src/main.ts
Normal file
14
admin/src/main.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus, { locale: undefined }) // 后续可配置中文语言包
|
||||
|
||||
app.mount('#app')
|
||||
91
admin/src/router/index.ts
Normal file
91
admin/src/router/index.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
// 管理后台路由配置
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/login/index.vue'),
|
||||
meta: { requiresAuth: false },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layout/AdminLayout.vue'),
|
||||
redirect: '/dashboard',
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/dashboard/index.vue'),
|
||||
meta: { title: '仪表盘', icon: 'Odometer' },
|
||||
},
|
||||
{
|
||||
path: 'banner',
|
||||
name: 'BannerManage',
|
||||
component: () => import('@/views/banner/index.vue'),
|
||||
meta: { title: 'Banner 管理', icon: 'Picture' },
|
||||
},
|
||||
{
|
||||
path: 'entry',
|
||||
name: 'EntryManage',
|
||||
component: () => import('@/views/entry/index.vue'),
|
||||
meta: { title: '入口图片管理', icon: 'Grid' },
|
||||
},
|
||||
{
|
||||
path: 'coupon',
|
||||
name: 'CouponManage',
|
||||
component: () => import('@/views/coupon/index.vue'),
|
||||
meta: { title: '优惠券管理', icon: 'Ticket' },
|
||||
},
|
||||
{
|
||||
path: 'stamp',
|
||||
name: 'StampManage',
|
||||
component: () => import('@/views/stamp/index.vue'),
|
||||
meta: { title: '节日印花管理', icon: 'Present' },
|
||||
},
|
||||
{
|
||||
path: 'membership',
|
||||
name: 'MembershipManage',
|
||||
component: () => import('@/views/membership/index.vue'),
|
||||
meta: { title: '会员管理', icon: 'UserFilled' },
|
||||
},
|
||||
{
|
||||
path: 'points',
|
||||
name: 'PointsConfig',
|
||||
component: () => import('@/views/points/index.vue'),
|
||||
meta: { title: '积分配置', icon: 'Coin' },
|
||||
},
|
||||
{
|
||||
path: 'user',
|
||||
name: 'UserManage',
|
||||
component: () => import('@/views/user/index.vue'),
|
||||
meta: { title: '用户管理', icon: 'User' },
|
||||
},
|
||||
{
|
||||
path: 'content',
|
||||
name: 'ContentManage',
|
||||
component: () => import('@/views/content/index.vue'),
|
||||
meta: { title: '内容管理', icon: 'Document' },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
// 路由守卫:权限检查
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
if (to.meta.requiresAuth !== false && !token) {
|
||||
next('/login')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
21
admin/src/store/auth.ts
Normal file
21
admin/src/store/auth.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
// 管理员认证状态管理
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref(localStorage.getItem('admin_token') || '')
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
|
||||
function setToken(newToken: string) {
|
||||
token.value = newToken
|
||||
localStorage.setItem('admin_token', newToken)
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = ''
|
||||
localStorage.removeItem('admin_token')
|
||||
}
|
||||
|
||||
return { token, isLoggedIn, setToken, logout }
|
||||
})
|
||||
45
admin/src/utils/request.ts
Normal file
45
admin/src/utils/request.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import router from '@/router'
|
||||
|
||||
// 创建 axios 实例,基础路径指向后端 Admin API
|
||||
const request = axios.create({
|
||||
baseURL: '/api/admin',
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
// 请求拦截器:附加 Token
|
||||
request.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
|
||||
// 响应拦截器:统一错误处理
|
||||
request.interceptors.response.use(
|
||||
(response) => {
|
||||
return response.data
|
||||
},
|
||||
(error) => {
|
||||
const status = error.response?.status
|
||||
if (status === 401) {
|
||||
// Token 过期或未认证,跳转登录页
|
||||
localStorage.removeItem('admin_token')
|
||||
router.push('/login')
|
||||
ElMessage.error('登录已过期,请重新登录')
|
||||
} else if (status === 403) {
|
||||
ElMessage.error('没有操作权限')
|
||||
} else {
|
||||
const msg = error.response?.data?.message || '请求失败'
|
||||
ElMessage.error(msg)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default request
|
||||
259
admin/src/views/banner/index.vue
Normal file
259
admin/src/views/banner/index.vue
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
<template>
|
||||
<div class="banner-manage">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="handleAdd">新增 Banner</el-button>
|
||||
<el-text type="info" size="small">拖拽行可调整排序</el-text>
|
||||
</div>
|
||||
|
||||
<!-- Banner 列表表格(支持拖拽排序) -->
|
||||
<el-table ref="tableRef" :data="bannerList" row-key="id" border style="width: 100%">
|
||||
<el-table-column width="60" align="center" label="排序">
|
||||
<template #default>
|
||||
<el-icon class="drag-handle" style="cursor: move"><Rank /></el-icon>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="简体中文图片" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<el-image
|
||||
v-if="row.imageUrlZhCn"
|
||||
:src="row.imageUrlZhCn"
|
||||
style="width: 120px; height: 60px"
|
||||
fit="cover"
|
||||
:preview-src-list="[row.imageUrlZhCn]"
|
||||
/>
|
||||
<span v-else>未配置</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="繁体中文图片" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<el-image
|
||||
v-if="row.imageUrlZhTw"
|
||||
:src="row.imageUrlZhTw"
|
||||
style="width: 120px; height: 60px"
|
||||
fit="cover"
|
||||
:preview-src-list="[row.imageUrlZhTw]"
|
||||
/>
|
||||
<span v-else>未配置</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="英文图片" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<el-image
|
||||
v-if="row.imageUrlEn"
|
||||
:src="row.imageUrlEn"
|
||||
style="width: 120px; height: 60px"
|
||||
fit="cover"
|
||||
:preview-src-list="[row.imageUrlEn]"
|
||||
/>
|
||||
<span v-else>未配置</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="跳转类型" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.linkType === 'internal'" type="primary">内部页面</el-tag>
|
||||
<el-tag v-else-if="row.linkType === 'external'" type="warning">外部链接</el-tag>
|
||||
<el-tag v-else type="info">无跳转</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="跳转地址" prop="linkUrl" min-width="180" show-overflow-tooltip />
|
||||
|
||||
<el-table-column label="操作" width="180" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-popconfirm title="确定删除此 Banner?" @confirm="handleDelete(row.id)">
|
||||
<template #reference>
|
||||
<el-button size="small" type="danger">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑 Banner' : '新增 Banner'"
|
||||
width="600px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item label="简体中文图片" required>
|
||||
<el-input v-model="form.imageUrlZhCn" placeholder="请输入图片 URL" />
|
||||
</el-form-item>
|
||||
<el-form-item label="繁体中文图片" required>
|
||||
<el-input v-model="form.imageUrlZhTw" placeholder="请输入图片 URL" />
|
||||
</el-form-item>
|
||||
<el-form-item label="英文图片" required>
|
||||
<el-input v-model="form.imageUrlEn" placeholder="请输入图片 URL" />
|
||||
</el-form-item>
|
||||
<el-form-item label="跳转类型">
|
||||
<el-select v-model="form.linkType" style="width: 100%">
|
||||
<el-option label="无跳转" value="none" />
|
||||
<el-option label="内部页面" value="internal" />
|
||||
<el-option label="外部链接" value="external" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.linkType !== 'none'" label="跳转地址">
|
||||
<el-input v-model="form.linkUrl" placeholder="请输入跳转地址" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { Rank } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import Sortable from 'sortablejs'
|
||||
import { getBanners, createBanner, updateBanner, deleteBanner, updateBannerSort } from '@/api/banner'
|
||||
|
||||
// Banner 数据类型
|
||||
interface BannerItem {
|
||||
id: string
|
||||
imageUrlZhCn: string
|
||||
imageUrlZhTw: string
|
||||
imageUrlEn: string
|
||||
linkType: string
|
||||
linkUrl: string | null
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
const tableRef = ref()
|
||||
const bannerList = ref<BannerItem[]>([])
|
||||
const dialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const submitting = ref(false)
|
||||
const editingId = ref('')
|
||||
|
||||
const defaultForm = () => ({
|
||||
imageUrlZhCn: '',
|
||||
imageUrlZhTw: '',
|
||||
imageUrlEn: '',
|
||||
linkType: 'none',
|
||||
linkUrl: '',
|
||||
})
|
||||
const form = ref(defaultForm())
|
||||
|
||||
// 加载 Banner 列表
|
||||
async function loadBanners() {
|
||||
try {
|
||||
const res: any = await getBanners()
|
||||
bannerList.value = res.data || []
|
||||
} catch {
|
||||
// 错误已由拦截器处理
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化拖拽排序
|
||||
function initSortable() {
|
||||
const el = tableRef.value?.$el?.querySelector('.el-table__body-wrapper tbody')
|
||||
if (!el) return
|
||||
Sortable.create(el, {
|
||||
handle: '.drag-handle',
|
||||
animation: 150,
|
||||
onEnd: async ({ oldIndex, newIndex }: { oldIndex?: number; newIndex?: number }) => {
|
||||
if (oldIndex == null || newIndex == null || oldIndex === newIndex) return
|
||||
// 更新本地数组顺序
|
||||
const list = [...bannerList.value]
|
||||
const [moved] = list.splice(oldIndex, 1)
|
||||
list.splice(newIndex, 0, moved)
|
||||
bannerList.value = list
|
||||
// 保存排序到后端
|
||||
const items = list.map((b, i) => ({ id: b.id, sortOrder: i + 1 }))
|
||||
try {
|
||||
await updateBannerSort(items)
|
||||
ElMessage.success('排序已更新')
|
||||
} catch {
|
||||
// 排序失败时重新加载
|
||||
await loadBanners()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 新增
|
||||
function handleAdd() {
|
||||
isEdit.value = false
|
||||
editingId.value = ''
|
||||
form.value = defaultForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑
|
||||
function handleEdit(row: BannerItem) {
|
||||
isEdit.value = true
|
||||
editingId.value = row.id
|
||||
form.value = {
|
||||
imageUrlZhCn: row.imageUrlZhCn,
|
||||
imageUrlZhTw: row.imageUrlZhTw,
|
||||
imageUrlEn: row.imageUrlEn,
|
||||
linkType: row.linkType || 'none',
|
||||
linkUrl: row.linkUrl || '',
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
async function handleSubmit() {
|
||||
if (!form.value.imageUrlZhCn || !form.value.imageUrlZhTw || !form.value.imageUrlEn) {
|
||||
ElMessage.warning('请填写所有语言的图片 URL')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await updateBanner(editingId.value, form.value)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await createBanner(form.value)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await loadBanners()
|
||||
} catch {
|
||||
// 错误已由拦截器处理
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除
|
||||
async function handleDelete(id: string) {
|
||||
try {
|
||||
await deleteBanner(id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadBanners()
|
||||
} catch {
|
||||
// 错误已由拦截器处理
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadBanners()
|
||||
await nextTick()
|
||||
initSortable()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.banner-manage {
|
||||
padding: 20px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
172
admin/src/views/content/index.vue
Normal file
172
admin/src/views/content/index.vue
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
<template>
|
||||
<div class="content-manage">
|
||||
<!-- 内容类型选择标签页 -->
|
||||
<el-tabs v-model="activeKey" @tab-change="handleTabChange">
|
||||
<el-tab-pane
|
||||
v-for="item in contentTypes"
|
||||
:key="item.key"
|
||||
:label="item.label"
|
||||
:name="item.key"
|
||||
/>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 多语言编辑区域 -->
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>{{ currentLabel }} - 多语言内容编辑</span>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getContent, updateContent } from '@/api/content'
|
||||
|
||||
// 可编辑的内容类型
|
||||
const contentTypes = [
|
||||
{ key: 'agreement', label: '用户协议' },
|
||||
{ key: 'privacy-policy', label: '隐私政策' },
|
||||
{ key: 'coupon-guide', label: '优惠券使用说明' },
|
||||
]
|
||||
|
||||
const activeKey = ref('agreement')
|
||||
const activeLang = ref('zhCn')
|
||||
const saving = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
const form = ref({
|
||||
contentZhCn: '',
|
||||
contentZhTw: '',
|
||||
contentEn: '',
|
||||
})
|
||||
|
||||
// 当前选中的内容类型标签
|
||||
const currentLabel = computed(() => {
|
||||
return contentTypes.find(t => t.key === activeKey.value)?.label || ''
|
||||
})
|
||||
|
||||
// 加载内容
|
||||
async function loadContent(key: string) {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await getContent(key)
|
||||
const data = res.data
|
||||
if (data) {
|
||||
form.value = {
|
||||
contentZhCn: data.contentZhCn || '',
|
||||
contentZhTw: data.contentZhTw || '',
|
||||
contentEn: data.contentEn || '',
|
||||
}
|
||||
} else {
|
||||
form.value = { contentZhCn: '', contentZhTw: '', contentEn: '' }
|
||||
}
|
||||
} catch {
|
||||
// 错误已由拦截器处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换标签页时加载对应内容
|
||||
function handleTabChange(key: string | number) {
|
||||
activeLang.value = 'zhCn'
|
||||
loadContent(key as string)
|
||||
}
|
||||
|
||||
// 保存内容
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
await updateContent(activeKey.value, form.value)
|
||||
ElMessage.success('保存成功')
|
||||
} catch {
|
||||
// 错误已由拦截器处理
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadContent(activeKey.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-manage {
|
||||
padding: 20px;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.editor-wrapper {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.preview-section {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.preview-content {
|
||||
padding: 16px;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
background: #fafafa;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
264
admin/src/views/coupon/index.vue
Normal file
264
admin/src/views/coupon/index.vue
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
<template>
|
||||
<div class="coupon-manage">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="handleAdd">新增优惠券</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 优惠券列表表格 -->
|
||||
<el-table :data="couponList" border style="width: 100%">
|
||||
<el-table-column label="名称(简中)" prop="nameZhCn" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="名称(繁中)" prop="nameZhTw" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="名称(英文)" prop="nameEn" min-width="120" show-overflow-tooltip />
|
||||
|
||||
<el-table-column label="类型" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.type === 'ThresholdDiscount'" type="warning">满减券</el-tag>
|
||||
<el-tag v-else type="success">抵扣券</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="满减门槛" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.thresholdAmount != null ? `¥${row.thresholdAmount}` : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="折扣金额" width="100" align="center">
|
||||
<template #default="{ row }">¥{{ row.discountAmount }}</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="所需积分" prop="pointsCost" width="100" align="center" />
|
||||
|
||||
<el-table-column label="到期时间" width="170" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.expireAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.isActive ? 'success' : 'info'">
|
||||
{{ row.isActive ? '上架' : '下架' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="200" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
:type="row.isActive ? 'warning' : 'success'"
|
||||
@click="handleToggle(row.id)"
|
||||
>
|
||||
{{ row.isActive ? '下架' : '上架' }}
|
||||
</el-button>
|
||||
</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="loadCoupons"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑优惠券' : '新增优惠券'"
|
||||
width="620px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item label="名称(简中)" required>
|
||||
<el-input v-model="form.nameZhCn" placeholder="请输入简体中文名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="名称(繁中)" required>
|
||||
<el-input v-model="form.nameZhTw" placeholder="请输入繁体中文名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="名称(英文)" required>
|
||||
<el-input v-model="form.nameEn" placeholder="请输入英文名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="优惠券类型" required>
|
||||
<el-select v-model="form.type" style="width: 100%">
|
||||
<el-option label="满减券" value="ThresholdDiscount" />
|
||||
<el-option label="抵扣券" value="DirectDiscount" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.type === 'ThresholdDiscount'" label="满减门槛">
|
||||
<el-input-number v-model="form.thresholdAmount" :min="0" :precision="2" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="折扣金额" required>
|
||||
<el-input-number v-model="form.discountAmount" :min="0.01" :precision="2" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="所需积分" required>
|
||||
<el-input-number v-model="form.pointsCost" :min="0" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="到期时间" required>
|
||||
<el-date-picker
|
||||
v-model="form.expireAt"
|
||||
type="datetime"
|
||||
placeholder="选择到期时间"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getCoupons, createCoupon, updateCoupon, toggleCouponActive } from '@/api/coupon'
|
||||
|
||||
// 优惠券数据类型
|
||||
interface CouponItem {
|
||||
id: string
|
||||
nameZhCn: string
|
||||
nameZhTw: string
|
||||
nameEn: string
|
||||
type: string
|
||||
thresholdAmount: number | null
|
||||
discountAmount: number
|
||||
pointsCost: number
|
||||
expireAt: string
|
||||
isActive: boolean
|
||||
isStamp: boolean
|
||||
}
|
||||
|
||||
const couponList = ref<CouponItem[]>([])
|
||||
const currentPage = ref(1)
|
||||
const pageSize = 20
|
||||
const total = ref(0)
|
||||
const dialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const submitting = ref(false)
|
||||
const editingId = ref('')
|
||||
|
||||
const defaultForm = () => ({
|
||||
nameZhCn: '',
|
||||
nameZhTw: '',
|
||||
nameEn: '',
|
||||
type: 'DirectDiscount' as string,
|
||||
thresholdAmount: 0 as number | null,
|
||||
discountAmount: 0,
|
||||
pointsCost: 0,
|
||||
expireAt: '' as string | Date,
|
||||
})
|
||||
const form = ref(defaultForm())
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateStr: string) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 加载优惠券列表
|
||||
async function loadCoupons() {
|
||||
try {
|
||||
const res: any = await getCoupons(currentPage.value, pageSize)
|
||||
couponList.value = res.data?.items || res.data || []
|
||||
total.value = res.data?.total || couponList.value.length
|
||||
} catch {
|
||||
// 错误已由拦截器处理
|
||||
}
|
||||
}
|
||||
|
||||
// 新增
|
||||
function handleAdd() {
|
||||
isEdit.value = false
|
||||
editingId.value = ''
|
||||
form.value = defaultForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑
|
||||
function handleEdit(row: CouponItem) {
|
||||
isEdit.value = true
|
||||
editingId.value = row.id
|
||||
form.value = {
|
||||
nameZhCn: row.nameZhCn,
|
||||
nameZhTw: row.nameZhTw,
|
||||
nameEn: row.nameEn,
|
||||
type: row.type,
|
||||
thresholdAmount: row.thresholdAmount,
|
||||
discountAmount: row.discountAmount,
|
||||
pointsCost: row.pointsCost,
|
||||
expireAt: row.expireAt,
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 上下架切换
|
||||
async function handleToggle(id: string) {
|
||||
try {
|
||||
await toggleCouponActive(id)
|
||||
ElMessage.success('操作成功')
|
||||
await loadCoupons()
|
||||
} catch {
|
||||
// 错误已由拦截器处理
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
async function handleSubmit() {
|
||||
if (!form.value.nameZhCn || !form.value.nameZhTw || !form.value.nameEn) {
|
||||
ElMessage.warning('请填写所有语言的名称')
|
||||
return
|
||||
}
|
||||
if (!form.value.expireAt) {
|
||||
ElMessage.warning('请选择到期时间')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload = {
|
||||
...form.value,
|
||||
thresholdAmount: form.value.type === 'ThresholdDiscount' ? form.value.thresholdAmount : null,
|
||||
}
|
||||
if (isEdit.value) {
|
||||
await updateCoupon(editingId.value, payload)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await createCoupon(payload)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await loadCoupons()
|
||||
} catch {
|
||||
// 错误已由拦截器处理
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCoupons()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.coupon-manage {
|
||||
padding: 20px;
|
||||
}
|
||||
.toolbar {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
44
admin/src/views/dashboard/index.vue
Normal file
44
admin/src/views/dashboard/index.vue
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<template>
|
||||
<div>
|
||||
<h2>仪表盘</h2>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover">
|
||||
<template #header>用户总数</template>
|
||||
<div class="stat-value">--</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover">
|
||||
<template #header>会员总数</template>
|
||||
<div class="stat-value">--</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover">
|
||||
<template #header>今日积分发放</template>
|
||||
<div class="stat-value">--</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover">
|
||||
<template #header>优惠券兑换次数</template>
|
||||
<div class="stat-value">--</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// TODO: 对接后端统计接口
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
color: #409eff;
|
||||
}
|
||||
</style>
|
||||
180
admin/src/views/entry/index.vue
Normal file
180
admin/src/views/entry/index.vue
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
<template>
|
||||
<div class="entry-manage">
|
||||
<el-alert
|
||||
title="入口图片管理"
|
||||
description="配置首页各功能入口的多语言图片。入口类型由系统预设,仅支持修改图片。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
|
||||
<el-table :data="entryList" border style="width: 100%">
|
||||
<el-table-column label="入口类型" width="140" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag>{{ typeLabel(row.type) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="简体中文图片" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<el-image
|
||||
v-if="row.imageUrlZhCn"
|
||||
:src="row.imageUrlZhCn"
|
||||
style="width: 120px; height: 80px"
|
||||
fit="cover"
|
||||
:preview-src-list="[row.imageUrlZhCn]"
|
||||
/>
|
||||
<span v-else>未配置</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="繁体中文图片" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<el-image
|
||||
v-if="row.imageUrlZhTw"
|
||||
:src="row.imageUrlZhTw"
|
||||
style="width: 120px; height: 80px"
|
||||
fit="cover"
|
||||
:preview-src-list="[row.imageUrlZhTw]"
|
||||
/>
|
||||
<span v-else>未配置</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="英文图片" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<el-image
|
||||
v-if="row.imageUrlEn"
|
||||
:src="row.imageUrlEn"
|
||||
style="width: 120px; height: 80px"
|
||||
fit="cover"
|
||||
:preview-src-list="[row.imageUrlEn]"
|
||||
/>
|
||||
<span v-else>未配置</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="100" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
title="编辑入口图片"
|
||||
width="600px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item label="入口类型">
|
||||
<el-tag>{{ typeLabel(editingType) }}</el-tag>
|
||||
</el-form-item>
|
||||
<el-form-item label="简体中文图片" required>
|
||||
<el-input v-model="form.imageUrlZhCn" placeholder="请输入图片 URL" />
|
||||
</el-form-item>
|
||||
<el-form-item label="繁体中文图片" required>
|
||||
<el-input v-model="form.imageUrlZhTw" placeholder="请输入图片 URL" />
|
||||
</el-form-item>
|
||||
<el-form-item label="英文图片" required>
|
||||
<el-input v-model="form.imageUrlEn" placeholder="请输入图片 URL" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getEntries, updateEntry } from '@/api/entry'
|
||||
|
||||
// 入口数据类型
|
||||
interface EntryItem {
|
||||
id: string
|
||||
type: string
|
||||
imageUrlZhCn: string
|
||||
imageUrlZhTw: string
|
||||
imageUrlEn: string
|
||||
}
|
||||
|
||||
const entryList = ref<EntryItem[]>([])
|
||||
const dialogVisible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const editingId = ref('')
|
||||
const editingType = ref('')
|
||||
|
||||
const form = ref({
|
||||
imageUrlZhCn: '',
|
||||
imageUrlZhTw: '',
|
||||
imageUrlEn: '',
|
||||
})
|
||||
|
||||
// 入口类型标签映射
|
||||
function typeLabel(type: string): string {
|
||||
const map: Record<string, string> = {
|
||||
membership: '成为会员',
|
||||
stamp: '节日印花',
|
||||
qrcode: '会员二维码',
|
||||
}
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
// 加载入口列表
|
||||
async function loadEntries() {
|
||||
try {
|
||||
const res: any = await getEntries()
|
||||
entryList.value = res.data || []
|
||||
} catch {
|
||||
// 错误已由拦截器处理
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑
|
||||
function handleEdit(row: EntryItem) {
|
||||
editingId.value = row.id
|
||||
editingType.value = row.type
|
||||
form.value = {
|
||||
imageUrlZhCn: row.imageUrlZhCn,
|
||||
imageUrlZhTw: row.imageUrlZhTw,
|
||||
imageUrlEn: row.imageUrlEn,
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 提交
|
||||
async function handleSubmit() {
|
||||
if (!form.value.imageUrlZhCn || !form.value.imageUrlZhTw || !form.value.imageUrlEn) {
|
||||
ElMessage.warning('请填写所有语言的图片 URL')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await updateEntry(editingId.value, form.value)
|
||||
ElMessage.success('更新成功')
|
||||
dialogVisible.value = false
|
||||
await loadEntries()
|
||||
} catch {
|
||||
// 错误已由拦截器处理
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadEntries()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.entry-manage {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
70
admin/src/views/login/index.vue
Normal file
70
admin/src/views/login/index.vue
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<template>
|
||||
<div class="login-container">
|
||||
<el-card class="login-card">
|
||||
<h2>贩卖机管理后台</h2>
|
||||
<el-form :model="form" @submit.prevent="handleLogin">
|
||||
<el-form-item>
|
||||
<el-input v-model="form.username" placeholder="用户名" prefix-icon="User" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
prefix-icon="Lock"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" style="width: 100%" @click="handleLogin">
|
||||
登录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
function handleLogin() {
|
||||
if (!form.username || !form.password) {
|
||||
ElMessage.warning('请输入用户名和密码')
|
||||
return
|
||||
}
|
||||
// TODO: 对接后端管理员登录接口,当前使用本地模拟
|
||||
authStore.setToken('admin-token-placeholder')
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/dashboard')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #2d3a4b;
|
||||
}
|
||||
.login-card {
|
||||
width: 400px;
|
||||
}
|
||||
.login-card h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
162
admin/src/views/membership/index.vue
Normal file
162
admin/src/views/membership/index.vue
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
<template>
|
||||
<div class="membership-manage">
|
||||
<el-table :data="productList" border style="width: 100%">
|
||||
<el-table-column label="会员类型" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.type === 'Monthly'" type="primary">单月会员</el-tag>
|
||||
<el-tag v-else type="success">订阅会员</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="价格" width="120" align="center">
|
||||
<template #default="{ row }">{{ row.currency }} {{ row.price }}</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="时长(天)" prop="durationDays" width="110" align="center" />
|
||||
<el-table-column label="描述(简中)" prop="descriptionZhCn" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="描述(繁中)" prop="descriptionZhTw" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="描述(英文)" prop="descriptionEn" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="Google 商品 ID" prop="googleProductId" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="Apple 商品 ID" prop="appleProductId" min-width="150" show-overflow-tooltip />
|
||||
|
||||
<el-table-column label="操作" width="100" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<el-dialog v-model="dialogVisible" title="编辑会员商品" width="620px" destroy-on-close>
|
||||
<el-form :model="form" label-width="130px">
|
||||
<el-form-item label="会员类型">
|
||||
<el-input :model-value="form.type === 'Monthly' ? '单月会员' : '订阅会员'" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="价格" required>
|
||||
<el-input-number v-model="form.price" :min="0.01" :precision="2" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="货币">
|
||||
<el-input v-model="form.currency" placeholder="如 TWD" />
|
||||
</el-form-item>
|
||||
<el-form-item label="时长(天)" required>
|
||||
<el-input-number v-model="form.durationDays" :min="1" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述(简中)">
|
||||
<el-input v-model="form.descriptionZhCn" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述(繁中)">
|
||||
<el-input v-model="form.descriptionZhTw" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述(英文)">
|
||||
<el-input v-model="form.descriptionEn" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
<el-form-item label="宣传图 URL">
|
||||
<el-input v-model="form.promotionImageUrl" placeholder="请输入宣传图 URL" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Google 商品 ID">
|
||||
<el-input v-model="form.googleProductId" placeholder="Google Play 商品 ID" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Apple 商品 ID">
|
||||
<el-input v-model="form.appleProductId" placeholder="App Store 商品 ID" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getMembershipProducts, updateMembershipProduct } from '@/api/membership'
|
||||
|
||||
// 会员商品数据类型
|
||||
interface ProductItem {
|
||||
id: string
|
||||
type: string
|
||||
price: number
|
||||
currency: string
|
||||
durationDays: number
|
||||
descriptionZhCn: string
|
||||
descriptionZhTw: string
|
||||
descriptionEn: string
|
||||
promotionImageUrl: string
|
||||
googleProductId: string
|
||||
appleProductId: string
|
||||
}
|
||||
|
||||
const productList = ref<ProductItem[]>([])
|
||||
const dialogVisible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const editingId = ref('')
|
||||
|
||||
const defaultForm = () => ({
|
||||
type: '',
|
||||
price: 0,
|
||||
currency: 'TWD',
|
||||
durationDays: 30,
|
||||
descriptionZhCn: '',
|
||||
descriptionZhTw: '',
|
||||
descriptionEn: '',
|
||||
promotionImageUrl: '',
|
||||
googleProductId: '',
|
||||
appleProductId: '',
|
||||
})
|
||||
const form = ref(defaultForm())
|
||||
|
||||
// 加载会员商品列表
|
||||
async function loadProducts() {
|
||||
try {
|
||||
const res: any = await getMembershipProducts()
|
||||
productList.value = res.data || []
|
||||
} catch {
|
||||
// 错误已由拦截器处理
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑
|
||||
function handleEdit(row: ProductItem) {
|
||||
editingId.value = row.id
|
||||
form.value = {
|
||||
type: row.type,
|
||||
price: row.price,
|
||||
currency: row.currency,
|
||||
durationDays: row.durationDays,
|
||||
descriptionZhCn: row.descriptionZhCn || '',
|
||||
descriptionZhTw: row.descriptionZhTw || '',
|
||||
descriptionEn: row.descriptionEn || '',
|
||||
promotionImageUrl: row.promotionImageUrl || '',
|
||||
googleProductId: row.googleProductId || '',
|
||||
appleProductId: row.appleProductId || '',
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 提交
|
||||
async function handleSubmit() {
|
||||
submitting.value = true
|
||||
try {
|
||||
await updateMembershipProduct(editingId.value, form.value)
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
await loadProducts()
|
||||
} catch {
|
||||
// 错误已由拦截器处理
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProducts()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.membership-manage {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
70
admin/src/views/points/index.vue
Normal file
70
admin/src/views/points/index.vue
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<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"
|
||||
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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getPointsConfig, updatePointsConfig } from '@/api/points'
|
||||
|
||||
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 {
|
||||
// 错误已由拦截器处理
|
||||
}
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
await updatePointsConfig({ conversionRate: form.value.conversionRate })
|
||||
ElMessage.success('配置已保存')
|
||||
} catch {
|
||||
// 错误已由拦截器处理
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadConfig()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.points-config {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
287
admin/src/views/stamp/index.vue
Normal file
287
admin/src/views/stamp/index.vue
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
<template>
|
||||
<div class="stamp-manage">
|
||||
<!-- Banner 图配置区域 -->
|
||||
<el-card class="banner-card" shadow="never">
|
||||
<template #header>
|
||||
<span>印花页 Banner 图配置</span>
|
||||
</template>
|
||||
<el-form :model="bannerForm" label-width="120px">
|
||||
<el-form-item label="简体中文图片">
|
||||
<el-input v-model="bannerForm.imageUrlZhCn" placeholder="请输入图片 URL" />
|
||||
</el-form-item>
|
||||
<el-form-item label="繁体中文图片">
|
||||
<el-input v-model="bannerForm.imageUrlZhTw" placeholder="请输入图片 URL" />
|
||||
</el-form-item>
|
||||
<el-form-item label="英文图片">
|
||||
<el-input v-model="bannerForm.imageUrlEn" placeholder="请输入图片 URL" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="bannerSaving" @click="saveBanner">保存 Banner</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 印花优惠券列表 -->
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="handleAdd">新增印花优惠券</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="stampList" border style="width: 100%">
|
||||
<el-table-column label="名称(简中)" prop="nameZhCn" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="名称(繁中)" prop="nameZhTw" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="名称(英文)" prop="nameEn" min-width="120" show-overflow-tooltip />
|
||||
|
||||
<el-table-column label="类型" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.type === 'ThresholdDiscount'" type="warning">满减券</el-tag>
|
||||
<el-tag v-else type="success">抵扣券</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="折扣金额" width="100" align="center">
|
||||
<template #default="{ row }">¥{{ row.discountAmount }}</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="所需积分" prop="pointsCost" width="100" align="center" />
|
||||
|
||||
<el-table-column label="到期时间" width="170" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.expireAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="120" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
|
||||
</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="loadStamps"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑印花优惠券' : '新增印花优惠券'"
|
||||
width="620px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item label="名称(简中)" required>
|
||||
<el-input v-model="form.nameZhCn" placeholder="请输入简体中文名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="名称(繁中)" required>
|
||||
<el-input v-model="form.nameZhTw" placeholder="请输入繁体中文名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="名称(英文)" required>
|
||||
<el-input v-model="form.nameEn" placeholder="请输入英文名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="优惠券类型" required>
|
||||
<el-select v-model="form.type" style="width: 100%">
|
||||
<el-option label="满减券" value="ThresholdDiscount" />
|
||||
<el-option label="抵扣券" value="DirectDiscount" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.type === 'ThresholdDiscount'" label="满减门槛">
|
||||
<el-input-number v-model="form.thresholdAmount" :min="0" :precision="2" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="折扣金额" required>
|
||||
<el-input-number v-model="form.discountAmount" :min="0.01" :precision="2" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="所需积分">
|
||||
<el-input-number v-model="form.pointsCost" :min="0" style="width: 100%" />
|
||||
<el-text type="info" size="small" style="margin-left: 8px">可设为 0 积分</el-text>
|
||||
</el-form-item>
|
||||
<el-form-item label="到期时间" required>
|
||||
<el-date-picker
|
||||
v-model="form.expireAt"
|
||||
type="datetime"
|
||||
placeholder="选择到期时间"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getStampCoupons, createStampCoupon, updateStampCoupon, getStampBanner, updateStampBanner } from '@/api/stamp'
|
||||
|
||||
// 印花优惠券数据类型
|
||||
interface StampItem {
|
||||
id: string
|
||||
nameZhCn: string
|
||||
nameZhTw: string
|
||||
nameEn: string
|
||||
type: string
|
||||
thresholdAmount: number | null
|
||||
discountAmount: number
|
||||
pointsCost: number
|
||||
expireAt: string
|
||||
}
|
||||
|
||||
const stampList = ref<StampItem[]>([])
|
||||
const currentPage = ref(1)
|
||||
const pageSize = 20
|
||||
const total = ref(0)
|
||||
const dialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const submitting = ref(false)
|
||||
const editingId = ref('')
|
||||
|
||||
// Banner 表单
|
||||
const bannerForm = ref({ imageUrlZhCn: '', imageUrlZhTw: '', imageUrlEn: '' })
|
||||
const bannerSaving = ref(false)
|
||||
|
||||
const defaultForm = () => ({
|
||||
nameZhCn: '',
|
||||
nameZhTw: '',
|
||||
nameEn: '',
|
||||
type: 'DirectDiscount' as string,
|
||||
thresholdAmount: 0 as number | null,
|
||||
discountAmount: 0,
|
||||
pointsCost: 0,
|
||||
expireAt: '' as string | Date,
|
||||
})
|
||||
const form = ref(defaultForm())
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateStr: string) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 加载 Banner 配置
|
||||
async function loadBanner() {
|
||||
try {
|
||||
const res: any = await getStampBanner()
|
||||
if (res.data) {
|
||||
bannerForm.value = {
|
||||
imageUrlZhCn: res.data.imageUrlZhCn || '',
|
||||
imageUrlZhTw: res.data.imageUrlZhTw || '',
|
||||
imageUrlEn: res.data.imageUrlEn || '',
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 错误已由拦截器处理
|
||||
}
|
||||
}
|
||||
|
||||
// 保存 Banner
|
||||
async function saveBanner() {
|
||||
bannerSaving.value = true
|
||||
try {
|
||||
await updateStampBanner(bannerForm.value)
|
||||
ElMessage.success('Banner 保存成功')
|
||||
} catch {
|
||||
// 错误已由拦截器处理
|
||||
} finally {
|
||||
bannerSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载印花优惠券列表
|
||||
async function loadStamps() {
|
||||
try {
|
||||
const res: any = await getStampCoupons(currentPage.value, pageSize)
|
||||
stampList.value = res.data?.items || res.data || []
|
||||
total.value = res.data?.total || stampList.value.length
|
||||
} catch {
|
||||
// 错误已由拦截器处理
|
||||
}
|
||||
}
|
||||
|
||||
// 新增
|
||||
function handleAdd() {
|
||||
isEdit.value = false
|
||||
editingId.value = ''
|
||||
form.value = defaultForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑
|
||||
function handleEdit(row: StampItem) {
|
||||
isEdit.value = true
|
||||
editingId.value = row.id
|
||||
form.value = {
|
||||
nameZhCn: row.nameZhCn,
|
||||
nameZhTw: row.nameZhTw,
|
||||
nameEn: row.nameEn,
|
||||
type: row.type,
|
||||
thresholdAmount: row.thresholdAmount,
|
||||
discountAmount: row.discountAmount,
|
||||
pointsCost: row.pointsCost,
|
||||
expireAt: row.expireAt,
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
async function handleSubmit() {
|
||||
if (!form.value.nameZhCn || !form.value.nameZhTw || !form.value.nameEn) {
|
||||
ElMessage.warning('请填写所有语言的名称')
|
||||
return
|
||||
}
|
||||
if (!form.value.expireAt) {
|
||||
ElMessage.warning('请选择到期时间')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload = {
|
||||
...form.value,
|
||||
thresholdAmount: form.value.type === 'ThresholdDiscount' ? form.value.thresholdAmount : null,
|
||||
}
|
||||
if (isEdit.value) {
|
||||
await updateStampCoupon(editingId.value, payload)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await createStampCoupon(payload)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await loadStamps()
|
||||
} catch {
|
||||
// 错误已由拦截器处理
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadBanner()
|
||||
loadStamps()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stamp-manage {
|
||||
padding: 20px;
|
||||
}
|
||||
.banner-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.toolbar {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
193
admin/src/views/user/index.vue
Normal file
193
admin/src/views/user/index.vue
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
<template>
|
||||
<div class="user-manage">
|
||||
<!-- 搜索栏 -->
|
||||
<div class="toolbar">
|
||||
<el-input
|
||||
v-model="searchText"
|
||||
placeholder="搜索手机号或 UID"
|
||||
clearable
|
||||
style="width: 280px"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 用户列表 -->
|
||||
<el-table :data="userList" border style="width: 100%">
|
||||
<el-table-column label="UID" prop="uid" width="150" />
|
||||
<el-table-column label="昵称" prop="nickname" min-width="130" />
|
||||
<el-table-column label="手机号" width="160">
|
||||
<template #default="{ row }">{{ row.areaCode }} {{ row.phone }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="会员状态" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.membershipType === 'Subscription'" type="success">订阅会员</el-tag>
|
||||
<el-tag v-else-if="row.membershipType === 'Monthly'" type="primary">单月会员</el-tag>
|
||||
<el-tag v-else type="info">非会员</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="积分余额" prop="pointsBalance" width="100" align="center" />
|
||||
<el-table-column label="注册时间" width="170" align="center">
|
||||
<template #default="{ row }">{{ formatDate(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="handleDetail(row.uid)">详情</el-button>
|
||||
</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="loadUsers"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 用户详情弹窗 -->
|
||||
<el-dialog v-model="detailVisible" title="用户详情" width="650px" destroy-on-close>
|
||||
<template v-if="userDetail">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="UID">{{ userDetail.uid }}</el-descriptions-item>
|
||||
<el-descriptions-item label="昵称">{{ userDetail.nickname }}</el-descriptions-item>
|
||||
<el-descriptions-item label="手机号">{{ userDetail.areaCode }} {{ userDetail.phone }}</el-descriptions-item>
|
||||
<el-descriptions-item label="会员类型">
|
||||
<el-tag v-if="userDetail.membershipType === 'Subscription'" type="success">订阅会员</el-tag>
|
||||
<el-tag v-else-if="userDetail.membershipType === 'Monthly'" type="primary">单月会员</el-tag>
|
||||
<el-tag v-else type="info">非会员</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="会员到期时间">
|
||||
{{ userDetail.membershipExpireAt ? formatDate(userDetail.membershipExpireAt) : '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="积分余额">{{ userDetail.pointsBalance }}</el-descriptions-item>
|
||||
<el-descriptions-item label="注册时间">{{ formatDate(userDetail.createdAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="语言">{{ userDetail.language }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<!-- 用户优惠券列表 -->
|
||||
<div v-if="userDetail.coupons && userDetail.coupons.length" style="margin-top: 20px">
|
||||
<el-text tag="b">优惠券</el-text>
|
||||
<el-table :data="userDetail.coupons" border style="margin-top: 8px" size="small">
|
||||
<el-table-column label="名称" prop="name" min-width="120" />
|
||||
<el-table-column label="类型" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.type === 'ThresholdDiscount' ? '满减' : '抵扣' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="金额" width="80" align="center">
|
||||
<template #default="{ row }">¥{{ row.discountAmount }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'Available' ? 'success' : row.status === 'Used' ? 'info' : 'danger'" size="small">
|
||||
{{ row.status === 'Available' ? '可使用' : row.status === 'Used' ? '已使用' : '已过期' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="到期时间" width="160" align="center">
|
||||
<template #default="{ row }">{{ formatDate(row.expireAt) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<el-button @click="detailVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getUsers, getUserDetail } from '@/api/user'
|
||||
|
||||
// 用户列表项
|
||||
interface UserItem {
|
||||
uid: string
|
||||
nickname: string
|
||||
phone: string
|
||||
areaCode: string
|
||||
membershipType: string
|
||||
pointsBalance: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// 用户详情
|
||||
interface UserDetailData extends UserItem {
|
||||
membershipExpireAt: string | null
|
||||
language: string
|
||||
coupons: {
|
||||
name: string
|
||||
type: string
|
||||
discountAmount: number
|
||||
status: string
|
||||
expireAt: string
|
||||
}[]
|
||||
}
|
||||
|
||||
const userList = ref<UserItem[]>([])
|
||||
const currentPage = ref(1)
|
||||
const pageSize = 20
|
||||
const total = ref(0)
|
||||
const searchText = ref('')
|
||||
const detailVisible = ref(false)
|
||||
const userDetail = ref<UserDetailData | null>(null)
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateStr: string) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 加载用户列表
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const res: any = await getUsers(currentPage.value, pageSize, searchText.value || undefined)
|
||||
userList.value = res.data?.items || res.data || []
|
||||
total.value = res.data?.total || userList.value.length
|
||||
} catch {
|
||||
// 错误已由拦截器处理
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
function handleSearch() {
|
||||
currentPage.value = 1
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
async function handleDetail(uid: string) {
|
||||
try {
|
||||
const res: any = await getUserDetail(uid)
|
||||
userDetail.value = res.data || null
|
||||
detailVisible.value = true
|
||||
} catch {
|
||||
// 错误已由拦截器处理
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-manage {
|
||||
padding: 20px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
16
admin/tsconfig.app.json
Normal file
16
admin/tsconfig.app.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
25
admin/tsconfig.json
Normal file
25
admin/tsconfig.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"types": ["node"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "env.d.ts", "vite.config.ts"]
|
||||
}
|
||||
1
admin/tsconfig.node.tsbuildinfo
Normal file
1
admin/tsconfig.node.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
1
admin/tsconfig.tsbuildinfo
Normal file
1
admin/tsconfig.tsbuildinfo
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"root":["./src/main.ts","./src/api/banner.ts","./src/api/content.ts","./src/api/coupon.ts","./src/api/index.ts","./src/api/membership.ts","./src/api/points.ts","./src/api/user.ts","./src/router/index.ts","./src/store/auth.ts","./src/utils/request.ts","./src/app.vue","./src/components/helloworld.vue","./src/layout/adminlayout.vue","./src/views/banner/index.vue","./src/views/content/index.vue","./src/views/coupon/index.vue","./src/views/dashboard/index.vue","./src/views/entry/index.vue","./src/views/login/index.vue","./src/views/membership/index.vue","./src/views/points/index.vue","./src/views/stamp/index.vue","./src/views/user/index.vue","./env.d.ts"],"errors":true,"version":"5.8.3"}
|
||||
34
admin/vite.config.ts
Normal file
34
admin/vite.config.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
// Element Plus 自动导入
|
||||
AutoImport({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
}),
|
||||
Components({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3100,
|
||||
// 代理后端 API
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:5082',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
11
backend/VendingMachine.slnx
Normal file
11
backend/VendingMachine.slnx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/VendingMachine.Api/VendingMachine.Api.csproj" />
|
||||
<Project Path="src/VendingMachine.Application/VendingMachine.Application.csproj" />
|
||||
<Project Path="src/VendingMachine.Domain/VendingMachine.Domain.csproj" />
|
||||
<Project Path="src/VendingMachine.Infrastructure/VendingMachine.Infrastructure.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/VendingMachine.Tests/VendingMachine.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using VendingMachine.Domain.Entities;
|
||||
using VendingMachine.Infrastructure.Data;
|
||||
|
||||
namespace VendingMachine.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 管理后台 - Banner 管理控制器
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/admin/banner")]
|
||||
[Authorize]
|
||||
public class AdminBannerController : ControllerBase
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public AdminBannerController(AppDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取所有 Banner(按排序顺序)
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll()
|
||||
{
|
||||
var banners = await _db.Banners
|
||||
.OrderBy(b => b.SortOrder)
|
||||
.ToListAsync();
|
||||
return Ok(new { success = true, data = banners });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建 Banner
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] BannerRequest request)
|
||||
{
|
||||
// 获取当前最大排序值
|
||||
var maxSort = await _db.Banners.AnyAsync()
|
||||
? await _db.Banners.MaxAsync(b => b.SortOrder)
|
||||
: 0;
|
||||
|
||||
var banner = new Banner
|
||||
{
|
||||
ImageUrlZhCn = request.ImageUrlZhCn,
|
||||
ImageUrlZhTw = request.ImageUrlZhTw,
|
||||
ImageUrlEn = request.ImageUrlEn,
|
||||
LinkType = request.LinkType ?? "none",
|
||||
LinkUrl = request.LinkUrl,
|
||||
SortOrder = maxSort + 1
|
||||
};
|
||||
|
||||
_db.Banners.Add(banner);
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(new { success = true, data = banner });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新 Banner
|
||||
/// </summary>
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> Update(string id, [FromBody] BannerRequest request)
|
||||
{
|
||||
var banner = await _db.Banners.FindAsync(id);
|
||||
if (banner == null)
|
||||
return NotFound(new { success = false, message = "Banner 不存在" });
|
||||
|
||||
banner.ImageUrlZhCn = request.ImageUrlZhCn;
|
||||
banner.ImageUrlZhTw = request.ImageUrlZhTw;
|
||||
banner.ImageUrlEn = request.ImageUrlEn;
|
||||
banner.LinkType = request.LinkType ?? "none";
|
||||
banner.LinkUrl = request.LinkUrl;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(new { success = true, data = banner });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除 Banner
|
||||
/// </summary>
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> Delete(string id)
|
||||
{
|
||||
var banner = await _db.Banners.FindAsync(id);
|
||||
if (banner == null)
|
||||
return NotFound(new { success = false, message = "Banner 不存在" });
|
||||
|
||||
_db.Banners.Remove(banner);
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新排序顺序(拖拽排序)
|
||||
/// </summary>
|
||||
[HttpPut("sort")]
|
||||
public async Task<IActionResult> UpdateSortOrder([FromBody] List<SortOrderItem> items)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
var banner = await _db.Banners.FindAsync(item.Id);
|
||||
if (banner != null)
|
||||
banner.SortOrder = item.SortOrder;
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Banner 请求体
|
||||
/// </summary>
|
||||
public class BannerRequest
|
||||
{
|
||||
public string ImageUrlZhCn { get; set; } = string.Empty;
|
||||
public string ImageUrlZhTw { get; set; } = string.Empty;
|
||||
public string ImageUrlEn { get; set; } = string.Empty;
|
||||
public string? LinkType { get; set; }
|
||||
public string? LinkUrl { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 排序项
|
||||
/// </summary>
|
||||
public class SortOrderItem
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using VendingMachine.Domain.Entities;
|
||||
using VendingMachine.Infrastructure.Data;
|
||||
|
||||
namespace VendingMachine.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 管理后台 - 内容管理控制器
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/admin/content")]
|
||||
[Authorize]
|
||||
public class AdminContentController : ControllerBase
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public AdminContentController(AppDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取内容配置(按 key)
|
||||
/// </summary>
|
||||
[HttpGet("{key}")]
|
||||
public async Task<IActionResult> GetContent(string key)
|
||||
{
|
||||
var config = await _db.ContentConfigs.FindAsync(key);
|
||||
if (config == null)
|
||||
{
|
||||
// 返回空内容,前端可直接编辑
|
||||
return Ok(new { success = true, data = new ContentConfig { Key = key } });
|
||||
}
|
||||
return Ok(new { success = true, data = config });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新内容配置(按 key)
|
||||
/// </summary>
|
||||
[HttpPut("{key}")]
|
||||
public async Task<IActionResult> UpdateContent(string key, [FromBody] ContentUpdateRequest request)
|
||||
{
|
||||
var config = await _db.ContentConfigs.FindAsync(key);
|
||||
if (config == null)
|
||||
{
|
||||
// 不存在则创建
|
||||
config = new ContentConfig
|
||||
{
|
||||
Key = key,
|
||||
ContentZhCn = request.ContentZhCn,
|
||||
ContentZhTw = request.ContentZhTw,
|
||||
ContentEn = request.ContentEn,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
_db.ContentConfigs.Add(config);
|
||||
}
|
||||
else
|
||||
{
|
||||
config.ContentZhCn = request.ContentZhCn;
|
||||
config.ContentZhTw = request.ContentZhTw;
|
||||
config.ContentEn = request.ContentEn;
|
||||
config.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 内容更新请求体
|
||||
/// </summary>
|
||||
public class ContentUpdateRequest
|
||||
{
|
||||
public string ContentZhCn { get; set; } = string.Empty;
|
||||
public string ContentZhTw { get; set; } = string.Empty;
|
||||
public string ContentEn { get; set; } = string.Empty;
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using VendingMachine.Domain.Entities;
|
||||
using VendingMachine.Infrastructure.Data;
|
||||
|
||||
namespace VendingMachine.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 管理后台 - 优惠券管理控制器
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/admin/coupon")]
|
||||
[Authorize]
|
||||
public class AdminCouponController : ControllerBase
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public AdminCouponController(AppDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取优惠券列表(分页,不含印花)
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll([FromQuery] int page = 1, [FromQuery] int size = 20)
|
||||
{
|
||||
var query = _db.CouponTemplates.Where(c => !c.IsStamp);
|
||||
var total = await query.CountAsync();
|
||||
var items = await query
|
||||
.OrderByDescending(c => c.ExpireAt)
|
||||
.Skip((page - 1) * size)
|
||||
.Take(size)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new { success = true, data = new { items, total, page, size } });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建优惠券
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CouponRequest request)
|
||||
{
|
||||
var coupon = new CouponTemplate
|
||||
{
|
||||
NameZhCn = request.NameZhCn,
|
||||
NameZhTw = request.NameZhTw,
|
||||
NameEn = request.NameEn,
|
||||
Type = request.Type,
|
||||
ThresholdAmount = request.ThresholdAmount,
|
||||
DiscountAmount = request.DiscountAmount,
|
||||
PointsCost = request.PointsCost,
|
||||
ExpireAt = request.ExpireAt,
|
||||
IsActive = true,
|
||||
IsStamp = false
|
||||
};
|
||||
|
||||
_db.CouponTemplates.Add(coupon);
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(new { success = true, data = coupon });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新优惠券
|
||||
/// </summary>
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> Update(string id, [FromBody] CouponRequest request)
|
||||
{
|
||||
var coupon = await _db.CouponTemplates.FindAsync(id);
|
||||
if (coupon == null)
|
||||
return NotFound(new { success = false, message = "优惠券不存在" });
|
||||
|
||||
coupon.NameZhCn = request.NameZhCn;
|
||||
coupon.NameZhTw = request.NameZhTw;
|
||||
coupon.NameEn = request.NameEn;
|
||||
coupon.Type = request.Type;
|
||||
coupon.ThresholdAmount = request.ThresholdAmount;
|
||||
coupon.DiscountAmount = request.DiscountAmount;
|
||||
coupon.PointsCost = request.PointsCost;
|
||||
coupon.ExpireAt = request.ExpireAt;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(new { success = true, data = coupon });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 上下架切换
|
||||
/// </summary>
|
||||
[HttpPut("{id}/toggle")]
|
||||
public async Task<IActionResult> ToggleActive(string id)
|
||||
{
|
||||
var coupon = await _db.CouponTemplates.FindAsync(id);
|
||||
if (coupon == null)
|
||||
return NotFound(new { success = false, message = "优惠券不存在" });
|
||||
|
||||
coupon.IsActive = !coupon.IsActive;
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(new { success = true, data = coupon });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券请求体
|
||||
/// </summary>
|
||||
public class CouponRequest
|
||||
{
|
||||
public string NameZhCn { get; set; } = string.Empty;
|
||||
public string NameZhTw { get; set; } = string.Empty;
|
||||
public string NameEn { get; set; } = string.Empty;
|
||||
public CouponType Type { get; set; }
|
||||
public decimal? ThresholdAmount { get; set; }
|
||||
public decimal DiscountAmount { get; set; }
|
||||
public int PointsCost { get; set; }
|
||||
public DateTime ExpireAt { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using VendingMachine.Infrastructure.Data;
|
||||
|
||||
namespace VendingMachine.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 管理后台 - 入口图片管理控制器
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/admin/entry")]
|
||||
[Authorize]
|
||||
public class AdminEntryController : ControllerBase
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public AdminEntryController(AppDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取所有入口图片
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll()
|
||||
{
|
||||
var entries = await _db.HomeEntries.ToListAsync();
|
||||
return Ok(new { success = true, data = entries });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新入口图片(按 ID)
|
||||
/// </summary>
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> Update(string id, [FromBody] EntryUpdateRequest request)
|
||||
{
|
||||
var entry = await _db.HomeEntries.FindAsync(id);
|
||||
if (entry == null)
|
||||
return NotFound(new { success = false, message = "入口不存在" });
|
||||
|
||||
entry.ImageUrlZhCn = request.ImageUrlZhCn;
|
||||
entry.ImageUrlZhTw = request.ImageUrlZhTw;
|
||||
entry.ImageUrlEn = request.ImageUrlEn;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(new { success = true, data = entry });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 入口图片更新请求体
|
||||
/// </summary>
|
||||
public class EntryUpdateRequest
|
||||
{
|
||||
public string ImageUrlZhCn { get; set; } = string.Empty;
|
||||
public string ImageUrlZhTw { get; set; } = string.Empty;
|
||||
public string ImageUrlEn { get; set; } = string.Empty;
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using VendingMachine.Infrastructure.Data;
|
||||
|
||||
namespace VendingMachine.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 管理后台 - 会员商品管理控制器
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/admin/membership")]
|
||||
[Authorize]
|
||||
public class AdminMembershipController : ControllerBase
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public AdminMembershipController(AppDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取会员商品列表
|
||||
/// </summary>
|
||||
[HttpGet("products")]
|
||||
public async Task<IActionResult> GetProducts()
|
||||
{
|
||||
var products = await _db.MembershipProducts.ToListAsync();
|
||||
return Ok(new { success = true, data = products });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新会员商品(价格、时长、宣传图、商品 ID 等)
|
||||
/// </summary>
|
||||
[HttpPut("products/{id}")]
|
||||
public async Task<IActionResult> UpdateProduct(string id, [FromBody] UpdateProductRequest request)
|
||||
{
|
||||
var product = await _db.MembershipProducts.FindAsync(id);
|
||||
if (product == null)
|
||||
return NotFound(new { success = false, message = "会员商品不存在" });
|
||||
|
||||
product.Price = request.Price;
|
||||
product.Currency = request.Currency;
|
||||
product.DurationDays = request.DurationDays;
|
||||
product.GoogleProductId = request.GoogleProductId;
|
||||
product.AppleProductId = request.AppleProductId;
|
||||
product.DescriptionZhCn = request.DescriptionZhCn;
|
||||
product.DescriptionZhTw = request.DescriptionZhTw;
|
||||
product.DescriptionEn = request.DescriptionEn;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(new { success = true, data = product });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 会员商品更新请求体
|
||||
/// </summary>
|
||||
public class UpdateProductRequest
|
||||
{
|
||||
public decimal Price { get; set; }
|
||||
public string Currency { get; set; } = "TWD";
|
||||
public int DurationDays { get; set; }
|
||||
public string GoogleProductId { get; set; } = string.Empty;
|
||||
public string AppleProductId { get; set; } = string.Empty;
|
||||
public string DescriptionZhCn { get; set; } = string.Empty;
|
||||
public string DescriptionZhTw { get; set; } = string.Empty;
|
||||
public string DescriptionEn { get; set; } = string.Empty;
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using VendingMachine.Domain.Entities;
|
||||
using VendingMachine.Infrastructure.Data;
|
||||
|
||||
namespace VendingMachine.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 管理后台 - 积分配置控制器
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/admin/pointsconfig")]
|
||||
[Authorize]
|
||||
public class AdminPointsConfigController : ControllerBase
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public AdminPointsConfigController(AppDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取积分转换配置
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetConfig()
|
||||
{
|
||||
var config = await _db.PointsConfigs.FirstOrDefaultAsync();
|
||||
if (config == null)
|
||||
{
|
||||
// 返回默认配置
|
||||
config = new PointsConfig { Id = 1, ConversionRate = 1m };
|
||||
}
|
||||
return Ok(new { success = true, data = config });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新积分转换配置
|
||||
/// </summary>
|
||||
[HttpPut]
|
||||
public async Task<IActionResult> UpdateConfig([FromBody] UpdatePointsConfigRequest request)
|
||||
{
|
||||
var config = await _db.PointsConfigs.FirstOrDefaultAsync();
|
||||
if (config == null)
|
||||
{
|
||||
config = new PointsConfig { ConversionRate = request.ConversionRate };
|
||||
_db.PointsConfigs.Add(config);
|
||||
}
|
||||
else
|
||||
{
|
||||
config.ConversionRate = request.ConversionRate;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(new { success = true, data = config });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分配置更新请求体
|
||||
/// </summary>
|
||||
public class UpdatePointsConfigRequest
|
||||
{
|
||||
public decimal ConversionRate { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using VendingMachine.Domain.Entities;
|
||||
using VendingMachine.Infrastructure.Data;
|
||||
|
||||
namespace VendingMachine.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 管理后台 - 节日印花管理控制器
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/admin/stamp")]
|
||||
[Authorize]
|
||||
public class AdminStampController : ControllerBase
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public AdminStampController(AppDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取印花优惠券列表(分页)
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll([FromQuery] int page = 1, [FromQuery] int size = 20)
|
||||
{
|
||||
var query = _db.CouponTemplates.Where(c => c.IsStamp);
|
||||
var total = await query.CountAsync();
|
||||
var items = await query
|
||||
.OrderByDescending(c => c.ExpireAt)
|
||||
.Skip((page - 1) * size)
|
||||
.Take(size)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new { success = true, data = new { items, total, page, size } });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建印花优惠券
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CouponRequest request)
|
||||
{
|
||||
var stamp = new CouponTemplate
|
||||
{
|
||||
NameZhCn = request.NameZhCn,
|
||||
NameZhTw = request.NameZhTw,
|
||||
NameEn = request.NameEn,
|
||||
Type = request.Type,
|
||||
ThresholdAmount = request.ThresholdAmount,
|
||||
DiscountAmount = request.DiscountAmount,
|
||||
PointsCost = request.PointsCost,
|
||||
ExpireAt = request.ExpireAt,
|
||||
IsActive = true,
|
||||
IsStamp = true
|
||||
};
|
||||
|
||||
_db.CouponTemplates.Add(stamp);
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(new { success = true, data = stamp });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新印花优惠券
|
||||
/// </summary>
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> Update(string id, [FromBody] CouponRequest request)
|
||||
{
|
||||
var stamp = await _db.CouponTemplates.FindAsync(id);
|
||||
if (stamp == null || !stamp.IsStamp)
|
||||
return NotFound(new { success = false, message = "印花优惠券不存在" });
|
||||
|
||||
stamp.NameZhCn = request.NameZhCn;
|
||||
stamp.NameZhTw = request.NameZhTw;
|
||||
stamp.NameEn = request.NameEn;
|
||||
stamp.Type = request.Type;
|
||||
stamp.ThresholdAmount = request.ThresholdAmount;
|
||||
stamp.DiscountAmount = request.DiscountAmount;
|
||||
stamp.PointsCost = request.PointsCost;
|
||||
stamp.ExpireAt = request.ExpireAt;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(new { success = true, data = stamp });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取印花页 Banner 图配置
|
||||
/// </summary>
|
||||
[HttpGet("banner")]
|
||||
public async Task<IActionResult> GetStampBanner()
|
||||
{
|
||||
var config = await _db.ContentConfigs.FindAsync("stamp-banner");
|
||||
if (config == null)
|
||||
return Ok(new { success = true, data = new ContentConfig { Key = "stamp-banner" } });
|
||||
|
||||
return Ok(new { success = true, data = config });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新印花页 Banner 图配置
|
||||
/// </summary>
|
||||
[HttpPut("banner")]
|
||||
public async Task<IActionResult> UpdateStampBanner([FromBody] StampBannerRequest request)
|
||||
{
|
||||
var config = await _db.ContentConfigs.FindAsync("stamp-banner");
|
||||
if (config == null)
|
||||
{
|
||||
config = new ContentConfig
|
||||
{
|
||||
Key = "stamp-banner",
|
||||
ContentZhCn = request.ImageUrlZhCn,
|
||||
ContentZhTw = request.ImageUrlZhTw,
|
||||
ContentEn = request.ImageUrlEn,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
_db.ContentConfigs.Add(config);
|
||||
}
|
||||
else
|
||||
{
|
||||
config.ContentZhCn = request.ImageUrlZhCn;
|
||||
config.ContentZhTw = request.ImageUrlZhTw;
|
||||
config.ContentEn = request.ImageUrlEn;
|
||||
config.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 印花 Banner 请求体
|
||||
/// </summary>
|
||||
public class StampBannerRequest
|
||||
{
|
||||
public string ImageUrlZhCn { get; set; } = string.Empty;
|
||||
public string ImageUrlZhTw { get; set; } = string.Empty;
|
||||
public string ImageUrlEn { get; set; } = string.Empty;
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using VendingMachine.Infrastructure.Data;
|
||||
|
||||
namespace VendingMachine.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 管理后台 - 用户管理控制器
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/admin/user")]
|
||||
[Authorize]
|
||||
public class AdminUserController : ControllerBase
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public AdminUserController(AppDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取用户列表(分页、搜索)
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetUsers(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int size = 20,
|
||||
[FromQuery] string? search = null)
|
||||
{
|
||||
var query = _db.Users.AsQueryable();
|
||||
|
||||
// 按手机号或 UID 搜索
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
query = query.Where(u => u.Phone.Contains(search) || u.Uid.Contains(search));
|
||||
}
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var items = await query
|
||||
.OrderByDescending(u => u.CreatedAt)
|
||||
.Skip((page - 1) * size)
|
||||
.Take(size)
|
||||
.Select(u => new
|
||||
{
|
||||
u.Uid,
|
||||
u.Phone,
|
||||
u.Nickname,
|
||||
u.IsMember,
|
||||
MembershipType = u.MembershipType.ToString(),
|
||||
u.MembershipExpireAt,
|
||||
u.PointsBalance,
|
||||
u.Language,
|
||||
u.CreatedAt
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new { success = true, data = new { items, total, page, size } });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取用户详情
|
||||
/// </summary>
|
||||
[HttpGet("{uid}")]
|
||||
public async Task<IActionResult> GetUserDetail(string uid)
|
||||
{
|
||||
var user = await _db.Users.FirstOrDefaultAsync(u => u.Uid == uid);
|
||||
if (user == null)
|
||||
return NotFound(new { success = false, message = "用户不存在" });
|
||||
|
||||
// 获取用户优惠券数量
|
||||
var couponCount = await _db.UserCoupons.CountAsync(c => c.UserId == uid);
|
||||
|
||||
// 获取用户积分记录数量
|
||||
var pointRecordCount = await _db.PointRecords.CountAsync(p => p.UserId == uid);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
data = new
|
||||
{
|
||||
user.Uid,
|
||||
user.Phone,
|
||||
user.Nickname,
|
||||
user.IsMember,
|
||||
MembershipType = user.MembershipType.ToString(),
|
||||
user.MembershipExpireAt,
|
||||
user.PointsBalance,
|
||||
user.PointsExpireAt,
|
||||
user.Language,
|
||||
user.CreatedAt,
|
||||
CouponCount = couponCount,
|
||||
PointRecordCount = pointRecordCount
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
101
backend/src/VendingMachine.Api/Controllers/ContentController.cs
Normal file
101
backend/src/VendingMachine.Api/Controllers/ContentController.cs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using VendingMachine.Application.Services;
|
||||
|
||||
namespace VendingMachine.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class ContentController : ControllerBase
|
||||
{
|
||||
private readonly IContentService _contentService;
|
||||
|
||||
public ContentController(IContentService contentService)
|
||||
{
|
||||
_contentService = contentService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取首页 Banner 列表
|
||||
/// </summary>
|
||||
[HttpGet("banners")]
|
||||
public async Task<IActionResult> GetHomeBanners()
|
||||
{
|
||||
var lang = GetLanguage();
|
||||
var result = await _contentService.GetHomeBannersAsync(lang);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取首页入口图片列表
|
||||
/// </summary>
|
||||
[HttpGet("entries")]
|
||||
public async Task<IActionResult> GetHomeEntries()
|
||||
{
|
||||
var lang = GetLanguage();
|
||||
var result = await _contentService.GetHomeEntriesAsync(lang);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取优惠券使用说明
|
||||
/// </summary>
|
||||
[HttpGet("coupon-guide")]
|
||||
public async Task<IActionResult> GetCouponGuide()
|
||||
{
|
||||
var lang = GetLanguage();
|
||||
var result = await _contentService.GetCouponGuideAsync(lang);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取会员宣传 Banner
|
||||
/// </summary>
|
||||
[HttpGet("membership-banner")]
|
||||
public async Task<IActionResult> GetMembershipBanner()
|
||||
{
|
||||
var lang = GetLanguage();
|
||||
var result = await _contentService.GetMembershipBannerAsync(lang);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取印花 Banner
|
||||
/// </summary>
|
||||
[HttpGet("stamp-banner")]
|
||||
public async Task<IActionResult> GetStampBanner()
|
||||
{
|
||||
var lang = GetLanguage();
|
||||
var result = await _contentService.GetStampBannerAsync(lang);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取用户协议
|
||||
/// </summary>
|
||||
[HttpGet("agreement")]
|
||||
public async Task<IActionResult> GetAgreement()
|
||||
{
|
||||
var lang = GetLanguage();
|
||||
var result = await _contentService.GetAgreementAsync(lang);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取隐私政策
|
||||
/// </summary>
|
||||
[HttpGet("privacy-policy")]
|
||||
public async Task<IActionResult> GetPrivacyPolicy()
|
||||
{
|
||||
var lang = GetLanguage();
|
||||
var result = await _contentService.GetPrivacyPolicyAsync(lang);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从请求头获取语言设置
|
||||
/// </summary>
|
||||
private string GetLanguage()
|
||||
{
|
||||
return HttpContext.Items["Language"]?.ToString() ?? "zh-CN";
|
||||
}
|
||||
}
|
||||
100
backend/src/VendingMachine.Api/Controllers/CouponController.cs
Normal file
100
backend/src/VendingMachine.Api/Controllers/CouponController.cs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using VendingMachine.Application.Common;
|
||||
using VendingMachine.Application.Services;
|
||||
|
||||
namespace VendingMachine.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class CouponController : ControllerBase
|
||||
{
|
||||
private readonly ICouponService _couponService;
|
||||
|
||||
public CouponController(ICouponService couponService)
|
||||
{
|
||||
_couponService = couponService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取可兑换优惠券列表
|
||||
/// </summary>
|
||||
[HttpGet("redeemable")]
|
||||
public async Task<IActionResult> GetRedeemableCoupons()
|
||||
{
|
||||
var lang = GetLanguage();
|
||||
var result = await _couponService.GetRedeemableCouponsAsync(lang);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 兑换优惠券
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[HttpPost("redeem/{couponId}")]
|
||||
public async Task<IActionResult> RedeemCoupon(string couponId)
|
||||
{
|
||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrEmpty(uid))
|
||||
return Unauthorized(ApiResponse.Fail("未授权"));
|
||||
|
||||
var result = await _couponService.RedeemCouponAsync(uid, couponId);
|
||||
return result.Success ? Ok(result) : BadRequest(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取用户优惠券列表
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[HttpGet("my")]
|
||||
public async Task<IActionResult> GetMyCoupons([FromQuery] string? status)
|
||||
{
|
||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrEmpty(uid))
|
||||
return Unauthorized(ApiResponse.Fail("未授权"));
|
||||
|
||||
var lang = GetLanguage();
|
||||
var result = await _couponService.GetMyCouponsAsync(uid, status, lang);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取印花优惠券列表
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[HttpGet("stamps")]
|
||||
public async Task<IActionResult> GetStampCoupons()
|
||||
{
|
||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrEmpty(uid))
|
||||
return Unauthorized(ApiResponse.Fail("未授权"));
|
||||
|
||||
var lang = GetLanguage();
|
||||
var result = await _couponService.GetStampCouponsAsync(uid, lang);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 兑换印花优惠券
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[HttpPost("stamps/redeem/{stampCouponId}")]
|
||||
public async Task<IActionResult> RedeemStampCoupon(string stampCouponId)
|
||||
{
|
||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrEmpty(uid))
|
||||
return Unauthorized(ApiResponse.Fail("未授权"));
|
||||
|
||||
var result = await _couponService.RedeemStampCouponAsync(uid, stampCouponId);
|
||||
return result.Success ? Ok(result) : BadRequest(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从请求头获取语言设置
|
||||
/// </summary>
|
||||
private string GetLanguage()
|
||||
{
|
||||
return HttpContext.Items["Language"]?.ToString() ?? "zh-CN";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using VendingMachine.Application.Common;
|
||||
using VendingMachine.Application.DTOs.Membership;
|
||||
using VendingMachine.Application.Services;
|
||||
|
||||
namespace VendingMachine.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class MembershipController : ControllerBase
|
||||
{
|
||||
private readonly IMembershipService _membershipService;
|
||||
|
||||
public MembershipController(IMembershipService membershipService)
|
||||
{
|
||||
_membershipService = membershipService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前用户会员信息
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[HttpGet("info")]
|
||||
public async Task<IActionResult> GetMembershipInfo()
|
||||
{
|
||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrEmpty(uid))
|
||||
return Unauthorized(ApiResponse.Fail("未授权"));
|
||||
|
||||
var result = await _membershipService.GetMembershipInfoAsync(uid);
|
||||
return result.Success ? Ok(result) : NotFound(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取会员商品列表
|
||||
/// </summary>
|
||||
[HttpGet("products")]
|
||||
public async Task<IActionResult> GetProducts([FromQuery] string lang = "zh-CN")
|
||||
{
|
||||
var result = await _membershipService.GetProductsAsync(lang);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 购买单月会员
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[HttpPost("purchase")]
|
||||
public async Task<IActionResult> Purchase([FromBody] PurchaseRequest request)
|
||||
{
|
||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrEmpty(uid))
|
||||
return Unauthorized(ApiResponse.Fail("未授权"));
|
||||
|
||||
var result = await _membershipService.PurchaseAsync(uid, request);
|
||||
return result.Success ? Ok(result) : BadRequest(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 订阅会员
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[HttpPost("subscribe")]
|
||||
public async Task<IActionResult> Subscribe([FromBody] SubscribeRequest request)
|
||||
{
|
||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrEmpty(uid))
|
||||
return Unauthorized(ApiResponse.Fail("未授权"));
|
||||
|
||||
var result = await _membershipService.SubscribeAsync(uid, request);
|
||||
return result.Success ? Ok(result) : BadRequest(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取订阅状态
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[HttpGet("subscription-status")]
|
||||
public async Task<IActionResult> GetSubscriptionStatus()
|
||||
{
|
||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrEmpty(uid))
|
||||
return Unauthorized(ApiResponse.Fail("未授权"));
|
||||
|
||||
var result = await _membershipService.GetSubscriptionStatusAsync(uid);
|
||||
return result.Success ? Ok(result) : NotFound(result);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using VendingMachine.Application.Common;
|
||||
using VendingMachine.Application.DTOs.Points;
|
||||
using VendingMachine.Application.Services;
|
||||
|
||||
namespace VendingMachine.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class PointsController : ControllerBase
|
||||
{
|
||||
private readonly IPointsService _pointsService;
|
||||
|
||||
public PointsController(IPointsService pointsService)
|
||||
{
|
||||
_pointsService = pointsService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前用户积分余额
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[HttpGet("balance")]
|
||||
public async Task<IActionResult> GetBalance()
|
||||
{
|
||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrEmpty(uid))
|
||||
return Unauthorized(ApiResponse.Fail("未授权"));
|
||||
|
||||
var result = await _pointsService.GetBalanceAsync(uid);
|
||||
return result.Success ? Ok(result) : NotFound(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取积分获取记录
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[HttpGet("earn-records")]
|
||||
public async Task<IActionResult> GetEarnRecords([FromQuery] int page = 1, [FromQuery] int size = 20)
|
||||
{
|
||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrEmpty(uid))
|
||||
return Unauthorized(ApiResponse.Fail("未授权"));
|
||||
|
||||
var result = await _pointsService.GetEarnRecordsAsync(uid, page, size);
|
||||
return result.Success ? Ok(result) : NotFound(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取积分使用记录
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[HttpGet("spend-records")]
|
||||
public async Task<IActionResult> GetSpendRecords([FromQuery] int page = 1, [FromQuery] int size = 20)
|
||||
{
|
||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrEmpty(uid))
|
||||
return Unauthorized(ApiResponse.Fail("未授权"));
|
||||
|
||||
var result = await _pointsService.GetSpendRecordsAsync(uid, page, size);
|
||||
return result.Success ? Ok(result) : NotFound(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 赠送积分给其他用户
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[HttpPost("gift")]
|
||||
public async Task<IActionResult> GiftPoints([FromBody] GiftPointsRequest request)
|
||||
{
|
||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrEmpty(uid))
|
||||
return Unauthorized(ApiResponse.Fail("未授权"));
|
||||
|
||||
var result = await _pointsService.GiftPointsAsync(uid, request);
|
||||
return result.Success ? Ok(result) : BadRequest(result);
|
||||
}
|
||||
}
|
||||
85
backend/src/VendingMachine.Api/Controllers/UserController.cs
Normal file
85
backend/src/VendingMachine.Api/Controllers/UserController.cs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using VendingMachine.Application.Common;
|
||||
using VendingMachine.Application.DTOs.User;
|
||||
using VendingMachine.Application.Services;
|
||||
|
||||
namespace VendingMachine.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class UserController : ControllerBase
|
||||
{
|
||||
private readonly IUserService _userService;
|
||||
|
||||
public UserController(IUserService userService)
|
||||
{
|
||||
_userService = userService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发送验证码
|
||||
/// </summary>
|
||||
[HttpPost("send-code")]
|
||||
public async Task<IActionResult> SendVerificationCode([FromBody] SendCodeRequest request)
|
||||
{
|
||||
var result = await _userService.SendVerificationCodeAsync(request);
|
||||
return result.Success ? Ok(result) : BadRequest(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 手机号验证码登录
|
||||
/// </summary>
|
||||
[HttpPost("login")]
|
||||
public async Task<IActionResult> Login([FromBody] LoginRequest request)
|
||||
{
|
||||
var result = await _userService.LoginAsync(request);
|
||||
return result.Success ? Ok(result) : BadRequest(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前用户信息
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[HttpGet("info")]
|
||||
public async Task<IActionResult> GetUserInfo()
|
||||
{
|
||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrEmpty(uid))
|
||||
return Unauthorized(ApiResponse.Fail("未授权"));
|
||||
|
||||
var result = await _userService.GetUserInfoAsync(uid);
|
||||
return result.Success ? Ok(result) : NotFound(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 退出登录
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[HttpPost("logout")]
|
||||
public async Task<IActionResult> Logout()
|
||||
{
|
||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrEmpty(uid))
|
||||
return Unauthorized(ApiResponse.Fail("未授权"));
|
||||
|
||||
var result = await _userService.LogoutAsync(uid);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 注销账号
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[HttpDelete("account")]
|
||||
public async Task<IActionResult> DeleteAccount()
|
||||
{
|
||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrEmpty(uid))
|
||||
return Unauthorized(ApiResponse.Fail("未授权"));
|
||||
|
||||
var result = await _userService.DeleteAccountAsync(uid);
|
||||
return result.Success ? Ok(result) : NotFound(result);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using VendingMachine.Application.Common;
|
||||
using VendingMachine.Application.DTOs.Vending;
|
||||
using VendingMachine.Application.Services;
|
||||
|
||||
namespace VendingMachine.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/vending")]
|
||||
public class VendingMachineController : ControllerBase
|
||||
{
|
||||
private readonly IVendingMachineService _vendingService;
|
||||
|
||||
public VendingMachineController(IVendingMachineService vendingService)
|
||||
{
|
||||
_vendingService = vendingService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成会员动态二维码(移动端调用)
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[HttpPost("qrcode")]
|
||||
public async Task<IActionResult> GenerateQrcode()
|
||||
{
|
||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrEmpty(uid))
|
||||
return Unauthorized(ApiResponse.Fail("未授权"));
|
||||
|
||||
var result = await _vendingService.GenerateQrcodeAsync(uid);
|
||||
return result.Success ? Ok(result) : BadRequest(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通过二维码获取用户信息(贩卖机调用)
|
||||
/// </summary>
|
||||
[HttpPost("user-info")]
|
||||
public async Task<IActionResult> GetUserByQrcode([FromBody] QrcodeRequest request)
|
||||
{
|
||||
// 贩卖机通过请求头传递机器 ID
|
||||
var machineId = Request.Headers["X-Machine-Id"].FirstOrDefault() ?? "unknown";
|
||||
|
||||
var result = await _vendingService.GetUserByQrcodeAsync(request, machineId);
|
||||
return result.Success ? Ok(result) : BadRequest(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 贩卖机支付回调
|
||||
/// </summary>
|
||||
[HttpPost("payment-callback")]
|
||||
public async Task<IActionResult> ReportPayment([FromBody] VendingPaymentPayload payload)
|
||||
{
|
||||
var result = await _vendingService.ReportPaymentAsync(payload);
|
||||
return result.Success ? Ok(result) : BadRequest(result);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using VendingMachine.Application.Common;
|
||||
|
||||
namespace VendingMachine.Api.Middleware;
|
||||
|
||||
public class ExceptionHandlingMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
|
||||
|
||||
public ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _next(context);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unhandled exception occurred");
|
||||
await HandleExceptionAsync(context, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task HandleExceptionAsync(HttpContext context, Exception exception)
|
||||
{
|
||||
context.Response.ContentType = "application/json";
|
||||
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
|
||||
|
||||
var response = ApiResponse.Fail(exception.Message);
|
||||
var json = JsonSerializer.Serialize(response, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
await context.Response.WriteAsync(json);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
namespace VendingMachine.Api.Middleware;
|
||||
|
||||
public class LanguageMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private static readonly HashSet<string> SupportedLanguages = ["zh-CN", "zh-TW", "en"];
|
||||
|
||||
public LanguageMiddleware(RequestDelegate next)
|
||||
{
|
||||
_next = next;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
var lang = context.Request.Headers.AcceptLanguage.FirstOrDefault();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(lang) || !SupportedLanguages.Contains(lang))
|
||||
{
|
||||
lang = "zh-CN";
|
||||
}
|
||||
|
||||
context.Items["Language"] = lang;
|
||||
await _next(context);
|
||||
}
|
||||
}
|
||||
87
backend/src/VendingMachine.Api/Program.cs
Normal file
87
backend/src/VendingMachine.Api/Program.cs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
using System.Text;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StackExchange.Redis;
|
||||
using VendingMachine.Api.Middleware;
|
||||
using VendingMachine.Application.Services;
|
||||
using VendingMachine.Infrastructure.Data;
|
||||
using VendingMachine.Infrastructure.Services;
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// EF Core + SQL Server
|
||||
builder.Services.AddDbContext<AppDbContext>(options =>
|
||||
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||
|
||||
// Redis
|
||||
builder.Services.AddSingleton<IConnectionMultiplexer>(
|
||||
ConnectionMultiplexer.Connect(builder.Configuration.GetConnectionString("Redis")!));
|
||||
|
||||
// JWT Authentication
|
||||
var jwtSection = builder.Configuration.GetSection("Jwt");
|
||||
var key = Encoding.UTF8.GetBytes(jwtSection["Secret"]!);
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
})
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = jwtSection["Issuer"],
|
||||
ValidAudience = jwtSection["Audience"],
|
||||
IssuerSigningKey = new SymmetricSecurityKey(key)
|
||||
};
|
||||
});
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
// Controllers
|
||||
builder.Services.AddControllers();
|
||||
|
||||
// CORS 配置(支持移动端和管理后台跨域请求)
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(policy =>
|
||||
{
|
||||
policy.AllowAnyOrigin()
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyHeader();
|
||||
});
|
||||
});
|
||||
|
||||
// 注册业务服务
|
||||
builder.Services.AddScoped<IUserService, UserService>();
|
||||
builder.Services.AddScoped<IMembershipService, MembershipService>();
|
||||
builder.Services.AddScoped<IPointsService, PointsService>();
|
||||
builder.Services.AddScoped<ICouponService, CouponService>();
|
||||
builder.Services.AddScoped<IVendingMachineService, VendingMachineService>();
|
||||
builder.Services.AddScoped<IContentService, ContentService>();
|
||||
builder.Services.AddSingleton<IRedisStore, RedisStore>();
|
||||
builder.Services.AddSingleton<IVerificationCodeStore, RedisVerificationCodeStore>();
|
||||
|
||||
// Swagger/OpenAPI
|
||||
builder.Services.AddSwaggerGen();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Swagger UI (development only)
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseCors();
|
||||
app.UseMiddleware<ExceptionHandlingMiddleware>();
|
||||
app.UseMiddleware<LanguageMiddleware>();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5082",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:7298;http://localhost:5082",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
24
backend/src/VendingMachine.Api/VendingMachine.Api.csproj
Normal file
24
backend/src/VendingMachine.Api/VendingMachine.Api.csproj
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\VendingMachine.Application\VendingMachine.Application.csproj" />
|
||||
<ProjectReference Include="..\VendingMachine.Infrastructure\VendingMachine.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.OpenApi" Version="3.5.1" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
19
backend/src/VendingMachine.Api/appsettings.json
Normal file
19
backend/src/VendingMachine.Api/appsettings.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Server=localhost;Database=VendingMachineDb;Trusted_Connection=True;TrustServerCertificate=True;",
|
||||
"Redis": "localhost:6379"
|
||||
},
|
||||
"Jwt": {
|
||||
"Secret": "YourSuperSecretKeyHere_ChangeInProduction_AtLeast32Chars!",
|
||||
"Issuer": "VendingMachine.Api",
|
||||
"Audience": "VendingMachine.App",
|
||||
"ExpirationMinutes": 10080
|
||||
}
|
||||
}
|
||||
BIN
backend/src/VendingMachine.Api/bin/Debug/net10.0/Azure.Core.dll
Normal file
BIN
backend/src/VendingMachine.Api/bin/Debug/net10.0/Azure.Core.dll
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,68 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<startup>
|
||||
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
|
||||
</startup>
|
||||
<runtime>
|
||||
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="Microsoft.Build" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-15.1.0.0" newVersion="15.1.0.0" />
|
||||
</dependentAssembly>
|
||||
</assemblyBinding>
|
||||
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="Microsoft.Build.Framework" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-15.1.0.0" newVersion="15.1.0.0" />
|
||||
</dependentAssembly>
|
||||
</assemblyBinding>
|
||||
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="Microsoft.Build.Utilities.Core" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-15.1.0.0" newVersion="15.1.0.0" />
|
||||
</dependentAssembly>
|
||||
</assemblyBinding>
|
||||
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="Microsoft.Build.Tasks.Core" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-15.1.0.0" newVersion="15.1.0.0" />
|
||||
</dependentAssembly>
|
||||
</assemblyBinding>
|
||||
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="Microsoft.IO.Redist" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-6.1.0.0" newVersion="6.1.0.0" />
|
||||
</dependentAssembly>
|
||||
</assemblyBinding>
|
||||
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.Buffers" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-4.0.4.0" newVersion="4.0.4.0" />
|
||||
</dependentAssembly>
|
||||
</assemblyBinding>
|
||||
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.Collections.Immutable" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-9.0.0.0" newVersion="9.0.0.0" />
|
||||
</dependentAssembly>
|
||||
</assemblyBinding>
|
||||
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.Memory" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-4.0.2.0" newVersion="4.0.2.0" />
|
||||
</dependentAssembly>
|
||||
</assemblyBinding>
|
||||
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.Runtime.CompilerServices.Unsafe" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-6.0.1.0" newVersion="6.0.1.0" />
|
||||
</dependentAssembly>
|
||||
</assemblyBinding>
|
||||
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.Threading.Tasks.Extensions" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-4.2.1.0" newVersion="4.2.1.0" />
|
||||
</dependentAssembly>
|
||||
</assemblyBinding>
|
||||
</runtime>
|
||||
</configuration>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,171 @@
|
|||
{
|
||||
"runtimeTarget": {
|
||||
"name": ".NETCoreApp,Version=v8.0",
|
||||
"signature": ""
|
||||
},
|
||||
"compilationOptions": {},
|
||||
"targets": {
|
||||
".NETCoreApp,Version=v8.0": {
|
||||
"Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost/5.0.0-2.25567.12": {
|
||||
"dependencies": {
|
||||
"Microsoft.Build.Locator": "1.10.2",
|
||||
"Newtonsoft.Json": "13.0.3",
|
||||
"System.Collections.Immutable": "9.0.0",
|
||||
"System.CommandLine": "2.0.0-rtm.25509.106"
|
||||
},
|
||||
"runtime": {
|
||||
"Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.dll": {}
|
||||
},
|
||||
"resources": {
|
||||
"cs/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
|
||||
"locale": "cs"
|
||||
},
|
||||
"de/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
|
||||
"locale": "de"
|
||||
},
|
||||
"es/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
|
||||
"locale": "es"
|
||||
},
|
||||
"fr/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
|
||||
"locale": "fr"
|
||||
},
|
||||
"it/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
|
||||
"locale": "it"
|
||||
},
|
||||
"ja/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
|
||||
"locale": "ja"
|
||||
},
|
||||
"ko/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
|
||||
"locale": "ko"
|
||||
},
|
||||
"pl/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
|
||||
"locale": "pl"
|
||||
},
|
||||
"pt-BR/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
|
||||
"locale": "pt-BR"
|
||||
},
|
||||
"ru/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
|
||||
"locale": "ru"
|
||||
},
|
||||
"tr/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
|
||||
"locale": "tr"
|
||||
},
|
||||
"zh-Hans/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
|
||||
"locale": "zh-Hans"
|
||||
},
|
||||
"zh-Hant/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
|
||||
"locale": "zh-Hant"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.Build.Locator/1.10.2": {
|
||||
"runtime": {
|
||||
"lib/net8.0/Microsoft.Build.Locator.dll": {
|
||||
"assemblyVersion": "1.0.0.0",
|
||||
"fileVersion": "1.10.2.26959"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Newtonsoft.Json/13.0.3": {
|
||||
"runtime": {
|
||||
"lib/net6.0/Newtonsoft.Json.dll": {
|
||||
"assemblyVersion": "13.0.0.0",
|
||||
"fileVersion": "13.0.3.27908"
|
||||
}
|
||||
}
|
||||
},
|
||||
"System.Collections.Immutable/9.0.0": {
|
||||
"runtime": {
|
||||
"lib/net8.0/System.Collections.Immutable.dll": {
|
||||
"assemblyVersion": "9.0.0.0",
|
||||
"fileVersion": "9.0.24.52809"
|
||||
}
|
||||
}
|
||||
},
|
||||
"System.CommandLine/2.0.0-rtm.25509.106": {
|
||||
"runtime": {
|
||||
"lib/net8.0/System.CommandLine.dll": {
|
||||
"assemblyVersion": "2.0.0.0",
|
||||
"fileVersion": "2.0.25.51006"
|
||||
}
|
||||
},
|
||||
"resources": {
|
||||
"lib/net8.0/cs/System.CommandLine.resources.dll": {
|
||||
"locale": "cs"
|
||||
},
|
||||
"lib/net8.0/de/System.CommandLine.resources.dll": {
|
||||
"locale": "de"
|
||||
},
|
||||
"lib/net8.0/es/System.CommandLine.resources.dll": {
|
||||
"locale": "es"
|
||||
},
|
||||
"lib/net8.0/fr/System.CommandLine.resources.dll": {
|
||||
"locale": "fr"
|
||||
},
|
||||
"lib/net8.0/it/System.CommandLine.resources.dll": {
|
||||
"locale": "it"
|
||||
},
|
||||
"lib/net8.0/ja/System.CommandLine.resources.dll": {
|
||||
"locale": "ja"
|
||||
},
|
||||
"lib/net8.0/ko/System.CommandLine.resources.dll": {
|
||||
"locale": "ko"
|
||||
},
|
||||
"lib/net8.0/pl/System.CommandLine.resources.dll": {
|
||||
"locale": "pl"
|
||||
},
|
||||
"lib/net8.0/pt-BR/System.CommandLine.resources.dll": {
|
||||
"locale": "pt-BR"
|
||||
},
|
||||
"lib/net8.0/ru/System.CommandLine.resources.dll": {
|
||||
"locale": "ru"
|
||||
},
|
||||
"lib/net8.0/tr/System.CommandLine.resources.dll": {
|
||||
"locale": "tr"
|
||||
},
|
||||
"lib/net8.0/zh-Hans/System.CommandLine.resources.dll": {
|
||||
"locale": "zh-Hans"
|
||||
},
|
||||
"lib/net8.0/zh-Hant/System.CommandLine.resources.dll": {
|
||||
"locale": "zh-Hant"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"libraries": {
|
||||
"Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost/5.0.0-2.25567.12": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Microsoft.Build.Locator/1.10.2": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-F+nLS7IpgtslyxNvtD6Jalnf5WU08lu8yfJBNQl3cbEF3AMUphs4t7nPuRYaaU8QZyGrqtVi7i73LhAe/yHx7A==",
|
||||
"path": "microsoft.build.locator/1.10.2",
|
||||
"hashPath": "microsoft.build.locator.1.10.2.nupkg.sha512"
|
||||
},
|
||||
"Newtonsoft.Json/13.0.3": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==",
|
||||
"path": "newtonsoft.json/13.0.3",
|
||||
"hashPath": "newtonsoft.json.13.0.3.nupkg.sha512"
|
||||
},
|
||||
"System.Collections.Immutable/9.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-QhkXUl2gNrQtvPmtBTQHb0YsUrDiDQ2QS09YbtTTiSjGcf7NBqtYbrG/BE06zcBPCKEwQGzIv13IVdXNOSub2w==",
|
||||
"path": "system.collections.immutable/9.0.0",
|
||||
"hashPath": "system.collections.immutable.9.0.0.nupkg.sha512"
|
||||
},
|
||||
"System.CommandLine/2.0.0-rtm.25509.106": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-IdCQOFNHQfK0hu3tzWOHFJLMaiEOR/4OynmOh+IfukrTIsCR4TTDm7lpuXQyMZ0eRfIyUcz06gHGJNlILAq/6A==",
|
||||
"path": "system.commandline/2.0.0-rtm.25509.106",
|
||||
"hashPath": "system.commandline.2.0.0-rtm.25509.106.nupkg.sha512"
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user