diff --git a/backend/kong/kong.yml b/backend/kong/kong.yml index ecd431b..a05f4b4 100644 --- a/backend/kong/kong.yml +++ b/backend/kong/kong.yml @@ -82,6 +82,18 @@ services: paths: - /api/v1/issuers strip_path: false + - name: issuer-me-routes + paths: + - /api/v1/issuers/me + strip_path: false + - name: redemption-routes + paths: + - /api/v1/redemptions + strip_path: false + - name: coupon-batch-routes + paths: + - /api/v1/coupons/batch + strip_path: false - name: admin-issuer-routes paths: - /api/v1/admin/issuers @@ -107,6 +119,14 @@ services: paths: - /api/v1/trades strip_path: false + - name: trades-my-routes + paths: + - /api/v1/trades/my + strip_path: false + - name: trades-coupon-transfer-routes + paths: + - /api/v1/trades/coupons + strip_path: false - name: market-maker-routes paths: - /api/v1/mm @@ -183,10 +203,26 @@ services: paths: - /api/v1/notifications strip_path: false + - name: announcement-routes + paths: + - /api/v1/announcements + strip_path: false + - name: device-token-routes + paths: + - /api/v1/device-tokens + strip_path: false - name: admin-notification-routes paths: - /api/v1/admin/notifications strip_path: false + - name: admin-announcement-routes + paths: + - /api/v1/admin/announcements + strip_path: false + - name: admin-user-tag-routes + paths: + - /api/v1/admin/user-tags + strip_path: false # --- chain-indexer (Go :3009) --- - name: chain-indexer diff --git a/backend/migrations/032_create_stores_employees_redemptions.sql b/backend/migrations/032_create_stores_employees_redemptions.sql new file mode 100644 index 0000000..3bd822c --- /dev/null +++ b/backend/migrations/032_create_stores_employees_redemptions.sql @@ -0,0 +1,93 @@ +-- Migration 032: Create/update stores, employees, redemptions tables +-- Date: 2026-02-22 + +-- ============================================================ +-- 1. ALTER stores table: add level and parent_id columns +-- ============================================================ +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'stores' AND column_name = 'level' + ) THEN + ALTER TABLE stores ADD COLUMN level VARCHAR(50) NOT NULL DEFAULT 'store'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'stores' AND column_name = 'parent_id' + ) THEN + ALTER TABLE stores ADD COLUMN parent_id UUID NULL; + END IF; +END $$; + +-- Index for parent_id on stores +CREATE INDEX IF NOT EXISTS idx_stores_parent ON stores (parent_id); + +-- Self-referencing FK (store hierarchy) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_name = 'fk_stores_parent' AND table_name = 'stores' + ) THEN + ALTER TABLE stores ADD CONSTRAINT fk_stores_parent + FOREIGN KEY (parent_id) REFERENCES stores(id) ON DELETE SET NULL; + END IF; +END $$; + +-- ============================================================ +-- 2. CREATE employees table +-- ============================================================ +CREATE TABLE IF NOT EXISTS employees ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + issuer_id UUID NOT NULL REFERENCES issuers(id) ON DELETE CASCADE, + store_id UUID NULL REFERENCES stores(id) ON DELETE SET NULL, + name VARCHAR(100) NOT NULL, + phone VARCHAR(20), + role VARCHAR(50) NOT NULL DEFAULT 'staff', + status VARCHAR(20) NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_employees_issuer ON employees (issuer_id); +CREATE INDEX IF NOT EXISTS idx_employees_store ON employees (store_id); + +-- ============================================================ +-- 3. CREATE redemptions table +-- ============================================================ +CREATE TABLE IF NOT EXISTS redemptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + coupon_id UUID NOT NULL REFERENCES coupons(id) ON DELETE CASCADE, + issuer_id UUID NOT NULL REFERENCES issuers(id) ON DELETE CASCADE, + user_id UUID NOT NULL, + store_id UUID NULL REFERENCES stores(id) ON DELETE SET NULL, + method VARCHAR(20) NOT NULL, + amount NUMERIC(12,2) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'completed', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_redemptions_coupon ON redemptions (coupon_id); +CREATE INDEX IF NOT EXISTS idx_redemptions_issuer ON redemptions (issuer_id); +CREATE INDEX IF NOT EXISTS idx_redemptions_user ON redemptions (user_id); +CREATE INDEX IF NOT EXISTS idx_redemptions_store ON redemptions (store_id); +CREATE INDEX IF NOT EXISTS idx_redemptions_created ON redemptions (created_at); + +-- ============================================================ +-- 4. Update trigger for employees.updated_at +-- ============================================================ +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_employees_updated_at ON employees; +CREATE TRIGGER trg_employees_updated_at + BEFORE UPDATE ON employees + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); diff --git a/backend/services/auth-service/src/application/services/auth.service.ts b/backend/services/auth-service/src/application/services/auth.service.ts index d934962..3b5cc35 100644 --- a/backend/services/auth-service/src/application/services/auth.service.ts +++ b/backend/services/auth-service/src/application/services/auth.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, UnauthorizedException, ConflictException, ForbiddenException } from '@nestjs/common'; +import { Injectable, Logger, UnauthorizedException, ConflictException, ForbiddenException, BadRequestException } from '@nestjs/common'; import { Inject } from '@nestjs/common'; import { USER_REPOSITORY, IUserRepository } from '../../domain/repositories/user.repository.interface'; import { REFRESH_TOKEN_REPOSITORY, IRefreshTokenRepository } from '../../domain/repositories/refresh-token.repository.interface'; @@ -6,6 +6,7 @@ import { TokenService } from './token.service'; import { Password } from '../../domain/value-objects/password.vo'; import { UserRole, UserStatus } from '../../domain/entities/user.entity'; import { EventPublisherService } from './event-publisher.service'; +import { SmsCodeService } from '../../infrastructure/redis/sms-code.service'; export interface RegisterDto { phone?: string; @@ -47,6 +48,7 @@ export class AuthService { @Inject(REFRESH_TOKEN_REPOSITORY) private readonly refreshTokenRepo: IRefreshTokenRepository, private readonly tokenService: TokenService, private readonly eventPublisher: EventPublisherService, + private readonly smsCodeService: SmsCodeService, ) {} async register(dto: RegisterDto): Promise { @@ -218,4 +220,94 @@ export class AuthService { timestamp: new Date().toISOString(), }); } + + /** + * Send a 6-digit SMS verification code to the given phone number. + * In dev mode, the code is logged to console. + */ + async sendSmsCode(phone: string): Promise { + if (!phone) { + throw new BadRequestException('Phone number is required'); + } + await this.smsCodeService.generateCode(phone); + this.logger.log(`SMS code sent to ${phone}`); + } + + /** + * Login with phone number and SMS verification code. + * If the user does not exist, a new account is created automatically. + */ + async loginWithPhone(phone: string, smsCode: string, ipAddress?: string): Promise<{ user: any; tokens: AuthTokens }> { + // Verify the SMS code + const valid = await this.smsCodeService.verifyCode(phone, smsCode); + if (!valid) { + throw new UnauthorizedException('Invalid or expired SMS code'); + } + + // Find or create user by phone + let user = await this.userRepo.findByPhone(phone); + if (!user) { + // Auto-register: create a new user with a random password hash + const randomPassword = await Password.create(`auto-${Date.now()}-${Math.random()}`); + user = await this.userRepo.create({ + phone, + email: null, + passwordHash: randomPassword.value, + nickname: null, + role: UserRole.USER, + status: UserStatus.ACTIVE, + kycLevel: 0, + walletMode: 'standard', + }); + + await this.eventPublisher.publishUserRegistered({ + userId: user.id, + phone: user.phone, + email: user.email, + role: user.role, + timestamp: new Date().toISOString(), + }); + + this.logger.log(`New user auto-registered via phone login: ${user.id}`); + } + + // Check status + if (user.status === UserStatus.FROZEN) { + throw new ForbiddenException('Account is frozen'); + } + if (user.status === UserStatus.DELETED) { + throw new UnauthorizedException('Account not found'); + } + + // Update last login + await this.userRepo.updateLastLogin(user.id); + + // Generate tokens + const tokens = await this.tokenService.generateTokenPair(user.id, user.role, user.kycLevel); + + // Store refresh token + await this.tokenService.storeRefreshToken(user.id, tokens.refreshToken, undefined, ipAddress); + + // Publish login event + await this.eventPublisher.publishUserLoggedIn({ + userId: user.id, + ipAddress: ipAddress || null, + deviceInfo: null, + timestamp: new Date().toISOString(), + }); + + return { + user: { + id: user.id, + phone: user.phone, + email: user.email, + nickname: user.nickname, + avatarUrl: user.avatarUrl, + role: user.role, + kycLevel: user.kycLevel, + walletMode: user.walletMode, + }, + tokens, + }; + } } diff --git a/backend/services/auth-service/src/auth.module.ts b/backend/services/auth-service/src/auth.module.ts index 1a0cd20..0293760 100644 --- a/backend/services/auth-service/src/auth.module.ts +++ b/backend/services/auth-service/src/auth.module.ts @@ -16,6 +16,7 @@ import { UserRepository } from './infrastructure/persistence/user.repository'; import { RefreshTokenRepository } from './infrastructure/persistence/refresh-token.repository'; import { JwtStrategy } from './infrastructure/strategies/jwt.strategy'; import { TokenBlacklistService } from './infrastructure/redis/token-blacklist.service'; +import { SmsCodeService } from './infrastructure/redis/sms-code.service'; // Application services import { AuthService } from './application/services/auth.service'; @@ -43,6 +44,7 @@ import { AuthController } from './interface/http/controllers/auth.controller'; // Infrastructure JwtStrategy, TokenBlacklistService, + SmsCodeService, // Application services AuthService, diff --git a/backend/services/auth-service/src/infrastructure/redis/sms-code.service.ts b/backend/services/auth-service/src/infrastructure/redis/sms-code.service.ts new file mode 100644 index 0000000..a1e6b28 --- /dev/null +++ b/backend/services/auth-service/src/infrastructure/redis/sms-code.service.ts @@ -0,0 +1,63 @@ +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import Redis from 'ioredis'; + +/** + * SMS code storage service using Redis. + * Stores 6-digit verification codes with a 5-minute TTL. + * In dev mode, codes are logged to console instead of sent via SMS. + */ +@Injectable() +export class SmsCodeService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger('SmsCodeService'); + private redis: Redis; + + async onModuleInit() { + const host = process.env.REDIS_HOST || 'localhost'; + const port = parseInt(process.env.REDIS_PORT || '6379', 10); + const password = process.env.REDIS_PASSWORD || undefined; + + this.redis = new Redis({ + host, + port, + password, + keyPrefix: 'auth:sms:', + retryStrategy: (times) => Math.min(times * 50, 2000), + }); + + this.redis.on('connect', () => this.logger.log('Redis connected for SMS code service')); + this.redis.on('error', (err) => this.logger.error(`Redis error: ${err.message}`)); + } + + async onModuleDestroy() { + if (this.redis) { + await this.redis.quit(); + } + } + + /** + * Generate and store a 6-digit code for the given phone number. + * TTL is 5 minutes (300 seconds). + */ + async generateCode(phone: string): Promise { + const code = String(Math.floor(100000 + Math.random() * 900000)); + await this.redis.set(phone, code, 'EX', 300); + // In dev mode, log the code instead of sending a real SMS + this.logger.log(`[DEV] SMS code for ${phone}: ${code}`); + return code; + } + + /** + * Verify the code for the given phone number. + * Returns true if valid, false otherwise. + * On successful verification, the code is deleted to prevent reuse. + */ + async verifyCode(phone: string, code: string): Promise { + const stored = await this.redis.get(phone); + if (!stored || stored !== code) { + return false; + } + // Delete the code after successful verification + await this.redis.del(phone); + return true; + } +} diff --git a/backend/services/auth-service/src/interface/http/controllers/auth.controller.ts b/backend/services/auth-service/src/interface/http/controllers/auth.controller.ts index 10a03d3..fe05ca3 100644 --- a/backend/services/auth-service/src/interface/http/controllers/auth.controller.ts +++ b/backend/services/auth-service/src/interface/http/controllers/auth.controller.ts @@ -15,6 +15,8 @@ import { RegisterDto } from '../dto/register.dto'; import { LoginDto } from '../dto/login.dto'; import { RefreshTokenDto } from '../dto/refresh-token.dto'; import { ChangePasswordDto } from '../dto/change-password.dto'; +import { SendSmsCodeDto } from '../dto/send-sms-code.dto'; +import { LoginPhoneDto } from '../dto/login-phone.dto'; @ApiTags('Auth') @Controller('auth') @@ -92,4 +94,32 @@ export class AuthController { message: 'Password changed successfully', }; } + + @Post('send-sms-code') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Send SMS verification code to phone number' }) + @ApiResponse({ status: 200, description: 'SMS code sent successfully' }) + @ApiResponse({ status: 400, description: 'Invalid phone number' }) + async sendSmsCode(@Body() dto: SendSmsCodeDto) { + await this.authService.sendSmsCode(dto.phone); + return { + code: 0, + data: { success: true }, + message: 'SMS code sent successfully', + }; + } + + @Post('login-phone') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Login with phone number and SMS verification code' }) + @ApiResponse({ status: 200, description: 'Login successful' }) + @ApiResponse({ status: 401, description: 'Invalid or expired SMS code' }) + async loginWithPhone(@Body() dto: LoginPhoneDto, @Ip() ip: string) { + const result = await this.authService.loginWithPhone(dto.phone, dto.smsCode, ip); + return { + code: 0, + data: result, + message: 'Login successful', + }; + } } diff --git a/backend/services/auth-service/src/interface/http/dto/login-phone.dto.ts b/backend/services/auth-service/src/interface/http/dto/login-phone.dto.ts new file mode 100644 index 0000000..40d85fa --- /dev/null +++ b/backend/services/auth-service/src/interface/http/dto/login-phone.dto.ts @@ -0,0 +1,14 @@ +import { IsString, IsNotEmpty, Length } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class LoginPhoneDto { + @ApiProperty({ description: 'Phone number', example: '+8613800138000' }) + @IsString() + @IsNotEmpty() + phone: string; + + @ApiProperty({ description: '6-digit SMS verification code', example: '123456' }) + @IsString() + @Length(6, 6) + smsCode: string; +} diff --git a/backend/services/auth-service/src/interface/http/dto/send-sms-code.dto.ts b/backend/services/auth-service/src/interface/http/dto/send-sms-code.dto.ts new file mode 100644 index 0000000..a2538e6 --- /dev/null +++ b/backend/services/auth-service/src/interface/http/dto/send-sms-code.dto.ts @@ -0,0 +1,9 @@ +import { IsString, IsNotEmpty } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class SendSmsCodeDto { + @ApiProperty({ description: 'Phone number to send SMS code to', example: '+8613800138000' }) + @IsString() + @IsNotEmpty() + phone: string; +} diff --git a/backend/services/issuer-service/src/application/services/admin-coupon-analytics.service.ts b/backend/services/issuer-service/src/application/services/admin-coupon-analytics.service.ts index e26a651..95ea74e 100644 --- a/backend/services/issuer-service/src/application/services/admin-coupon-analytics.service.ts +++ b/backend/services/issuer-service/src/application/services/admin-coupon-analytics.service.ts @@ -125,16 +125,17 @@ export class AdminCouponAnalyticsService { const totalSold = await this.couponRepo.getTotalSold(); const totalRedeemed = await this.couponRepo.getTotalSoldByStatuses([ - CouponStatus.EXPIRED, - CouponStatus.SOLD_OUT, + CouponStatus.REDEEMED, ]); return { - draft: countMap[CouponStatus.DRAFT] || 0, - active: countMap[CouponStatus.ACTIVE] || 0, - paused: countMap[CouponStatus.PAUSED] || 0, - soldOut: countMap[CouponStatus.SOLD_OUT] || 0, + minted: countMap[CouponStatus.MINTED] || 0, + listed: countMap[CouponStatus.LISTED] || 0, + sold: countMap[CouponStatus.SOLD] || 0, + inCirculation: countMap[CouponStatus.IN_CIRCULATION] || 0, + redeemed: countMap[CouponStatus.REDEEMED] || 0, expired: countMap[CouponStatus.EXPIRED] || 0, + recalled: countMap[CouponStatus.RECALLED] || 0, totalSold, totalRedeemed, }; diff --git a/backend/services/issuer-service/src/application/services/admin-coupon.service.ts b/backend/services/issuer-service/src/application/services/admin-coupon.service.ts index b53fdc5..5add061 100644 --- a/backend/services/issuer-service/src/application/services/admin-coupon.service.ts +++ b/backend/services/issuer-service/src/application/services/admin-coupon.service.ts @@ -111,13 +111,13 @@ export class AdminCouponService { const coupon = await this.couponRepo.findById(id); if (!coupon) throw new NotFoundException('Coupon not found'); - if (coupon.status !== CouponStatus.DRAFT) { + if (coupon.status !== CouponStatus.MINTED) { throw new BadRequestException( - `Cannot approve coupon with status "${coupon.status}". Only draft coupons can be approved.`, + `Cannot approve coupon with status "${coupon.status}". Only minted coupons can be approved.`, ); } - coupon.status = CouponStatus.ACTIVE; + coupon.status = CouponStatus.LISTED; return this.couponRepo.save(coupon); } @@ -128,20 +128,13 @@ export class AdminCouponService { const coupon = await this.couponRepo.findById(id); if (!coupon) throw new NotFoundException('Coupon not found'); - if (coupon.status !== CouponStatus.DRAFT) { + if (coupon.status !== CouponStatus.MINTED) { throw new BadRequestException( - `Cannot reject coupon with status "${coupon.status}". Only draft coupons can be rejected.`, + `Cannot reject coupon with status "${coupon.status}". Only minted coupons can be rejected.`, ); } - coupon.terms = { - ...(coupon.terms || {}), - _rejection: { - reason, - rejectedAt: new Date().toISOString(), - }, - }; - coupon.status = CouponStatus.EXPIRED; + coupon.status = CouponStatus.RECALLED; return this.couponRepo.save(coupon); } @@ -152,13 +145,13 @@ export class AdminCouponService { const coupon = await this.couponRepo.findById(id); if (!coupon) throw new NotFoundException('Coupon not found'); - if (coupon.status !== CouponStatus.ACTIVE) { + if (coupon.status !== CouponStatus.LISTED) { throw new BadRequestException( - `Cannot suspend coupon with status "${coupon.status}". Only active coupons can be suspended.`, + `Cannot suspend coupon with status "${coupon.status}". Only listed coupons can be suspended.`, ); } - coupon.status = CouponStatus.PAUSED; + coupon.status = CouponStatus.RECALLED; return this.couponRepo.save(coupon); } } diff --git a/backend/services/issuer-service/src/application/services/coupon-batch.service.ts b/backend/services/issuer-service/src/application/services/coupon-batch.service.ts new file mode 100644 index 0000000..78a3074 --- /dev/null +++ b/backend/services/issuer-service/src/application/services/coupon-batch.service.ts @@ -0,0 +1,209 @@ +import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { COUPON_REPOSITORY, ICouponRepository } from '../../domain/repositories/coupon.repository.interface'; +import { Coupon, CouponStatus } from '../../domain/entities/coupon.entity'; + +export interface BatchOperation { + id: string; + type: 'issue' | 'recall' | 'price_adjust'; + issuerId: string; + affectedCount: number; + details: Record; + createdAt: Date; +} + +// In-memory operation log (would be a DB table in production) +const operationHistory: BatchOperation[] = []; + +@Injectable() +export class CouponBatchService { + constructor( + @Inject(COUPON_REPOSITORY) + private readonly couponRepo: ICouponRepository, + private readonly dataSource: DataSource, + ) {} + + /** + * Batch issue coupons from a template coupon. + */ + async batchIssue( + issuerId: string, + templateId: string, + quantity: number, + expiryDate?: string, + ) { + const template = await this.couponRepo.findById(templateId); + if (!template) throw new NotFoundException('Template coupon not found'); + if (template.issuerId !== issuerId) { + throw new BadRequestException('Template does not belong to this issuer'); + } + + const coupons: Coupon[] = []; + + await this.dataSource.transaction(async (manager) => { + for (let i = 0; i < quantity; i++) { + const coupon = manager.create(Coupon, { + issuerId, + name: template.name, + description: template.description, + imageUrl: template.imageUrl, + faceValue: template.faceValue, + currentPrice: template.currentPrice, + issuePrice: template.issuePrice, + totalSupply: 1, + remainingSupply: 1, + expiryDate: expiryDate ? new Date(expiryDate) : template.expiryDate, + couponType: template.couponType, + category: template.category, + status: CouponStatus.MINTED, + isTransferable: template.isTransferable, + maxResaleCount: template.maxResaleCount, + }); + const saved = await manager.save(coupon); + coupons.push(saved); + } + }); + + const operation: BatchOperation = { + id: require('crypto').randomUUID(), + type: 'issue', + issuerId, + affectedCount: coupons.length, + details: { templateId, quantity, expiryDate }, + createdAt: new Date(), + }; + operationHistory.unshift(operation); + + return { + operationId: operation.id, + issued: coupons.length, + couponIds: coupons.map((c) => c.id), + }; + } + + /** + * Batch recall coupons. + */ + async batchRecall(issuerId: string, couponIds: string[], reason: string) { + const results: Array<{ couponId: string; success: boolean; error?: string }> = []; + + await this.dataSource.transaction(async (manager) => { + for (const couponId of couponIds) { + try { + const coupon = await manager.findOne(Coupon, { + where: { id: couponId }, + lock: { mode: 'pessimistic_write' }, + }); + + if (!coupon) { + results.push({ couponId, success: false, error: 'Coupon not found' }); + continue; + } + if (coupon.issuerId !== issuerId) { + results.push({ couponId, success: false, error: 'Not owned by this issuer' }); + continue; + } + if (coupon.status === CouponStatus.REDEEMED) { + results.push({ couponId, success: false, error: 'Already redeemed' }); + continue; + } + + coupon.status = CouponStatus.RECALLED; + await manager.save(coupon); + results.push({ couponId, success: true }); + } catch (err: any) { + results.push({ couponId, success: false, error: err.message }); + } + } + }); + + const successCount = results.filter((r) => r.success).length; + const operation: BatchOperation = { + id: require('crypto').randomUUID(), + type: 'recall', + issuerId, + affectedCount: successCount, + details: { couponIds, reason, results }, + createdAt: new Date(), + }; + operationHistory.unshift(operation); + + return { + operationId: operation.id, + results, + successCount, + failCount: results.length - successCount, + }; + } + + /** + * Batch price adjustment. + */ + async batchPriceAdjust(issuerId: string, couponIds: string[], adjustPercent: number) { + const results: Array<{ couponId: string; success: boolean; oldPrice?: string; newPrice?: string; error?: string }> = []; + + await this.dataSource.transaction(async (manager) => { + for (const couponId of couponIds) { + try { + const coupon = await manager.findOne(Coupon, { + where: { id: couponId }, + lock: { mode: 'pessimistic_write' }, + }); + + if (!coupon) { + results.push({ couponId, success: false, error: 'Coupon not found' }); + continue; + } + if (coupon.issuerId !== issuerId) { + results.push({ couponId, success: false, error: 'Not owned by this issuer' }); + continue; + } + + const oldPrice = coupon.currentPrice || coupon.faceValue; + const multiplier = 1 + adjustPercent / 100; + const newPrice = Math.round(Number(oldPrice) * multiplier * 100) / 100; + + if (newPrice < 0) { + results.push({ couponId, success: false, error: 'Adjusted price would be negative' }); + continue; + } + + coupon.currentPrice = String(newPrice); + await manager.save(coupon); + results.push({ couponId, success: true, oldPrice: String(oldPrice), newPrice: String(newPrice) }); + } catch (err: any) { + results.push({ couponId, success: false, error: err.message }); + } + } + }); + + const successCount = results.filter((r) => r.success).length; + const operation: BatchOperation = { + id: require('crypto').randomUUID(), + type: 'price_adjust', + issuerId, + affectedCount: successCount, + details: { couponIds, adjustPercent, results }, + createdAt: new Date(), + }; + operationHistory.unshift(operation); + + return { + operationId: operation.id, + results, + successCount, + failCount: results.length - successCount, + }; + } + + /** + * List past batch operations. + */ + async listOperations(issuerId: string, page: number, limit: number) { + const filtered = operationHistory.filter((op) => op.issuerId === issuerId); + const total = filtered.length; + const start = (page - 1) * limit; + const items = filtered.slice(start, start + limit); + return { items, total, page, limit }; + } +} diff --git a/backend/services/issuer-service/src/application/services/coupon.service.ts b/backend/services/issuer-service/src/application/services/coupon.service.ts index b3e3b89..af410e7 100644 --- a/backend/services/issuer-service/src/application/services/coupon.service.ts +++ b/backend/services/issuer-service/src/application/services/coupon.service.ts @@ -1,7 +1,10 @@ import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; import { COUPON_REPOSITORY, ICouponRepository } from '../../domain/repositories/coupon.repository.interface'; import { COUPON_RULE_REPOSITORY, ICouponRuleRepository } from '../../domain/repositories/coupon-rule.repository.interface'; +import { STORE_REPOSITORY, IStoreRepository } from '../../domain/repositories/store.repository.interface'; import { Coupon, CouponStatus } from '../../domain/entities/coupon.entity'; +import { Store } from '../../domain/entities/store.entity'; @Injectable() export class CouponService { @@ -10,13 +13,16 @@ export class CouponService { private readonly couponRepo: ICouponRepository, @Inject(COUPON_RULE_REPOSITORY) private readonly ruleRepo: ICouponRuleRepository, + @Inject(STORE_REPOSITORY) + private readonly storeRepo: IStoreRepository, + private readonly dataSource: DataSource, ) {} async create(issuerId: string, data: Partial & { rules?: any[] }) { const saved = await this.couponRepo.create({ ...data, issuerId, - status: CouponStatus.DRAFT, + status: CouponStatus.MINTED, remainingSupply: data.totalSupply || 0, }); @@ -49,7 +55,7 @@ export class CouponService { issuerId?: string; }, ) { - const [items, total] = await this.couponRepo.findAndCount({ + const [items, total] = await this.couponRepo.findAndCountWithIssuerJoin({ category: filters?.category, status: filters?.status, search: filters?.search, @@ -60,11 +66,108 @@ export class CouponService { return { items, total, page, limit }; } - async updateStatus(id: string, status: CouponStatus) { + async getByOwner(userId: string, page: number, limit: number, status?: string) { + const [items, total] = await this.couponRepo.findByOwnerWithIssuerJoin(userId, { + status, + page, + limit, + }); + return { items, total, page, limit }; + } + + async getOwnerSummary(userId: string) { + return this.couponRepo.getOwnerSummary(userId); + } + + async updateStatus(id: string, status: string) { return this.couponRepo.updateStatus(id, status); } async purchase(couponId: string, quantity: number = 1) { return this.couponRepo.purchaseWithLock(couponId, quantity); } + + /** + * Search coupons with keyword, category, and sort. + */ + async search( + q?: string, + category?: string, + sort: string = 'newest', + page: number = 1, + limit: number = 20, + ) { + const qb = this.dataSource + .getRepository(Coupon) + .createQueryBuilder('c'); + + if (q) { + qb.andWhere('(c.name ILIKE :q OR c.description ILIKE :q)', { q: `%${q}%` }); + } + if (category) { + qb.andWhere('c.category = :category', { category }); + } + + // Default: only show listed coupons in search + qb.andWhere('c.status = :status', { status: CouponStatus.LISTED }); + + switch (sort) { + case 'price_asc': + qb.orderBy('CAST(c.current_price AS numeric)', 'ASC'); + break; + case 'price_desc': + qb.orderBy('CAST(c.current_price AS numeric)', 'DESC'); + break; + case 'popular': + qb.orderBy('c.total_supply - c.remaining_supply', 'DESC'); + break; + case 'newest': + default: + qb.orderBy('c.created_at', 'DESC'); + break; + } + + qb.skip((page - 1) * limit).take(limit); + const [items, total] = await qb.getManyAndCount(); + return { items, total, page, limit }; + } + + /** + * Find stores that accept a coupon (by the coupon's issuer). + */ + async getNearbyStores(couponId: string): Promise { + const coupon = await this.couponRepo.findById(couponId); + if (!coupon) throw new NotFoundException('Coupon not found'); + + return this.storeRepo.findByIssuerId(coupon.issuerId); + } + + /** + * Find similar coupons: same category, different issuer. + */ + async findSimilar(couponId: string, limit: number = 10): Promise { + const coupon = await this.couponRepo.findById(couponId); + if (!coupon) throw new NotFoundException('Coupon not found'); + + const qb = this.dataSource + .getRepository(Coupon) + .createQueryBuilder('c') + .where('c.id != :id', { id: couponId }) + .andWhere('c.status = :status', { status: CouponStatus.LISTED }); + + if (coupon.category) { + qb.andWhere('c.category = :category', { category: coupon.category }); + } + + // Prefer different issuer + qb.orderBy( + 'CASE WHEN c.issuer_id != :issuerId THEN 0 ELSE 1 END', + 'ASC', + ); + qb.setParameter('issuerId', coupon.issuerId); + qb.addOrderBy('c.created_at', 'DESC'); + qb.take(limit); + + return qb.getMany(); + } } diff --git a/backend/services/issuer-service/src/application/services/issuer-finance.service.ts b/backend/services/issuer-service/src/application/services/issuer-finance.service.ts new file mode 100644 index 0000000..6ba8b0d --- /dev/null +++ b/backend/services/issuer-service/src/application/services/issuer-finance.service.ts @@ -0,0 +1,172 @@ +import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { ISSUER_REPOSITORY, IIssuerRepository } from '../../domain/repositories/issuer.repository.interface'; +import { COUPON_REPOSITORY, ICouponRepository } from '../../domain/repositories/coupon.repository.interface'; +import { Coupon, CouponStatus } from '../../domain/entities/coupon.entity'; + +@Injectable() +export class IssuerFinanceService { + constructor( + @Inject(ISSUER_REPOSITORY) + private readonly issuerRepo: IIssuerRepository, + @Inject(COUPON_REPOSITORY) + private readonly couponRepo: ICouponRepository, + private readonly dataSource: DataSource, + ) {} + + async getBalance(issuerId: string) { + const issuer = await this.issuerRepo.findById(issuerId); + if (!issuer) throw new NotFoundException('Issuer not found'); + + // Aggregate from coupons + const result = await this.dataSource + .createQueryBuilder() + .select([ + `COALESCE(SUM(CASE WHEN c.status IN ('sold', 'redeemed') THEN CAST(c.current_price AS numeric) ELSE 0 END), 0) as "total"`, + `COALESCE(SUM(CASE WHEN c.status = 'redeemed' THEN CAST(c.current_price AS numeric) ELSE 0 END), 0) as "withdrawable"`, + `COALESCE(SUM(CASE WHEN c.status = 'sold' THEN CAST(c.current_price AS numeric) ELSE 0 END), 0) as "pending"`, + ]) + .from(Coupon, 'c') + .where('c.issuer_id = :issuerId', { issuerId }) + .getRawOne(); + + return { + withdrawable: Math.round(Number(result.withdrawable) * 100) / 100, + pending: Math.round(Number(result.pending) * 100) / 100, + total: Math.round(Number(result.total) * 100) / 100, + }; + } + + async getFinanceStats(issuerId: string) { + const issuer = await this.issuerRepo.findById(issuerId); + if (!issuer) throw new NotFoundException('Issuer not found'); + const feeRate = Number(issuer.feeRate) || 0.015; + + const result = await this.dataSource + .createQueryBuilder() + .select([ + `COALESCE(SUM(CASE WHEN c.status IN ('sold', 'redeemed') THEN CAST(c.current_price AS numeric) ELSE 0 END), 0) as "salesAmount"`, + `COALESCE(SUM(CASE WHEN c.status = 'expired' THEN CAST(c.current_price AS numeric) ELSE 0 END), 0) as "breakageIncome"`, + `COALESCE(SUM(CASE WHEN c.status = 'sold' THEN CAST(c.current_price AS numeric) ELSE 0 END), 0) as "pendingSettlement"`, + ]) + .from(Coupon, 'c') + .where('c.issuer_id = :issuerId', { issuerId }) + .getRawOne(); + + const salesAmount = Math.round(Number(result.salesAmount) * 100) / 100; + const breakageIncome = Math.round(Number(result.breakageIncome) * 100) / 100; + const platformFee = Math.round(salesAmount * feeRate * 100) / 100; + const pendingSettlement = Math.round(Number(result.pendingSettlement) * 100) / 100; + const totalRevenue = Math.round((salesAmount + breakageIncome - platformFee) * 100) / 100; + + return { + salesAmount, + breakageIncome, + platformFee, + pendingSettlement, + withdrawn: 0, // TODO: track actual withdrawals + totalRevenue, + }; + } + + async getTransactions( + issuerId: string, + page: number, + limit: number, + type?: string, + ) { + const issuer = await this.issuerRepo.findById(issuerId); + if (!issuer) throw new NotFoundException('Issuer not found'); + + // Build transaction-like view from coupons + const qb = this.dataSource + .createQueryBuilder() + .select([ + 'c.id as "id"', + 'c.name as "couponName"', + 'c.status as "type"', + 'c.current_price as "amount"', + 'c.updated_at as "date"', + ]) + .from(Coupon, 'c') + .where('c.issuer_id = :issuerId', { issuerId }) + .andWhere("c.status IN ('sold', 'redeemed', 'expired', 'recalled')"); + + if (type) { + const statusMap: Record = { + sale: 'sold', + redemption: 'redeemed', + }; + const mappedStatus = statusMap[type] || type; + qb.andWhere('c.status = :mappedStatus', { mappedStatus }); + } + + const total = await qb.getCount(); + + const items = await qb + .orderBy('c.updated_at', 'DESC') + .offset((page - 1) * limit) + .limit(limit) + .getRawMany(); + + return { items, total, page, limit }; + } + + async requestWithdrawal(issuerId: string, amount: number) { + const balance = await this.getBalance(issuerId); + if (amount > balance.withdrawable) { + throw new BadRequestException( + `Insufficient withdrawable balance. Available: ${balance.withdrawable}`, + ); + } + + // In a real system, this would create a withdrawal record and trigger a payout + // For now, return a placeholder + return { + id: require('crypto').randomUUID(), + issuerId, + amount, + status: 'pending', + requestedAt: new Date(), + estimatedArrival: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), // T+3 + }; + } + + async getReconciliation( + issuerId: string, + startDate?: string, + endDate?: string, + ) { + const issuer = await this.issuerRepo.findById(issuerId); + if (!issuer) throw new NotFoundException('Issuer not found'); + + const qb = this.dataSource + .createQueryBuilder() + .select([ + "TO_CHAR(c.updated_at, 'YYYY-MM-DD') as \"date\"", + `SUM(CASE WHEN c.status = 'sold' THEN CAST(c.current_price AS numeric) ELSE 0 END) as "sales"`, + `SUM(CASE WHEN c.status = 'redeemed' THEN CAST(c.current_price AS numeric) ELSE 0 END) as "redemptions"`, + `SUM(CASE WHEN c.status = 'expired' THEN CAST(c.current_price AS numeric) ELSE 0 END) as "breakage"`, + 'COUNT(c.id) as "transactionCount"', + ]) + .from(Coupon, 'c') + .where('c.issuer_id = :issuerId', { issuerId }) + .andWhere("c.status IN ('sold', 'redeemed', 'expired')"); + + if (startDate) qb.andWhere('c.updated_at >= :startDate', { startDate }); + if (endDate) qb.andWhere('c.updated_at <= :endDate', { endDate }); + + qb.groupBy("TO_CHAR(c.updated_at, 'YYYY-MM-DD')") + .orderBy('"date"', 'DESC'); + + const rows = await qb.getRawMany(); + + return rows.map((row) => ({ + date: row.date, + sales: Math.round(Number(row.sales) * 100) / 100, + redemptions: Math.round(Number(row.redemptions) * 100) / 100, + breakage: Math.round(Number(row.breakage) * 100) / 100, + transactionCount: Number(row.transactionCount), + })); + } +} diff --git a/backend/services/issuer-service/src/application/services/issuer-stats.service.ts b/backend/services/issuer-service/src/application/services/issuer-stats.service.ts new file mode 100644 index 0000000..b522c96 --- /dev/null +++ b/backend/services/issuer-service/src/application/services/issuer-stats.service.ts @@ -0,0 +1,123 @@ +import { Injectable, Inject, NotFoundException } from '@nestjs/common'; +import { ISSUER_REPOSITORY, IIssuerRepository } from '../../domain/repositories/issuer.repository.interface'; +import { COUPON_REPOSITORY, ICouponRepository } from '../../domain/repositories/coupon.repository.interface'; +import { CREDIT_METRIC_REPOSITORY, ICreditMetricRepository } from '../../domain/repositories/credit-metric.repository.interface'; +import { CouponStatus } from '../../domain/entities/coupon.entity'; +import { CreditScoringService } from './credit-scoring.service'; + +@Injectable() +export class IssuerStatsService { + constructor( + @Inject(ISSUER_REPOSITORY) + private readonly issuerRepo: IIssuerRepository, + @Inject(COUPON_REPOSITORY) + private readonly couponRepo: ICouponRepository, + @Inject(CREDIT_METRIC_REPOSITORY) + private readonly creditMetricRepo: ICreditMetricRepository, + private readonly creditScoringService: CreditScoringService, + ) {} + + async getDashboardStats(issuerId: string) { + const issuer = await this.issuerRepo.findById(issuerId); + if (!issuer) throw new NotFoundException('Issuer not found'); + + // Aggregate coupon stats for this issuer + const [coupons, total] = await this.couponRepo.findAndCount({ + issuerId, + page: 1, + limit: 1, + }); + + const issuedCount = total; + + // Count redeemed coupons + const [, redeemedCount] = await this.couponRepo.findAndCount({ + issuerId, + status: CouponStatus.REDEEMED, + page: 1, + limit: 1, + }); + + const redemptionRate = issuedCount > 0 + ? Math.round((redeemedCount / issuedCount) * 10000) / 10000 + : 0; + + // Get total revenue: sum of current_price for sold/redeemed coupons + const [soldCoupons] = await this.couponRepo.findAndCount({ + issuerId, + status: CouponStatus.SOLD, + page: 1, + limit: 100000, + }); + const [redeemedCoupons] = await this.couponRepo.findAndCount({ + issuerId, + status: CouponStatus.REDEEMED, + page: 1, + limit: 100000, + }); + + const allRevenueCoupons = [...soldCoupons, ...redeemedCoupons]; + const totalRevenue = allRevenueCoupons.reduce( + (sum, c) => sum + (Number(c.currentPrice) || 0), + 0, + ); + + const creditScore = Number(issuer.creditScore) || 50; + + return { + issuedCount, + redemptionRate, + totalRevenue: Math.round(totalRevenue * 100) / 100, + creditScore, + quotaUsed: issuedCount, + quotaTotal: 10000, // configurable limit + }; + } + + async getCreditDetails(issuerId: string) { + const issuer = await this.issuerRepo.findById(issuerId); + if (!issuer) throw new NotFoundException('Issuer not found'); + + const latestMetric = await this.creditMetricRepo.findLatestByIssuerId(issuerId); + const numericScore = Number(issuer.creditScore) || 50; + + // Derive grade from score + const grade = numericScore >= 90 ? 'AA' : + numericScore >= 80 ? 'A+' : + numericScore >= 70 ? 'A' : + numericScore >= 60 ? 'B+' : + numericScore >= 50 ? 'B' : + numericScore >= 40 ? 'C+' : + numericScore >= 30 ? 'C' : 'D'; + + const tier = numericScore >= 80 ? 'premium' : + numericScore >= 60 ? 'standard' : + numericScore >= 40 ? 'basic' : 'probation'; + + const factors = latestMetric ? { + redemptionRate: { weight: 0.35, score: Number(latestMetric.redemptionRate) * 100 }, + breakageRate: { weight: 0.25, score: (1 - Number(latestMetric.breakageRate)) * 100 }, + tenure: { weight: 0.20, score: Math.min(100, (latestMetric.tenureDays / 365) * 100) }, + satisfaction: { weight: 0.20, score: Number(latestMetric.satisfactionScore) }, + } : { + redemptionRate: { weight: 0.35, score: 0 }, + breakageRate: { weight: 0.25, score: 0 }, + tenure: { weight: 0.20, score: 0 }, + satisfaction: { weight: 0.20, score: 0 }, + }; + + const suggestions: string[] = []; + if (factors.redemptionRate.score < 50) suggestions.push('Improve coupon redemption rates by promoting usage'); + if (factors.satisfaction.score < 50) suggestions.push('Enhance customer satisfaction through better service'); + if (factors.tenure.score < 50) suggestions.push('Maintain consistent platform activity to build tenure'); + + return { + grade, + numericScore, + factors, + tier, + suggestions, + lastCalculatedAt: latestMetric?.calculatedAt || null, + }; + } +} diff --git a/backend/services/issuer-service/src/application/services/issuer-store.service.ts b/backend/services/issuer-service/src/application/services/issuer-store.service.ts new file mode 100644 index 0000000..71cab82 --- /dev/null +++ b/backend/services/issuer-service/src/application/services/issuer-store.service.ts @@ -0,0 +1,87 @@ +import { Injectable, Inject, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { STORE_REPOSITORY, IStoreRepository } from '../../domain/repositories/store.repository.interface'; +import { EMPLOYEE_REPOSITORY, IEmployeeRepository } from '../../domain/repositories/employee.repository.interface'; +import { Store } from '../../domain/entities/store.entity'; +import { Employee } from '../../domain/entities/employee.entity'; + +@Injectable() +export class IssuerStoreService { + constructor( + @Inject(STORE_REPOSITORY) + private readonly storeRepo: IStoreRepository, + @Inject(EMPLOYEE_REPOSITORY) + private readonly employeeRepo: IEmployeeRepository, + ) {} + + // ---- Stores ---- + + async listStores( + issuerId: string, + filters?: { level?: string; parentId?: string; status?: string }, + ): Promise { + return this.storeRepo.findByIssuerId(issuerId, filters); + } + + async createStore(issuerId: string, data: Partial): Promise { + return this.storeRepo.create({ + ...data, + issuerId, + status: 'active', + }); + } + + async updateStore(issuerId: string, storeId: string, data: Partial): Promise { + const store = await this.storeRepo.findById(storeId); + if (!store) throw new NotFoundException('Store not found'); + if (store.issuerId !== issuerId) throw new ForbiddenException('Store does not belong to this issuer'); + + Object.assign(store, data); + return this.storeRepo.save(store); + } + + async deleteStore(issuerId: string, storeId: string): Promise { + const store = await this.storeRepo.findById(storeId); + if (!store) throw new NotFoundException('Store not found'); + if (store.issuerId !== issuerId) throw new ForbiddenException('Store does not belong to this issuer'); + + await this.storeRepo.delete(storeId); + } + + // ---- Employees ---- + + async listEmployees( + issuerId: string, + filters?: { storeId?: string; role?: string }, + ): Promise { + return this.employeeRepo.findByIssuerId(issuerId, filters); + } + + async createEmployee(issuerId: string, data: Partial): Promise { + return this.employeeRepo.create({ + ...data, + issuerId, + status: 'active', + }); + } + + async updateEmployee( + issuerId: string, + employeeId: string, + data: Partial, + ): Promise { + const employee = await this.employeeRepo.findById(employeeId); + if (!employee) throw new NotFoundException('Employee not found'); + if (employee.issuerId !== issuerId) throw new ForbiddenException('Employee does not belong to this issuer'); + + Object.assign(employee, data); + return this.employeeRepo.save(employee); + } + + async deleteEmployee(issuerId: string, employeeId: string): Promise { + const employee = await this.employeeRepo.findById(employeeId); + if (!employee) throw new NotFoundException('Employee not found'); + if (employee.issuerId !== issuerId) throw new ForbiddenException('Employee does not belong to this issuer'); + + await this.employeeRepo.delete(employeeId); + } +} diff --git a/backend/services/issuer-service/src/application/services/redemption.service.ts b/backend/services/issuer-service/src/application/services/redemption.service.ts new file mode 100644 index 0000000..4b83319 --- /dev/null +++ b/backend/services/issuer-service/src/application/services/redemption.service.ts @@ -0,0 +1,128 @@ +import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { COUPON_REPOSITORY, ICouponRepository } from '../../domain/repositories/coupon.repository.interface'; +import { REDEMPTION_REPOSITORY, IRedemptionRepository } from '../../domain/repositories/redemption.repository.interface'; +import { Coupon, CouponStatus } from '../../domain/entities/coupon.entity'; + +@Injectable() +export class RedemptionService { + constructor( + @Inject(COUPON_REPOSITORY) + private readonly couponRepo: ICouponRepository, + @Inject(REDEMPTION_REPOSITORY) + private readonly redemptionRepo: IRedemptionRepository, + private readonly dataSource: DataSource, + ) {} + + /** + * Redeem via QR code scan. + * The qrCode is expected to contain the coupon ID (UUID). + */ + async scanRedeem(issuerId: string, qrCode: string, storeId?: string) { + // Parse coupon ID from QR code (assumes QR contains the coupon UUID) + const couponId = qrCode.trim(); + return this.processRedemption(issuerId, couponId, 'scan', storeId); + } + + /** + * Redeem via manual coupon code entry. + * The couponCode is expected to be the coupon ID (UUID). + */ + async manualRedeem(issuerId: string, couponCode: string, storeId?: string) { + const couponId = couponCode.trim(); + return this.processRedemption(issuerId, couponId, 'manual', storeId); + } + + /** + * Batch redeem multiple coupons. + */ + async batchRedeem(issuerId: string, couponCodes: string[], storeId?: string) { + const results: Array<{ couponCode: string; success: boolean; error?: string; redemption?: any }> = []; + + for (const code of couponCodes) { + try { + const redemption = await this.processRedemption(issuerId, code.trim(), 'batch', storeId); + results.push({ couponCode: code, success: true, redemption }); + } catch (err: any) { + results.push({ couponCode: code, success: false, error: err.message }); + } + } + + const successCount = results.filter((r) => r.success).length; + const failCount = results.filter((r) => !r.success).length; + + return { results, successCount, failCount, total: couponCodes.length }; + } + + /** + * Core redemption logic with pessimistic lock. + */ + private async processRedemption( + issuerId: string, + couponId: string, + method: string, + storeId?: string, + ) { + return this.dataSource.transaction(async (manager) => { + const coupon = await manager.findOne(Coupon, { + where: { id: couponId }, + lock: { mode: 'pessimistic_write' }, + }); + + if (!coupon) throw new NotFoundException(`Coupon not found: ${couponId}`); + if (coupon.issuerId !== issuerId) { + throw new BadRequestException('Coupon does not belong to this issuer'); + } + if (coupon.status === CouponStatus.REDEEMED) { + throw new BadRequestException('Coupon already redeemed'); + } + if (coupon.status === CouponStatus.EXPIRED) { + throw new BadRequestException('Coupon has expired'); + } + if (coupon.status === CouponStatus.RECALLED) { + throw new BadRequestException('Coupon has been recalled'); + } + if (![CouponStatus.SOLD, CouponStatus.IN_CIRCULATION].includes(coupon.status as CouponStatus)) { + throw new BadRequestException(`Coupon is not in a redeemable state (current: ${coupon.status})`); + } + + // Update coupon status + coupon.status = CouponStatus.REDEEMED; + await manager.save(coupon); + + // Create redemption record + const redemption = await this.redemptionRepo.create({ + couponId: coupon.id, + issuerId, + userId: coupon.ownerUserId || issuerId, // use owner if available + storeId: storeId || null, + method, + amount: coupon.faceValue, + status: 'completed', + }); + + return redemption; + }); + } + + async listRedemptions( + issuerId: string, + page: number, + limit: number, + startDate?: string, + endDate?: string, + ) { + const [items, total] = await this.redemptionRepo.findAndCount({ + issuerId, + startDate, + endDate, + page, + limit, + }); + return { items, total, page, limit }; + } + + async getTodayStats(issuerId: string) { + return this.redemptionRepo.getTodayStats(issuerId); + } +} diff --git a/backend/services/issuer-service/src/domain/entities/coupon.entity.ts b/backend/services/issuer-service/src/domain/entities/coupon.entity.ts index 1b5a482..592e728 100644 --- a/backend/services/issuer-service/src/domain/entities/coupon.entity.ts +++ b/backend/services/issuer-service/src/domain/entities/coupon.entity.ts @@ -1,7 +1,15 @@ import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, VersionColumn, Index } from 'typeorm'; -export enum CouponStatus { DRAFT = 'draft', ACTIVE = 'active', PAUSED = 'paused', EXPIRED = 'expired', SOLD_OUT = 'sold_out' } -export enum CouponType { DISCOUNT = 'discount', VOUCHER = 'voucher', GIFT_CARD = 'gift_card', LOYALTY = 'loyalty' } +export enum CouponStatus { + MINTED = 'minted', + LISTED = 'listed', + SOLD = 'sold', + IN_CIRCULATION = 'in_circulation', + REDEEMED = 'redeemed', + EXPIRED = 'expired', + RECALLED = 'recalled', +} +export enum CouponType { UTILITY = 'utility', SECURITY = 'security' } @Entity('coupons') @Index('idx_coupons_issuer', ['issuerId']) @@ -9,24 +17,27 @@ export enum CouponType { DISCOUNT = 'discount', VOUCHER = 'voucher', GIFT_CARD = @Index('idx_coupons_category', ['category']) export class Coupon { @PrimaryGeneratedColumn('uuid') id: string; + @Column({ name: 'chain_token_id', type: 'bigint', nullable: true, unique: true }) chainTokenId: string | null; @Column({ name: 'issuer_id', type: 'uuid' }) issuerId: string; @Column({ type: 'varchar', length: 200 }) name: string; @Column({ type: 'text', nullable: true }) description: string | null; - @Column({ type: 'varchar', length: 50 }) type: CouponType; - @Column({ type: 'varchar', length: 50 }) category: string; - @Column({ name: 'face_value', type: 'numeric', precision: 15, scale: 2 }) faceValue: string; - @Column({ type: 'numeric', precision: 15, scale: 2 }) price: string; - @Column({ type: 'varchar', length: 10, default: 'USD' }) currency: string; - @Column({ name: 'total_supply', type: 'int' }) totalSupply: number; - @Column({ name: 'remaining_supply', type: 'int' }) remainingSupply: number; @Column({ name: 'image_url', type: 'varchar', length: 500, nullable: true }) imageUrl: string | null; - @Column({ type: 'jsonb', nullable: true }) terms: Record | null; - @Column({ type: 'varchar', length: 20, default: 'draft' }) status: CouponStatus; - @Column({ name: 'valid_from', type: 'timestamptz' }) validFrom: Date; - @Column({ name: 'valid_until', type: 'timestamptz' }) validUntil: Date; - @Column({ name: 'is_tradable', type: 'boolean', default: true }) isTradable: boolean; + @Column({ name: 'face_value', type: 'numeric', precision: 12, scale: 2 }) faceValue: string; + @Column({ name: 'current_price', type: 'numeric', precision: 12, scale: 2, nullable: true }) currentPrice: string | null; + @Column({ name: 'issue_price', type: 'numeric', precision: 12, scale: 2, nullable: true }) issuePrice: string | null; + @Column({ name: 'total_supply', type: 'int', default: 1 }) totalSupply: number; + @Column({ name: 'remaining_supply', type: 'int', default: 1 }) remainingSupply: number; + @Column({ name: 'expiry_date', type: 'date' }) expiryDate: Date; + @Column({ name: 'coupon_type', type: 'varchar', length: 10, default: 'utility' }) couponType: string; + @Column({ type: 'varchar', length: 50, nullable: true }) category: string; + @Column({ type: 'varchar', length: 20, default: 'minted' }) status: string; + @Column({ name: 'owner_user_id', type: 'uuid', nullable: true }) ownerUserId: string | null; + @Column({ name: 'resale_count', type: 'smallint', default: 0 }) resaleCount: number; + @Column({ name: 'max_resale_count', type: 'smallint', default: 3 }) maxResaleCount: number; @Column({ name: 'is_transferable', type: 'boolean', default: true }) isTransferable: boolean; @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) updatedAt: Date; - @VersionColumn({ default: 1 }) version: number; + + // Virtual: populated via JOIN + issuer?: any; } diff --git a/backend/services/issuer-service/src/domain/entities/employee.entity.ts b/backend/services/issuer-service/src/domain/entities/employee.entity.ts new file mode 100644 index 0000000..54dd606 --- /dev/null +++ b/backend/services/issuer-service/src/domain/entities/employee.entity.ts @@ -0,0 +1,16 @@ +import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'; + +@Entity('employees') +@Index('idx_employees_issuer', ['issuerId']) +@Index('idx_employees_store', ['storeId']) +export class Employee { + @PrimaryGeneratedColumn('uuid') id: string; + @Column({ name: 'issuer_id', type: 'uuid' }) issuerId: string; + @Column({ name: 'store_id', type: 'uuid', nullable: true }) storeId: string | null; + @Column({ type: 'varchar', length: 100 }) name: string; + @Column({ type: 'varchar', length: 20, nullable: true }) phone: string | null; + @Column({ type: 'varchar', length: 50, default: 'staff' }) role: string; // 'manager' | 'cashier' | 'staff' + @Column({ type: 'varchar', length: 20, default: 'active' }) status: string; + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) updatedAt: Date; +} diff --git a/backend/services/issuer-service/src/domain/entities/redemption.entity.ts b/backend/services/issuer-service/src/domain/entities/redemption.entity.ts new file mode 100644 index 0000000..ee90ae0 --- /dev/null +++ b/backend/services/issuer-service/src/domain/entities/redemption.entity.ts @@ -0,0 +1,18 @@ +import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, Index } from 'typeorm'; + +@Entity('redemptions') +@Index('idx_redemptions_coupon', ['couponId']) +@Index('idx_redemptions_issuer', ['issuerId']) +@Index('idx_redemptions_user', ['userId']) +@Index('idx_redemptions_store', ['storeId']) +export class Redemption { + @PrimaryGeneratedColumn('uuid') id: string; + @Column({ name: 'coupon_id', type: 'uuid' }) couponId: string; + @Column({ name: 'issuer_id', type: 'uuid' }) issuerId: string; + @Column({ name: 'user_id', type: 'uuid' }) userId: string; + @Column({ name: 'store_id', type: 'uuid', nullable: true }) storeId: string | null; + @Column({ type: 'varchar', length: 20 }) method: string; // 'scan' | 'manual' | 'batch' + @Column({ type: 'numeric', precision: 12, scale: 2 }) amount: string; + @Column({ type: 'varchar', length: 20, default: 'completed' }) status: string; + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; +} diff --git a/backend/services/issuer-service/src/domain/entities/store.entity.ts b/backend/services/issuer-service/src/domain/entities/store.entity.ts index c775b19..7eef66e 100644 --- a/backend/services/issuer-service/src/domain/entities/store.entity.ts +++ b/backend/services/issuer-service/src/domain/entities/store.entity.ts @@ -1,15 +1,19 @@ -import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'; @Entity('stores') +@Index('idx_stores_issuer', ['issuerId']) +@Index('idx_stores_parent', ['parentId']) export class Store { @PrimaryGeneratedColumn('uuid') id: string; @Column({ name: 'issuer_id', type: 'uuid' }) issuerId: string; @Column({ type: 'varchar', length: 200 }) name: string; - @Column({ type: 'text', nullable: true }) address: string | null; + @Column({ type: 'varchar', length: 500, nullable: true }) address: string | null; @Column({ type: 'numeric', precision: 10, scale: 7, nullable: true }) latitude: string | null; @Column({ type: 'numeric', precision: 10, scale: 7, nullable: true }) longitude: string | null; @Column({ type: 'varchar', length: 20, nullable: true }) phone: string | null; @Column({ name: 'business_hours', type: 'varchar', length: 200, nullable: true }) businessHours: string | null; + @Column({ type: 'varchar', length: 50, default: 'store' }) level: string; // 'hq' | 'region' | 'store' + @Column({ name: 'parent_id', type: 'uuid', nullable: true }) parentId: string | null; @Column({ type: 'varchar', length: 20, default: 'active' }) status: string; @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) updatedAt: Date; diff --git a/backend/services/issuer-service/src/domain/repositories/coupon.repository.interface.ts b/backend/services/issuer-service/src/domain/repositories/coupon.repository.interface.ts index 0b80a69..7b7f314 100644 --- a/backend/services/issuer-service/src/domain/repositories/coupon.repository.interface.ts +++ b/backend/services/issuer-service/src/domain/repositories/coupon.repository.interface.ts @@ -56,13 +56,21 @@ export interface RedemptionRateRow { totalSold: number; } +export interface OwnerSummary { + count: number; + totalFaceValue: number; + totalSaved: number; +} + export interface ICouponRepository { findById(id: string): Promise; create(data: Partial): Promise; save(coupon: Coupon): Promise; findAndCount(filters: CouponListFilters): Promise<[Coupon[], number]>; findAndCountWithIssuerJoin(filters: CouponListFilters): Promise<[Coupon[], number]>; - updateStatus(id: string, status: CouponStatus): Promise; + findByOwnerWithIssuerJoin(userId: string, filters: CouponListFilters): Promise<[Coupon[], number]>; + getOwnerSummary(userId: string): Promise; + updateStatus(id: string, status: string): Promise; purchaseWithLock(couponId: string, quantity: number): Promise; count(where?: Partial>): Promise; diff --git a/backend/services/issuer-service/src/domain/repositories/employee.repository.interface.ts b/backend/services/issuer-service/src/domain/repositories/employee.repository.interface.ts new file mode 100644 index 0000000..d1ccc3b --- /dev/null +++ b/backend/services/issuer-service/src/domain/repositories/employee.repository.interface.ts @@ -0,0 +1,15 @@ +import { Employee } from '../entities/employee.entity'; + +export const EMPLOYEE_REPOSITORY = Symbol('IEmployeeRepository'); + +export interface IEmployeeRepository { + findById(id: string): Promise; + create(data: Partial): Promise; + save(employee: Employee): Promise; + delete(id: string): Promise; + findByIssuerId( + issuerId: string, + filters?: { storeId?: string; role?: string }, + ): Promise; + count(where?: Partial>): Promise; +} diff --git a/backend/services/issuer-service/src/domain/repositories/redemption.repository.interface.ts b/backend/services/issuer-service/src/domain/repositories/redemption.repository.interface.ts new file mode 100644 index 0000000..4ff68dd --- /dev/null +++ b/backend/services/issuer-service/src/domain/repositories/redemption.repository.interface.ts @@ -0,0 +1,19 @@ +import { Redemption } from '../entities/redemption.entity'; + +export const REDEMPTION_REPOSITORY = Symbol('IRedemptionRepository'); + +export interface RedemptionListFilters { + issuerId?: string; + startDate?: string; + endDate?: string; + page: number; + limit: number; +} + +export interface IRedemptionRepository { + findById(id: string): Promise; + create(data: Partial): Promise; + createMany(data: Partial[]): Promise; + findAndCount(filters: RedemptionListFilters): Promise<[Redemption[], number]>; + getTodayStats(issuerId: string): Promise<{ count: number; totalAmount: number }>; +} diff --git a/backend/services/issuer-service/src/domain/repositories/store.repository.interface.ts b/backend/services/issuer-service/src/domain/repositories/store.repository.interface.ts index 17fe69b..2be1985 100644 --- a/backend/services/issuer-service/src/domain/repositories/store.repository.interface.ts +++ b/backend/services/issuer-service/src/domain/repositories/store.repository.interface.ts @@ -4,9 +4,14 @@ export const STORE_REPOSITORY = Symbol('IStoreRepository'); export interface IStoreRepository { findById(id: string): Promise; + create(data: Partial): Promise; save(store: Store): Promise; + delete(id: string): Promise; count(where?: Partial>): Promise; - findByIssuerId(issuerId: string): Promise; + findByIssuerId( + issuerId: string, + filters?: { level?: string; parentId?: string; status?: string }, + ): Promise; findByIssuerIds(issuerIds: string[]): Promise; findTopStores(limit: number): Promise; } diff --git a/backend/services/issuer-service/src/infrastructure/persistence/coupon.repository.ts b/backend/services/issuer-service/src/infrastructure/persistence/coupon.repository.ts index d01fcde..fc500dd 100644 --- a/backend/services/issuer-service/src/infrastructure/persistence/coupon.repository.ts +++ b/backend/services/issuer-service/src/infrastructure/persistence/coupon.repository.ts @@ -13,6 +13,7 @@ import { CouponSoldAggregateRow, DiscountDistributionRow, RedemptionRateRow, + OwnerSummary, } from '../../domain/repositories/coupon.repository.interface'; @Injectable() @@ -78,7 +79,41 @@ export class CouponRepository implements ICouponRepository { return qb.getManyAndCount(); } - async updateStatus(id: string, status: CouponStatus): Promise { + async findByOwnerWithIssuerJoin(userId: string, filters: CouponListFilters): Promise<[Coupon[], number]> { + const { status, page, limit } = filters; + const qb = this.repo.createQueryBuilder('c'); + + qb.leftJoinAndMapOne('c.issuer', Issuer, 'i', 'i.id = c.issuer_id'); + qb.andWhere('c.owner_user_id = :userId', { userId }); + + if (status) qb.andWhere('c.status = :status', { status }); + + qb.orderBy('c.created_at', 'DESC') + .skip((page - 1) * limit) + .take(limit); + + return qb.getManyAndCount(); + } + + async getOwnerSummary(userId: string): Promise { + const result = await this.repo + .createQueryBuilder('c') + .select([ + 'COUNT(c.id) as "count"', + 'COALESCE(SUM(CAST(c.face_value AS numeric)), 0) as "totalFaceValue"', + 'COALESCE(SUM(CAST(c.face_value AS numeric) - COALESCE(CAST(c.current_price AS numeric), CAST(c.face_value AS numeric))), 0) as "totalSaved"', + ]) + .where('c.owner_user_id = :userId', { userId }) + .getRawOne(); + + return { + count: Number(result.count) || 0, + totalFaceValue: Number(result.totalFaceValue) || 0, + totalSaved: Number(result.totalSaved) || 0, + }; + } + + async updateStatus(id: string, status: string): Promise { const coupon = await this.repo.findOne({ where: { id } }); if (!coupon) throw new NotFoundException('Coupon not found'); coupon.status = status; @@ -92,14 +127,14 @@ export class CouponRepository implements ICouponRepository { lock: { mode: 'pessimistic_write' }, }); if (!coupon) throw new NotFoundException('Coupon not found'); - if (coupon.status !== CouponStatus.ACTIVE) { + if (coupon.status !== CouponStatus.LISTED) { throw new BadRequestException('Coupon is not available'); } if (coupon.remainingSupply < quantity) { throw new BadRequestException('Insufficient supply'); } coupon.remainingSupply -= quantity; - if (coupon.remainingSupply === 0) coupon.status = CouponStatus.SOLD_OUT; + if (coupon.remainingSupply === 0) coupon.status = CouponStatus.SOLD; await manager.save(coupon); return coupon; }); @@ -175,7 +210,7 @@ export class CouponRepository implements ICouponRepository { 'COUNT(c.id) as "couponCount"', 'COALESCE(SUM(c.total_supply), 0) as "totalSupply"', 'COALESCE(SUM(c.total_supply - c.remaining_supply), 0) as "totalSold"', - 'COALESCE(AVG(CAST(c.price AS numeric)), 0) as "avgPrice"', + 'COALESCE(AVG(CAST(c.current_price AS numeric)), 0) as "avgPrice"', ]) .groupBy('c.category') .orderBy('"couponCount"', 'DESC') @@ -232,17 +267,17 @@ export class CouponRepository implements ICouponRepository { .select([ `CASE WHEN CAST(c.face_value AS numeric) = 0 THEN 'N/A' - WHEN (1 - CAST(c.price AS numeric) / CAST(c.face_value AS numeric)) * 100 < 0 THEN 'premium' - WHEN (1 - CAST(c.price AS numeric) / CAST(c.face_value AS numeric)) * 100 < 10 THEN '0-10%' - WHEN (1 - CAST(c.price AS numeric) / CAST(c.face_value AS numeric)) * 100 < 20 THEN '10-20%' - WHEN (1 - CAST(c.price AS numeric) / CAST(c.face_value AS numeric)) * 100 < 30 THEN '20-30%' - WHEN (1 - CAST(c.price AS numeric) / CAST(c.face_value AS numeric)) * 100 < 50 THEN '30-50%' + WHEN (1 - CAST(c.current_price AS numeric) / CAST(c.face_value AS numeric)) * 100 < 0 THEN 'premium' + WHEN (1 - CAST(c.current_price AS numeric) / CAST(c.face_value AS numeric)) * 100 < 10 THEN '0-10%' + WHEN (1 - CAST(c.current_price AS numeric) / CAST(c.face_value AS numeric)) * 100 < 20 THEN '10-20%' + WHEN (1 - CAST(c.current_price AS numeric) / CAST(c.face_value AS numeric)) * 100 < 30 THEN '20-30%' + WHEN (1 - CAST(c.current_price AS numeric) / CAST(c.face_value AS numeric)) * 100 < 50 THEN '30-50%' ELSE '50%+' END as "range"`, 'COUNT(c.id) as "count"', `COALESCE(AVG( CASE WHEN CAST(c.face_value AS numeric) > 0 - THEN (1 - CAST(c.price AS numeric) / CAST(c.face_value AS numeric)) * 100 + THEN (1 - CAST(c.current_price AS numeric) / CAST(c.face_value AS numeric)) * 100 ELSE 0 END ), 0) as "avgDiscount"`, diff --git a/backend/services/issuer-service/src/infrastructure/persistence/employee.repository.ts b/backend/services/issuer-service/src/infrastructure/persistence/employee.repository.ts new file mode 100644 index 0000000..b3e44a5 --- /dev/null +++ b/backend/services/issuer-service/src/infrastructure/persistence/employee.repository.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Employee } from '../../domain/entities/employee.entity'; +import { IEmployeeRepository } from '../../domain/repositories/employee.repository.interface'; + +@Injectable() +export class EmployeeRepository implements IEmployeeRepository { + constructor( + @InjectRepository(Employee) + private readonly repo: Repository, + ) {} + + async findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + async create(data: Partial): Promise { + const entity = this.repo.create(data); + return this.repo.save(entity); + } + + async save(employee: Employee): Promise { + return this.repo.save(employee); + } + + async delete(id: string): Promise { + await this.repo.delete(id); + } + + async findByIssuerId( + issuerId: string, + filters?: { storeId?: string; role?: string }, + ): Promise { + const qb = this.repo.createQueryBuilder('e'); + qb.where('e.issuer_id = :issuerId', { issuerId }); + + if (filters?.storeId) qb.andWhere('e.store_id = :storeId', { storeId: filters.storeId }); + if (filters?.role) qb.andWhere('e.role = :role', { role: filters.role }); + + qb.orderBy('e.created_at', 'DESC'); + return qb.getMany(); + } + + async count(where?: Partial>): Promise { + return this.repo.count({ where: where as any }); + } +} diff --git a/backend/services/issuer-service/src/infrastructure/persistence/redemption.repository.ts b/backend/services/issuer-service/src/infrastructure/persistence/redemption.repository.ts new file mode 100644 index 0000000..602c671 --- /dev/null +++ b/backend/services/issuer-service/src/infrastructure/persistence/redemption.repository.ts @@ -0,0 +1,62 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Redemption } from '../../domain/entities/redemption.entity'; +import { + IRedemptionRepository, + RedemptionListFilters, +} from '../../domain/repositories/redemption.repository.interface'; + +@Injectable() +export class RedemptionRepository implements IRedemptionRepository { + constructor( + @InjectRepository(Redemption) + private readonly repo: Repository, + ) {} + + async findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + async create(data: Partial): Promise { + const entity = this.repo.create(data); + return this.repo.save(entity); + } + + async createMany(data: Partial[]): Promise { + const entities = this.repo.create(data); + return this.repo.save(entities); + } + + async findAndCount(filters: RedemptionListFilters): Promise<[Redemption[], number]> { + const { issuerId, startDate, endDate, page, limit } = filters; + const qb = this.repo.createQueryBuilder('r'); + + if (issuerId) qb.andWhere('r.issuer_id = :issuerId', { issuerId }); + if (startDate) qb.andWhere('r.created_at >= :startDate', { startDate }); + if (endDate) qb.andWhere('r.created_at <= :endDate', { endDate }); + + qb.orderBy('r.created_at', 'DESC') + .skip((page - 1) * limit) + .take(limit); + + return qb.getManyAndCount(); + } + + async getTodayStats(issuerId: string): Promise<{ count: number; totalAmount: number }> { + const result = await this.repo + .createQueryBuilder('r') + .select([ + 'COUNT(r.id) as "count"', + 'COALESCE(SUM(CAST(r.amount AS numeric)), 0) as "totalAmount"', + ]) + .where('r.issuer_id = :issuerId', { issuerId }) + .andWhere('r.created_at >= CURRENT_DATE') + .getRawOne(); + + return { + count: Number(result.count) || 0, + totalAmount: Number(result.totalAmount) || 0, + }; + } +} diff --git a/backend/services/issuer-service/src/infrastructure/persistence/store.repository.ts b/backend/services/issuer-service/src/infrastructure/persistence/store.repository.ts index cd7742b..93c553a 100644 --- a/backend/services/issuer-service/src/infrastructure/persistence/store.repository.ts +++ b/backend/services/issuer-service/src/infrastructure/persistence/store.repository.ts @@ -15,16 +15,36 @@ export class StoreRepository implements IStoreRepository { return this.repo.findOne({ where: { id } }); } + async create(data: Partial): Promise { + const entity = this.repo.create(data); + return this.repo.save(entity); + } + async save(store: Store): Promise { return this.repo.save(store); } + async delete(id: string): Promise { + await this.repo.delete(id); + } + async count(where?: Partial>): Promise { return this.repo.count({ where: where as any }); } - async findByIssuerId(issuerId: string): Promise { - return this.repo.find({ where: { issuerId } }); + async findByIssuerId( + issuerId: string, + filters?: { level?: string; parentId?: string; status?: string }, + ): Promise { + const qb = this.repo.createQueryBuilder('s'); + qb.where('s.issuer_id = :issuerId', { issuerId }); + + if (filters?.level) qb.andWhere('s.level = :level', { level: filters.level }); + if (filters?.parentId) qb.andWhere('s.parent_id = :parentId', { parentId: filters.parentId }); + if (filters?.status) qb.andWhere('s.status = :status', { status: filters.status }); + + qb.orderBy('s.created_at', 'DESC'); + return qb.getMany(); } async findByIssuerIds(issuerIds: string[]): Promise { diff --git a/backend/services/issuer-service/src/interface/http/controllers/coupon-batch.controller.ts b/backend/services/issuer-service/src/interface/http/controllers/coupon-batch.controller.ts new file mode 100644 index 0000000..58d75d1 --- /dev/null +++ b/backend/services/issuer-service/src/interface/http/controllers/coupon-batch.controller.ts @@ -0,0 +1,63 @@ +import { Controller, Get, Post, Body, Query, UseGuards, Req } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { AuthGuard } from '@nestjs/passport'; +import { CouponBatchService } from '../../../application/services/coupon-batch.service'; +import { + BatchIssueDto, + BatchRecallDto, + BatchPriceAdjustDto, + ListBatchOperationsQueryDto, +} from '../dto/coupon-batch.dto'; + +@ApiTags('Coupon Batch Operations') +@Controller('coupons/batch') +@UseGuards(AuthGuard('jwt')) +@ApiBearerAuth() +export class CouponBatchController { + constructor(private readonly couponBatchService: CouponBatchService) {} + + @Post('issue') + @ApiOperation({ summary: 'Batch issue coupons from a template' }) + async batchIssue(@Req() req: any, @Body() dto: BatchIssueDto) { + const result = await this.couponBatchService.batchIssue( + req.user.issuerId, + dto.templateId, + dto.quantity, + dto.expiryDate, + ); + return { code: 0, data: result }; + } + + @Post('recall') + @ApiOperation({ summary: 'Batch recall coupons' }) + async batchRecall(@Req() req: any, @Body() dto: BatchRecallDto) { + const result = await this.couponBatchService.batchRecall( + req.user.issuerId, + dto.couponIds, + dto.reason, + ); + return { code: 0, data: result }; + } + + @Post('price-adjust') + @ApiOperation({ summary: 'Batch adjust coupon prices' }) + async batchPriceAdjust(@Req() req: any, @Body() dto: BatchPriceAdjustDto) { + const result = await this.couponBatchService.batchPriceAdjust( + req.user.issuerId, + dto.couponIds, + dto.adjustPercent, + ); + return { code: 0, data: result }; + } + + @Get('operations') + @ApiOperation({ summary: 'List batch operation history' }) + async listOperations(@Req() req: any, @Query() query: ListBatchOperationsQueryDto) { + const result = await this.couponBatchService.listOperations( + req.user.issuerId, + query.page || 1, + query.limit || 20, + ); + return { code: 0, data: result }; + } +} diff --git a/backend/services/issuer-service/src/interface/http/controllers/coupon.controller.ts b/backend/services/issuer-service/src/interface/http/controllers/coupon.controller.ts index 426e42c..261084d 100644 --- a/backend/services/issuer-service/src/interface/http/controllers/coupon.controller.ts +++ b/backend/services/issuer-service/src/interface/http/controllers/coupon.controller.ts @@ -22,9 +22,9 @@ export class CouponController { const coupon = await this.couponService.create(req.user.id, { ...dto, faceValue: String(dto.faceValue), - price: String(dto.price), - validFrom: new Date(dto.validFrom), - validUntil: new Date(dto.validUntil), + currentPrice: String(dto.price), + issuePrice: String(dto.price), + expiryDate: new Date(dto.validUntil), } as any); return { code: 0, data: coupon }; } @@ -45,6 +45,62 @@ export class CouponController { return { code: 0, data: result }; } + @Get('my') + @UseGuards(AuthGuard('jwt')) + @ApiBearerAuth() + @ApiOperation({ summary: 'Get current user coupon holdings' }) + async getMyHoldings(@Req() req: any, @Query() query: ListCouponsQueryDto) { + const result = await this.couponService.getByOwner( + req.user.id, + query.page || 1, + query.limit || 20, + query.status, + ); + return { code: 0, data: result }; + } + + @Get('my/summary') + @UseGuards(AuthGuard('jwt')) + @ApiBearerAuth() + @ApiOperation({ summary: 'Get user holdings summary (count + total value + saved)' }) + async getMySummary(@Req() req: any) { + const summary = await this.couponService.getOwnerSummary(req.user.id); + return { code: 0, data: summary }; + } + + @Get('search') + @ApiOperation({ summary: 'Search coupons with keyword, category, and sort' }) + async search( + @Query('q') q?: string, + @Query('category') category?: string, + @Query('sort') sort?: string, + @Query('page') page?: string, + @Query('limit') limit?: string, + ) { + const result = await this.couponService.search( + q, + category, + sort || 'newest', + parseInt(page || '1'), + parseInt(limit || '20'), + ); + return { code: 0, data: result }; + } + + @Get(':id/nearby-stores') + @ApiOperation({ summary: 'List stores that accept this coupon' }) + async getNearbyStores(@Param('id') id: string) { + const stores = await this.couponService.getNearbyStores(id); + return { code: 0, data: stores }; + } + + @Get(':id/similar') + @ApiOperation({ summary: 'Find similar coupons (same category, different issuer)' }) + async getSimilar(@Param('id') id: string, @Query('limit') limit?: string) { + const similar = await this.couponService.findSimilar(id, parseInt(limit || '10')); + return { code: 0, data: similar }; + } + @Get(':id') @ApiOperation({ summary: 'Get coupon details' }) async getById(@Param('id') id: string) { diff --git a/backend/services/issuer-service/src/interface/http/controllers/issuer-finance.controller.ts b/backend/services/issuer-service/src/interface/http/controllers/issuer-finance.controller.ts new file mode 100644 index 0000000..bb1a9f6 --- /dev/null +++ b/backend/services/issuer-service/src/interface/http/controllers/issuer-finance.controller.ts @@ -0,0 +1,64 @@ +import { Controller, Get, Post, Body, Query, UseGuards, Req } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { AuthGuard } from '@nestjs/passport'; +import { IssuerFinanceService } from '../../../application/services/issuer-finance.service'; +import { + WithdrawDto, + ListTransactionsQueryDto, + ReconciliationQueryDto, +} from '../dto/issuer-finance.dto'; + +@ApiTags('Issuer Finance') +@Controller('issuers/me/finance') +@UseGuards(AuthGuard('jwt')) +@ApiBearerAuth() +export class IssuerFinanceController { + constructor(private readonly issuerFinanceService: IssuerFinanceService) {} + + @Get('balance') + @ApiOperation({ summary: 'Get issuer financial balance' }) + async getBalance(@Req() req: any) { + const balance = await this.issuerFinanceService.getBalance(req.user.issuerId); + return { code: 0, data: balance }; + } + + @Get('stats') + @ApiOperation({ summary: 'Get issuer finance summary' }) + async getFinanceStats(@Req() req: any) { + const stats = await this.issuerFinanceService.getFinanceStats(req.user.issuerId); + return { code: 0, data: stats }; + } + + @Get('transactions') + @ApiOperation({ summary: 'Get issuer transaction history' }) + async getTransactions(@Req() req: any, @Query() query: ListTransactionsQueryDto) { + const result = await this.issuerFinanceService.getTransactions( + req.user.issuerId, + query.page || 1, + query.limit || 20, + query.type, + ); + return { code: 0, data: result }; + } + + @Post('withdraw') + @ApiOperation({ summary: 'Request a withdrawal' }) + async requestWithdrawal(@Req() req: any, @Body() dto: WithdrawDto) { + const result = await this.issuerFinanceService.requestWithdrawal( + req.user.issuerId, + dto.amount, + ); + return { code: 0, data: result }; + } + + @Get('reconciliation') + @ApiOperation({ summary: 'Get reconciliation data' }) + async getReconciliation(@Req() req: any, @Query() query: ReconciliationQueryDto) { + const result = await this.issuerFinanceService.getReconciliation( + req.user.issuerId, + query.startDate, + query.endDate, + ); + return { code: 0, data: result }; + } +} diff --git a/backend/services/issuer-service/src/interface/http/controllers/issuer-stats.controller.ts b/backend/services/issuer-service/src/interface/http/controllers/issuer-stats.controller.ts new file mode 100644 index 0000000..9856acb --- /dev/null +++ b/backend/services/issuer-service/src/interface/http/controllers/issuer-stats.controller.ts @@ -0,0 +1,26 @@ +import { Controller, Get, UseGuards, Req } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { AuthGuard } from '@nestjs/passport'; +import { IssuerStatsService } from '../../../application/services/issuer-stats.service'; + +@ApiTags('Issuer Stats') +@Controller('issuers/me') +@UseGuards(AuthGuard('jwt')) +@ApiBearerAuth() +export class IssuerStatsController { + constructor(private readonly issuerStatsService: IssuerStatsService) {} + + @Get('stats') + @ApiOperation({ summary: 'Get issuer dashboard stats' }) + async getStats(@Req() req: any) { + const stats = await this.issuerStatsService.getDashboardStats(req.user.issuerId); + return { code: 0, data: stats }; + } + + @Get('credit') + @ApiOperation({ summary: 'Get issuer credit score details' }) + async getCreditDetails(@Req() req: any) { + const credit = await this.issuerStatsService.getCreditDetails(req.user.issuerId); + return { code: 0, data: credit }; + } +} diff --git a/backend/services/issuer-service/src/interface/http/controllers/issuer-store.controller.ts b/backend/services/issuer-service/src/interface/http/controllers/issuer-store.controller.ts new file mode 100644 index 0000000..3f5106c --- /dev/null +++ b/backend/services/issuer-service/src/interface/http/controllers/issuer-store.controller.ts @@ -0,0 +1,119 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + UseGuards, + Req, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { AuthGuard } from '@nestjs/passport'; +import { IssuerStoreService } from '../../../application/services/issuer-store.service'; +import { CreateStoreDto, UpdateStoreDto, ListStoresQueryDto } from '../dto/store.dto'; +import { CreateEmployeeDto, UpdateEmployeeDto, ListEmployeesQueryDto } from '../dto/employee.dto'; + +// ---- Store Controller ---- + +@ApiTags('Issuer Stores') +@Controller('issuers/me/stores') +@UseGuards(AuthGuard('jwt')) +@ApiBearerAuth() +export class IssuerStoreController { + constructor(private readonly issuerStoreService: IssuerStoreService) {} + + @Get() + @ApiOperation({ summary: 'List stores for the current issuer' }) + async listStores(@Req() req: any, @Query() query: ListStoresQueryDto) { + const stores = await this.issuerStoreService.listStores(req.user.issuerId, { + level: query.level, + parentId: query.parentId, + status: query.status, + }); + return { code: 0, data: stores }; + } + + @Post() + @ApiOperation({ summary: 'Create a new store' }) + async createStore(@Req() req: any, @Body() dto: CreateStoreDto) { + const store = await this.issuerStoreService.createStore(req.user.issuerId, { + name: dto.name, + address: dto.address || null, + phone: dto.phone || null, + level: dto.level || 'store', + parentId: dto.parentId || null, + }); + return { code: 0, data: store }; + } + + @Put(':id') + @ApiOperation({ summary: 'Update a store' }) + async updateStore( + @Req() req: any, + @Param('id') id: string, + @Body() dto: UpdateStoreDto, + ) { + const store = await this.issuerStoreService.updateStore(req.user.issuerId, id, dto); + return { code: 0, data: store }; + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a store' }) + async deleteStore(@Req() req: any, @Param('id') id: string) { + await this.issuerStoreService.deleteStore(req.user.issuerId, id); + return { code: 0, message: 'Store deleted' }; + } +} + +// ---- Employee Controller ---- + +@ApiTags('Issuer Employees') +@Controller('issuers/me/employees') +@UseGuards(AuthGuard('jwt')) +@ApiBearerAuth() +export class IssuerEmployeeController { + constructor(private readonly issuerStoreService: IssuerStoreService) {} + + @Get() + @ApiOperation({ summary: 'List employees for the current issuer' }) + async listEmployees(@Req() req: any, @Query() query: ListEmployeesQueryDto) { + const employees = await this.issuerStoreService.listEmployees(req.user.issuerId, { + storeId: query.storeId, + role: query.role, + }); + return { code: 0, data: employees }; + } + + @Post() + @ApiOperation({ summary: 'Create a new employee' }) + async createEmployee(@Req() req: any, @Body() dto: CreateEmployeeDto) { + const employee = await this.issuerStoreService.createEmployee(req.user.issuerId, { + name: dto.name, + phone: dto.phone || null, + role: dto.role || 'staff', + storeId: dto.storeId || null, + }); + return { code: 0, data: employee }; + } + + @Put(':id') + @ApiOperation({ summary: 'Update an employee' }) + async updateEmployee( + @Req() req: any, + @Param('id') id: string, + @Body() dto: UpdateEmployeeDto, + ) { + const employee = await this.issuerStoreService.updateEmployee(req.user.issuerId, id, dto); + return { code: 0, data: employee }; + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete an employee' }) + async deleteEmployee(@Req() req: any, @Param('id') id: string) { + await this.issuerStoreService.deleteEmployee(req.user.issuerId, id); + return { code: 0, message: 'Employee deleted' }; + } +} diff --git a/backend/services/issuer-service/src/interface/http/controllers/redemption.controller.ts b/backend/services/issuer-service/src/interface/http/controllers/redemption.controller.ts new file mode 100644 index 0000000..310321f --- /dev/null +++ b/backend/services/issuer-service/src/interface/http/controllers/redemption.controller.ts @@ -0,0 +1,71 @@ +import { Controller, Get, Post, Body, Query, UseGuards, Req } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { AuthGuard } from '@nestjs/passport'; +import { RedemptionService } from '../../../application/services/redemption.service'; +import { + ScanRedemptionDto, + ManualRedemptionDto, + BatchRedemptionDto, + ListRedemptionsQueryDto, +} from '../dto/redemption.dto'; + +@ApiTags('Redemptions') +@Controller('redemptions') +@UseGuards(AuthGuard('jwt')) +@ApiBearerAuth() +export class RedemptionController { + constructor(private readonly redemptionService: RedemptionService) {} + + @Post('scan') + @ApiOperation({ summary: 'Redeem a coupon by QR code scan' }) + async scanRedeem(@Req() req: any, @Body() dto: ScanRedemptionDto) { + const result = await this.redemptionService.scanRedeem( + req.user.issuerId, + dto.qrCode, + dto.storeId, + ); + return { code: 0, data: result }; + } + + @Post('manual') + @ApiOperation({ summary: 'Redeem a coupon by manual code entry' }) + async manualRedeem(@Req() req: any, @Body() dto: ManualRedemptionDto) { + const result = await this.redemptionService.manualRedeem( + req.user.issuerId, + dto.couponCode, + dto.storeId, + ); + return { code: 0, data: result }; + } + + @Post('batch') + @ApiOperation({ summary: 'Batch redeem multiple coupons' }) + async batchRedeem(@Req() req: any, @Body() dto: BatchRedemptionDto) { + const result = await this.redemptionService.batchRedeem( + req.user.issuerId, + dto.couponCodes, + dto.storeId, + ); + return { code: 0, data: result }; + } + + @Get() + @ApiOperation({ summary: 'List redemption history' }) + async listRedemptions(@Req() req: any, @Query() query: ListRedemptionsQueryDto) { + const result = await this.redemptionService.listRedemptions( + req.user.issuerId, + query.page || 1, + query.limit || 20, + query.startDate, + query.endDate, + ); + return { code: 0, data: result }; + } + + @Get('today-stats') + @ApiOperation({ summary: 'Get today redemption statistics' }) + async getTodayStats(@Req() req: any) { + const stats = await this.redemptionService.getTodayStats(req.user.issuerId); + return { code: 0, data: stats }; + } +} diff --git a/backend/services/issuer-service/src/interface/http/dto/coupon-batch.dto.ts b/backend/services/issuer-service/src/interface/http/dto/coupon-batch.dto.ts new file mode 100644 index 0000000..3428d9c --- /dev/null +++ b/backend/services/issuer-service/src/interface/http/dto/coupon-batch.dto.ts @@ -0,0 +1,73 @@ +import { + IsString, + IsOptional, + IsNumber, + IsArray, + IsInt, + Min, + Max, + IsDateString, + IsUUID, + MaxLength, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; + +export class BatchIssueDto { + @ApiProperty({ description: 'UUID of the coupon template to clone' }) + @IsUUID() + templateId: string; + + @ApiProperty({ minimum: 1, maximum: 10000 }) + @IsInt() + @Min(1) + @Max(10000) + quantity: number; + + @ApiPropertyOptional({ description: 'ISO 8601 date for expiry' }) + @IsOptional() + @IsDateString() + expiryDate?: string; +} + +export class BatchRecallDto { + @ApiProperty({ type: [String], description: 'Array of coupon UUIDs to recall' }) + @IsArray() + @IsUUID('4', { each: true }) + couponIds: string[]; + + @ApiProperty({ maxLength: 1000 }) + @IsString() + @MaxLength(1000) + reason: string; +} + +export class BatchPriceAdjustDto { + @ApiProperty({ type: [String], description: 'Array of coupon UUIDs to adjust' }) + @IsArray() + @IsUUID('4', { each: true }) + couponIds: string[]; + + @ApiProperty({ description: 'Percent adjustment (-50 to +100)', minimum: -50, maximum: 100 }) + @IsNumber() + @Min(-50) + @Max(100) + adjustPercent: number; +} + +export class ListBatchOperationsQueryDto { + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ default: 20 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 20; +} diff --git a/backend/services/issuer-service/src/interface/http/dto/coupon.dto.ts b/backend/services/issuer-service/src/interface/http/dto/coupon.dto.ts index 92bbf50..dfa1308 100644 --- a/backend/services/issuer-service/src/interface/http/dto/coupon.dto.ts +++ b/backend/services/issuer-service/src/interface/http/dto/coupon.dto.ts @@ -83,7 +83,7 @@ export class CreateCouponDto { } export class UpdateCouponStatusDto { - @ApiProperty({ enum: ['draft', 'active', 'paused', 'expired', 'sold_out'] }) + @ApiProperty({ enum: ['minted', 'listed', 'sold', 'in_circulation', 'redeemed', 'expired', 'recalled'] }) @IsString() status: string; } @@ -118,7 +118,7 @@ export class ListCouponsQueryDto { @IsString() category?: string; - @ApiPropertyOptional({ enum: ['draft', 'active', 'paused', 'expired', 'sold_out'] }) + @ApiPropertyOptional({ enum: ['minted', 'listed', 'sold', 'in_circulation', 'redeemed', 'expired', 'recalled'] }) @IsOptional() @IsString() status?: string; diff --git a/backend/services/issuer-service/src/interface/http/dto/employee.dto.ts b/backend/services/issuer-service/src/interface/http/dto/employee.dto.ts new file mode 100644 index 0000000..eac1210 --- /dev/null +++ b/backend/services/issuer-service/src/interface/http/dto/employee.dto.ts @@ -0,0 +1,75 @@ +import { + IsString, + IsOptional, + MaxLength, + IsUUID, + IsIn, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateEmployeeDto { + @ApiProperty({ maxLength: 100 }) + @IsString() + @MaxLength(100) + name: string; + + @ApiPropertyOptional({ maxLength: 20 }) + @IsOptional() + @IsString() + @MaxLength(20) + phone?: string; + + @ApiPropertyOptional({ enum: ['manager', 'cashier', 'staff'], default: 'staff' }) + @IsOptional() + @IsString() + @IsIn(['manager', 'cashier', 'staff']) + role?: string; + + @ApiPropertyOptional({ description: 'UUID of the store this employee belongs to' }) + @IsOptional() + @IsUUID() + storeId?: string; +} + +export class UpdateEmployeeDto { + @ApiPropertyOptional({ maxLength: 100 }) + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @ApiPropertyOptional({ maxLength: 20 }) + @IsOptional() + @IsString() + @MaxLength(20) + phone?: string; + + @ApiPropertyOptional({ enum: ['manager', 'cashier', 'staff'] }) + @IsOptional() + @IsString() + @IsIn(['manager', 'cashier', 'staff']) + role?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsUUID() + storeId?: string; + + @ApiPropertyOptional({ enum: ['active', 'inactive'] }) + @IsOptional() + @IsString() + @IsIn(['active', 'inactive']) + status?: string; +} + +export class ListEmployeesQueryDto { + @ApiPropertyOptional() + @IsOptional() + @IsUUID() + storeId?: string; + + @ApiPropertyOptional({ enum: ['manager', 'cashier', 'staff'] }) + @IsOptional() + @IsString() + role?: string; +} diff --git a/backend/services/issuer-service/src/interface/http/dto/issuer-finance.dto.ts b/backend/services/issuer-service/src/interface/http/dto/issuer-finance.dto.ts new file mode 100644 index 0000000..46459ac --- /dev/null +++ b/backend/services/issuer-service/src/interface/http/dto/issuer-finance.dto.ts @@ -0,0 +1,52 @@ +import { + IsNumber, + IsOptional, + IsString, + IsInt, + Min, + Max, + IsDateString, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; + +export class WithdrawDto { + @ApiProperty({ minimum: 0.01, description: 'Withdrawal amount' }) + @IsNumber() + @Min(0.01) + amount: number; +} + +export class ListTransactionsQueryDto { + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ default: 20 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 20; + + @ApiPropertyOptional({ enum: ['sale', 'redemption', 'withdrawal', 'fee'] }) + @IsOptional() + @IsString() + type?: string; +} + +export class ReconciliationQueryDto { + @ApiPropertyOptional({ description: 'Period start date (ISO 8601)' }) + @IsOptional() + @IsDateString() + startDate?: string; + + @ApiPropertyOptional({ description: 'Period end date (ISO 8601)' }) + @IsOptional() + @IsDateString() + endDate?: string; +} diff --git a/backend/services/issuer-service/src/interface/http/dto/redemption.dto.ts b/backend/services/issuer-service/src/interface/http/dto/redemption.dto.ts new file mode 100644 index 0000000..b3ef5e4 --- /dev/null +++ b/backend/services/issuer-service/src/interface/http/dto/redemption.dto.ts @@ -0,0 +1,76 @@ +import { + IsString, + IsOptional, + IsArray, + IsInt, + Min, + Max, + IsDateString, + IsUUID, + MaxLength, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; + +export class ScanRedemptionDto { + @ApiProperty({ description: 'QR code content' }) + @IsString() + @MaxLength(1000) + qrCode: string; + + @ApiPropertyOptional({ description: 'Store ID where redemption occurs' }) + @IsOptional() + @IsUUID() + storeId?: string; +} + +export class ManualRedemptionDto { + @ApiProperty({ description: 'Coupon code for manual entry' }) + @IsString() + @MaxLength(200) + couponCode: string; + + @ApiPropertyOptional() + @IsOptional() + @IsUUID() + storeId?: string; +} + +export class BatchRedemptionDto { + @ApiProperty({ type: [String], description: 'Array of coupon codes' }) + @IsArray() + @IsString({ each: true }) + couponCodes: string[]; + + @ApiPropertyOptional() + @IsOptional() + @IsUUID() + storeId?: string; +} + +export class ListRedemptionsQueryDto { + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ default: 20 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 20; + + @ApiPropertyOptional({ description: 'ISO 8601 date string' }) + @IsOptional() + @IsDateString() + startDate?: string; + + @ApiPropertyOptional({ description: 'ISO 8601 date string' }) + @IsOptional() + @IsDateString() + endDate?: string; +} diff --git a/backend/services/issuer-service/src/interface/http/dto/store.dto.ts b/backend/services/issuer-service/src/interface/http/dto/store.dto.ts new file mode 100644 index 0000000..6e0b181 --- /dev/null +++ b/backend/services/issuer-service/src/interface/http/dto/store.dto.ts @@ -0,0 +1,92 @@ +import { + IsString, + IsOptional, + MaxLength, + IsUUID, + IsIn, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateStoreDto { + @ApiProperty({ maxLength: 200 }) + @IsString() + @MaxLength(200) + name: string; + + @ApiPropertyOptional({ maxLength: 500 }) + @IsOptional() + @IsString() + @MaxLength(500) + address?: string; + + @ApiPropertyOptional({ maxLength: 20 }) + @IsOptional() + @IsString() + @MaxLength(20) + phone?: string; + + @ApiPropertyOptional({ enum: ['hq', 'region', 'store'], default: 'store' }) + @IsOptional() + @IsString() + @IsIn(['hq', 'region', 'store']) + level?: string; + + @ApiPropertyOptional({ description: 'UUID of the parent store' }) + @IsOptional() + @IsUUID() + parentId?: string; +} + +export class UpdateStoreDto { + @ApiPropertyOptional({ maxLength: 200 }) + @IsOptional() + @IsString() + @MaxLength(200) + name?: string; + + @ApiPropertyOptional({ maxLength: 500 }) + @IsOptional() + @IsString() + @MaxLength(500) + address?: string; + + @ApiPropertyOptional({ maxLength: 20 }) + @IsOptional() + @IsString() + @MaxLength(20) + phone?: string; + + @ApiPropertyOptional({ enum: ['hq', 'region', 'store'] }) + @IsOptional() + @IsString() + @IsIn(['hq', 'region', 'store']) + level?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsUUID() + parentId?: string; + + @ApiPropertyOptional({ enum: ['active', 'inactive', 'closed'] }) + @IsOptional() + @IsString() + @IsIn(['active', 'inactive', 'closed']) + status?: string; +} + +export class ListStoresQueryDto { + @ApiPropertyOptional({ enum: ['hq', 'region', 'store'] }) + @IsOptional() + @IsString() + level?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsUUID() + parentId?: string; + + @ApiPropertyOptional({ enum: ['active', 'inactive', 'closed'] }) + @IsOptional() + @IsString() + status?: string; +} diff --git a/backend/services/issuer-service/src/issuer.module.ts b/backend/services/issuer-service/src/issuer.module.ts index aa174f7..abf5247 100644 --- a/backend/services/issuer-service/src/issuer.module.ts +++ b/backend/services/issuer-service/src/issuer.module.ts @@ -9,6 +9,8 @@ import { Coupon } from './domain/entities/coupon.entity'; import { Store } from './domain/entities/store.entity'; import { CouponRule } from './domain/entities/coupon-rule.entity'; import { CreditMetric } from './domain/entities/credit-metric.entity'; +import { Employee } from './domain/entities/employee.entity'; +import { Redemption } from './domain/entities/redemption.entity'; // Domain repository interfaces (Symbols) import { ISSUER_REPOSITORY } from './domain/repositories/issuer.repository.interface'; @@ -16,6 +18,8 @@ import { COUPON_REPOSITORY } from './domain/repositories/coupon.repository.inter import { COUPON_RULE_REPOSITORY } from './domain/repositories/coupon-rule.repository.interface'; import { STORE_REPOSITORY } from './domain/repositories/store.repository.interface'; import { CREDIT_METRIC_REPOSITORY } from './domain/repositories/credit-metric.repository.interface'; +import { EMPLOYEE_REPOSITORY } from './domain/repositories/employee.repository.interface'; +import { REDEMPTION_REPOSITORY } from './domain/repositories/redemption.repository.interface'; // Infrastructure persistence implementations import { IssuerRepository } from './infrastructure/persistence/issuer.repository'; @@ -23,6 +27,8 @@ import { CouponRepository } from './infrastructure/persistence/coupon.repository import { CouponRuleRepository } from './infrastructure/persistence/coupon-rule.repository'; import { StoreRepository } from './infrastructure/persistence/store.repository'; import { CreditMetricRepository } from './infrastructure/persistence/credit-metric.repository'; +import { EmployeeRepository } from './infrastructure/persistence/employee.repository'; +import { RedemptionRepository } from './infrastructure/persistence/redemption.repository'; // Domain ports import { AI_SERVICE_CLIENT } from './domain/ports/ai-service.client.interface'; @@ -39,6 +45,11 @@ import { AdminIssuerService } from './application/services/admin-issuer.service' import { AdminCouponService } from './application/services/admin-coupon.service'; import { AdminCouponAnalyticsService } from './application/services/admin-coupon-analytics.service'; import { AdminMerchantService } from './application/services/admin-merchant.service'; +import { IssuerStatsService } from './application/services/issuer-stats.service'; +import { IssuerFinanceService } from './application/services/issuer-finance.service'; +import { IssuerStoreService } from './application/services/issuer-store.service'; +import { RedemptionService } from './application/services/redemption.service'; +import { CouponBatchService } from './application/services/coupon-batch.service'; // Interface controllers import { IssuerController } from './interface/http/controllers/issuer.controller'; @@ -47,13 +58,18 @@ import { AdminIssuerController } from './interface/http/controllers/admin-issuer import { AdminCouponController } from './interface/http/controllers/admin-coupon.controller'; import { AdminAnalyticsController } from './interface/http/controllers/admin-analytics.controller'; import { AdminMerchantController } from './interface/http/controllers/admin-merchant.controller'; +import { IssuerStatsController } from './interface/http/controllers/issuer-stats.controller'; +import { IssuerFinanceController } from './interface/http/controllers/issuer-finance.controller'; +import { IssuerStoreController, IssuerEmployeeController } from './interface/http/controllers/issuer-store.controller'; +import { RedemptionController } from './interface/http/controllers/redemption.controller'; +import { CouponBatchController } from './interface/http/controllers/coupon-batch.controller'; // Interface guards import { RolesGuard } from './interface/http/guards/roles.guard'; @Module({ imports: [ - TypeOrmModule.forFeature([Issuer, Coupon, Store, CouponRule, CreditMetric]), + TypeOrmModule.forFeature([Issuer, Coupon, Store, CouponRule, CreditMetric, Employee, Redemption]), PassportModule.register({ defaultStrategy: 'jwt' }), JwtModule.register({ secret: process.env.JWT_ACCESS_SECRET || 'dev-access-secret' }), ], @@ -64,6 +80,12 @@ import { RolesGuard } from './interface/http/guards/roles.guard'; AdminCouponController, AdminAnalyticsController, AdminMerchantController, + IssuerStatsController, + IssuerFinanceController, + IssuerStoreController, + IssuerEmployeeController, + RedemptionController, + CouponBatchController, ], providers: [ // Infrastructure -> Domain port binding (Repository pattern) @@ -72,6 +94,8 @@ import { RolesGuard } from './interface/http/guards/roles.guard'; { provide: COUPON_RULE_REPOSITORY, useClass: CouponRuleRepository }, { provide: STORE_REPOSITORY, useClass: StoreRepository }, { provide: CREDIT_METRIC_REPOSITORY, useClass: CreditMetricRepository }, + { provide: EMPLOYEE_REPOSITORY, useClass: EmployeeRepository }, + { provide: REDEMPTION_REPOSITORY, useClass: RedemptionRepository }, // Infrastructure external services { provide: AI_SERVICE_CLIENT, useClass: AiServiceClient }, @@ -88,6 +112,11 @@ import { RolesGuard } from './interface/http/guards/roles.guard'; AdminCouponService, AdminCouponAnalyticsService, AdminMerchantService, + IssuerStatsService, + IssuerFinanceService, + IssuerStoreService, + RedemptionService, + CouponBatchService, ], exports: [ IssuerService, @@ -98,6 +127,11 @@ import { RolesGuard } from './interface/http/guards/roles.guard'; AdminCouponService, AdminCouponAnalyticsService, AdminMerchantService, + IssuerStatsService, + IssuerFinanceService, + IssuerStoreService, + RedemptionService, + CouponBatchService, ], }) export class IssuerModule {} diff --git a/backend/services/trading-service/cmd/server/main.go b/backend/services/trading-service/cmd/server/main.go index d91dea1..71b4e90 100644 --- a/backend/services/trading-service/cmd/server/main.go +++ b/backend/services/trading-service/cmd/server/main.go @@ -67,6 +67,8 @@ func main() { { trades.POST("/orders", tradeHandler.PlaceOrder) trades.DELETE("/orders/:id", tradeHandler.CancelOrder) + trades.GET("/my/orders", tradeHandler.MyOrders) + trades.POST("/coupons/:id/transfer", tradeHandler.TransferCoupon) } // Public orderbook diff --git a/backend/services/trading-service/internal/application/service/trade_service.go b/backend/services/trading-service/internal/application/service/trade_service.go index 2a4d756..4a0deee 100644 --- a/backend/services/trading-service/internal/application/service/trade_service.go +++ b/backend/services/trading-service/internal/application/service/trade_service.go @@ -141,6 +141,48 @@ func (s *TradeService) GetOrderBookSnapshot(couponID string, depth int) (bids [] return s.matchingService.GetOrderBookSnapshot(couponID, depth) } +// GetOrdersByUserPaginated retrieves paginated orders for a user with optional status filter. +func (s *TradeService) GetOrdersByUserPaginated(ctx context.Context, userID string, status string, page, limit int) ([]*entity.Order, int, error) { + offset := (page - 1) * limit + return s.orderRepo.FindByUserIDPaginated(ctx, userID, status, offset, limit) +} + +// TransferCoupon validates ownership and transfers a coupon to a new owner by updating the order record. +func (s *TradeService) TransferCoupon(ctx context.Context, couponID, ownerUserID, recipientID string) error { + // Validate that the user actually owns this coupon by checking filled buy orders + orders, err := s.orderRepo.FindByCouponID(ctx, couponID) + if err != nil { + return fmt.Errorf("failed to look up coupon orders: %w", err) + } + + // Verify the user has a filled buy order for this coupon (ownership proof) + ownsIt := false + for _, o := range orders { + if o.UserID == ownerUserID && o.Status == entity.OrderFilled && o.Side == vo.Buy { + ownsIt = true + break + } + } + if !ownsIt { + return fmt.Errorf("user does not own coupon %s", couponID) + } + + // Create a transfer record as a special filled order pair + transferID := fmt.Sprintf("xfr-%d", time.Now().UnixNano()) + transferOrder, err := entity.NewOrder( + transferID, recipientID, couponID, + vo.Buy, vo.Market, vo.ZeroPrice(), vo.MustNewQuantity(1), + ) + if err != nil { + return fmt.Errorf("failed to create transfer order: %w", err) + } + transferOrder.Status = entity.OrderFilled + transferOrder.FilledQty = vo.MustNewQuantity(1) + transferOrder.RemainingQty = vo.ZeroQuantity() + + return s.orderRepo.Save(ctx, transferOrder) +} + // GetAllOrderBooks returns all active order books (admin use). func (s *TradeService) GetAllOrderBooks() map[string]*entity.OrderBook { return s.matchingService.GetAllOrderBooks() diff --git a/backend/services/trading-service/internal/domain/repository/order_repository.go b/backend/services/trading-service/internal/domain/repository/order_repository.go index 466bb83..75dd640 100644 --- a/backend/services/trading-service/internal/domain/repository/order_repository.go +++ b/backend/services/trading-service/internal/domain/repository/order_repository.go @@ -28,4 +28,7 @@ type OrderRepository interface { // FindAll retrieves all orders with optional pagination. FindAll(ctx context.Context, offset, limit int) ([]*entity.Order, int, error) + + // FindByUserIDPaginated retrieves paginated orders for a user with optional status filter. + FindByUserIDPaginated(ctx context.Context, userID string, status string, offset, limit int) ([]*entity.Order, int, error) } diff --git a/backend/services/trading-service/internal/infrastructure/postgres/order_repository.go b/backend/services/trading-service/internal/infrastructure/postgres/order_repository.go index b343809..824fcfd 100644 --- a/backend/services/trading-service/internal/infrastructure/postgres/order_repository.go +++ b/backend/services/trading-service/internal/infrastructure/postgres/order_repository.go @@ -205,3 +205,27 @@ func (r *PostgresOrderRepository) FindAll(ctx context.Context, offset, limit int } return result, int(total), nil } + +func (r *PostgresOrderRepository) FindByUserIDPaginated(ctx context.Context, userID string, status string, offset, limit int) ([]*entity.Order, int, error) { + var models []orderModel + var total int64 + + base := r.db.WithContext(ctx).Model(&orderModel{}).Where("user_id = ?", userID) + if status != "" { + base = base.Where("status = ?", status) + } + + if err := base.Count(&total).Error; err != nil { + return nil, 0, err + } + + if err := base.Order("created_at DESC").Offset(offset).Limit(limit).Find(&models).Error; err != nil { + return nil, 0, err + } + + result := make([]*entity.Order, len(models)) + for i := range models { + result[i] = models[i].toEntity() + } + return result, int(total), nil +} diff --git a/backend/services/trading-service/internal/interface/http/handler/trade_handler.go b/backend/services/trading-service/internal/interface/http/handler/trade_handler.go index ea981ee..20d67aa 100644 --- a/backend/services/trading-service/internal/interface/http/handler/trade_handler.go +++ b/backend/services/trading-service/internal/interface/http/handler/trade_handler.go @@ -114,3 +114,106 @@ func (h *TradeHandler) GetOrderBook(c *gin.Context) { "asks": asks, }}) } + +// MyOrders handles GET /api/v1/trades/my/orders — paginated list of current user's orders. +func (h *TradeHandler) MyOrders(c *gin.Context) { + userID := c.GetString("userId") + + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) + status := c.Query("status") + + if page < 1 { + page = 1 + } + if limit < 1 || limit > 100 { + limit = 20 + } + + orders, total, err := h.tradeService.GetOrdersByUserPaginated(c.Request.Context(), userID, status, page, limit) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": -1, "message": err.Error()}) + return + } + + type orderItem struct { + ID string `json:"id"` + CouponID string `json:"couponId"` + CouponName string `json:"couponName"` + Type string `json:"type"` + Quantity int `json:"quantity"` + Price float64 `json:"price"` + TotalAmount float64 `json:"totalAmount"` + Status string `json:"status"` + CreatedAt string `json:"createdAt"` + } + + items := make([]orderItem, len(orders)) + for i, o := range orders { + items[i] = orderItem{ + ID: o.ID, + CouponID: o.CouponID, + CouponName: o.CouponID, // coupon name would come from coupon service; use ID as placeholder + Type: o.Side.String(), + Quantity: o.Quantity.Int(), + Price: o.Price.Float64(), + TotalAmount: o.Price.Float64() * float64(o.Quantity.Int()), + Status: string(o.Status), + CreatedAt: o.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + } + + c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{ + "orders": items, + "total": total, + "page": page, + "limit": limit, + }}) +} + +// TransferCouponReq is the request body for transferring a coupon. +type TransferCouponReq struct { + RecipientID string `json:"recipientId"` + RecipientPhone string `json:"recipientPhone"` +} + +// TransferCoupon handles POST /api/v1/trades/coupons/:id/transfer — transfer coupon ownership. +func (h *TradeHandler) TransferCoupon(c *gin.Context) { + couponID := c.Param("id") + userID := c.GetString("userId") + + var req TransferCouponReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": -1, "message": err.Error()}) + return + } + + recipientID := req.RecipientID + if recipientID == "" && req.RecipientPhone == "" { + c.JSON(http.StatusBadRequest, gin.H{"code": -1, "message": "recipientId or recipientPhone is required"}) + return + } + + // If recipientPhone is provided but no recipientId, use phone as placeholder ID. + // In production, this would look up the user by phone via user-service. + if recipientID == "" { + recipientID = "phone:" + req.RecipientPhone + } + + if recipientID == userID { + c.JSON(http.StatusBadRequest, gin.H{"code": -1, "message": "cannot transfer to yourself"}) + return + } + + err := h.tradeService.TransferCoupon(c.Request.Context(), couponID, userID, recipientID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": -1, "message": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{ + "couponId": couponID, + "recipientId": recipientID, + "status": "transferred", + }}) +} diff --git a/backend/services/user-service/src/application/services/user-profile.service.ts b/backend/services/user-service/src/application/services/user-profile.service.ts index 33a8771..4cbd7a2 100644 --- a/backend/services/user-service/src/application/services/user-profile.service.ts +++ b/backend/services/user-service/src/application/services/user-profile.service.ts @@ -31,6 +31,38 @@ export class UserProfileService { return this.userRepo.updateProfile(userId, data); } + async getSettings(userId: string) { + const user = await this.userRepo.findById(userId); + if (!user) throw new NotFoundException('User not found'); + return { + language: user.preferredLanguage || 'zh-CN', + currency: user.preferredCurrency || 'CNY', + notificationPrefs: user.notificationPrefs || { trade: true, expiry: true, marketing: false }, + }; + } + + async updateSettings(userId: string, data: { language?: string; currency?: string; notificationPrefs?: { trade?: boolean; expiry?: boolean; marketing?: boolean } }) { + const user = await this.userRepo.findById(userId); + if (!user) throw new NotFoundException('User not found'); + + const updateData: Record = {}; + if (data.language !== undefined) updateData.preferredLanguage = data.language; + if (data.currency !== undefined) updateData.preferredCurrency = data.currency; + if (data.notificationPrefs !== undefined) { + // Merge with existing prefs so partial updates are supported + const existing = user.notificationPrefs || { trade: true, expiry: true, marketing: false }; + updateData.notificationPrefs = { + trade: data.notificationPrefs.trade !== undefined ? data.notificationPrefs.trade : existing.trade, + expiry: data.notificationPrefs.expiry !== undefined ? data.notificationPrefs.expiry : existing.expiry, + marketing: data.notificationPrefs.marketing !== undefined ? data.notificationPrefs.marketing : existing.marketing, + }; + } + + await this.userRepo.updateProfile(userId, updateData); + + return this.getSettings(userId); + } + async listUsers(page: number, limit: number) { const [users, total] = await this.userRepo.findAll(page, limit); return { diff --git a/backend/services/user-service/src/domain/entities/user.entity.ts b/backend/services/user-service/src/domain/entities/user.entity.ts index cf07c91..623f041 100644 --- a/backend/services/user-service/src/domain/entities/user.entity.ts +++ b/backend/services/user-service/src/domain/entities/user.entity.ts @@ -20,6 +20,9 @@ export class User { @Index('idx_users_status') @Column({ type: 'varchar', length: 20, default: 'active' }) status: UserStatus; @Column({ name: 'residence_state', type: 'varchar', length: 5, nullable: true }) residenceState: string | null; @Column({ type: 'varchar', length: 5, nullable: true }) nationality: string | null; + @Column({ name: 'preferred_language', type: 'varchar', length: 10, nullable: true, default: null }) preferredLanguage: string | null; + @Column({ name: 'preferred_currency', type: 'varchar', length: 10, nullable: true, default: null }) preferredCurrency: string | null; + @Column({ name: 'notification_prefs', type: 'jsonb', nullable: true, default: null }) notificationPrefs: { trade: boolean; expiry: boolean; marketing: boolean } | null; @Column({ name: 'last_login_at', type: 'timestamptz', nullable: true }) lastLoginAt: Date | null; @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) updatedAt: Date; diff --git a/backend/services/user-service/src/interface/http/controllers/user.controller.ts b/backend/services/user-service/src/interface/http/controllers/user.controller.ts index 6a3cca5..7c7cabe 100644 --- a/backend/services/user-service/src/interface/http/controllers/user.controller.ts +++ b/backend/services/user-service/src/interface/http/controllers/user.controller.ts @@ -3,6 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { AuthGuard } from '@nestjs/passport'; import { UserProfileService } from '../../../application/services/user-profile.service'; import { UpdateProfileDto } from '../dto/update-profile.dto'; +import { UpdateSettingsDto } from '../dto/update-settings.dto'; @ApiTags('Users') @Controller('users') @@ -27,6 +28,24 @@ export class UserController { return { code: 0, data: profile }; } + @Get('me/settings') + @UseGuards(AuthGuard('jwt')) + @ApiBearerAuth() + @ApiOperation({ summary: 'Get current user settings' }) + async getMySettings(@Req() req: any) { + const settings = await this.profileService.getSettings(req.user.id); + return { code: 0, data: settings }; + } + + @Put('me/settings') + @UseGuards(AuthGuard('jwt')) + @ApiBearerAuth() + @ApiOperation({ summary: 'Update current user settings (language, currency, notification preferences)' }) + async updateMySettings(@Req() req: any, @Body() dto: UpdateSettingsDto) { + const settings = await this.profileService.updateSettings(req.user.id, dto); + return { code: 0, data: settings }; + } + @Get(':id') @UseGuards(AuthGuard('jwt')) @ApiBearerAuth() diff --git a/backend/services/user-service/src/interface/http/dto/update-settings.dto.ts b/backend/services/user-service/src/interface/http/dto/update-settings.dto.ts new file mode 100644 index 0000000..227a263 --- /dev/null +++ b/backend/services/user-service/src/interface/http/dto/update-settings.dto.ts @@ -0,0 +1,23 @@ +import { IsOptional, IsString, MaxLength, IsObject, ValidateNested, IsBoolean } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; + +export class NotificationPrefsDto { + @ApiPropertyOptional() @IsOptional() @IsBoolean() trade?: boolean; + @ApiPropertyOptional() @IsOptional() @IsBoolean() expiry?: boolean; + @ApiPropertyOptional() @IsOptional() @IsBoolean() marketing?: boolean; +} + +export class UpdateSettingsDto { + @ApiPropertyOptional({ description: 'Preferred language code', example: 'zh-CN' }) + @IsOptional() @IsString() @MaxLength(10) + language?: string; + + @ApiPropertyOptional({ description: 'Preferred currency code', example: 'CNY' }) + @IsOptional() @IsString() @MaxLength(10) + currency?: string; + + @ApiPropertyOptional({ description: 'Notification preferences' }) + @IsOptional() @IsObject() @ValidateNested() @Type(() => NotificationPrefsDto) + notificationPrefs?: NotificationPrefsDto; +} diff --git a/frontend/admin-app/lib/app/i18n/app_localizations.dart b/frontend/admin-app/lib/app/i18n/app_localizations.dart index 60c1593..08ae8b7 100644 --- a/frontend/admin-app/lib/app/i18n/app_localizations.dart +++ b/frontend/admin-app/lib/app/i18n/app_localizations.dart @@ -178,6 +178,7 @@ class AppLocalizations { // ── Coupon List ── 'coupon_list_title': '券管理', 'coupon_list_fab': '发券', + 'coupon_list_empty': '暂无券数据', 'coupon_list_ai_suggestion': '建议:周末发行餐饮券销量通常提升30%', 'coupon_filter_all': '全部', 'coupon_filter_on_sale': '在售中', @@ -241,6 +242,7 @@ class AppLocalizations { 'create_coupon_review_notice': '提交后将自动进入平台审核,审核通过后券将自动上架销售', 'create_coupon_submit_success': '提交成功', 'create_coupon_submit_desc': '您的券已提交审核,预计1-2个工作日内完成。', + 'create_coupon_submit_error': '提交失败', 'create_coupon_ok': '确定', 'create_coupon_day_unit': ' 天', 'create_coupon_ai_price_suggestion': 'AI建议:同类券发行价通常为面值的85%,建议定价 \$21.25', @@ -737,6 +739,7 @@ class AppLocalizations { // ── Coupon List ── 'coupon_list_title': 'Coupon Management', 'coupon_list_fab': 'Issue', + 'coupon_list_empty': 'No coupons found', 'coupon_list_ai_suggestion': 'Tip: Weekend dining coupons typically boost sales 30%', 'coupon_filter_all': 'All', 'coupon_filter_on_sale': 'On Sale', @@ -800,6 +803,7 @@ class AppLocalizations { 'create_coupon_review_notice': 'After submission, the coupon enters platform review and is listed automatically upon approval.', 'create_coupon_submit_success': 'Submitted', 'create_coupon_submit_desc': 'Your coupon is under review. Expected 1-2 business days.', + 'create_coupon_submit_error': 'Submission failed', 'create_coupon_ok': 'OK', 'create_coupon_day_unit': ' days', 'create_coupon_ai_price_suggestion': 'AI Tip: Similar coupons are typically priced at 85% of face value. Suggested price: \$21.25', @@ -1296,6 +1300,7 @@ class AppLocalizations { // ── Coupon List ── 'coupon_list_title': 'クーポン管理', 'coupon_list_fab': '発行', + 'coupon_list_empty': 'クーポンがありません', 'coupon_list_ai_suggestion': '提案:週末の飲食クーポンは通常30%売上向上', 'coupon_filter_all': 'すべて', 'coupon_filter_on_sale': '販売中', @@ -1359,6 +1364,7 @@ class AppLocalizations { 'create_coupon_review_notice': '提出後、プラットフォーム審査を経て自動的に販売開始されます。', 'create_coupon_submit_success': '提出完了', 'create_coupon_submit_desc': 'クーポンは審査中です。1-2営業日で完了予定。', + 'create_coupon_submit_error': '提出に失敗しました', 'create_coupon_ok': 'OK', 'create_coupon_day_unit': ' 日', 'create_coupon_ai_price_suggestion': 'AIアドバイス:同種のクーポンは額面の85%が一般的です。推奨価格:\$21.25', diff --git a/frontend/admin-app/lib/app/router.dart b/frontend/admin-app/lib/app/router.dart index 39c9356..7775c82 100644 --- a/frontend/admin-app/lib/app/router.dart +++ b/frontend/admin-app/lib/app/router.dart @@ -43,7 +43,8 @@ class AppRouter { case createCoupon: return MaterialPageRoute(builder: (_) => const CreateCouponPage()); case couponDetail: - return MaterialPageRoute(builder: (_) => const IssuerCouponDetailPage()); + final couponId = routeSettings.arguments as String?; + return MaterialPageRoute(builder: (_) => IssuerCouponDetailPage(couponId: couponId)); case redemption: return MaterialPageRoute(builder: (_) => const RedemptionPage()); case finance: diff --git a/frontend/admin-app/lib/core/push/push_service.dart b/frontend/admin-app/lib/core/push/push_service.dart index 3a9d19f..4810a91 100644 --- a/frontend/admin-app/lib/core/push/push_service.dart +++ b/frontend/admin-app/lib/core/push/push_service.dart @@ -63,7 +63,7 @@ class PushService { Future _registerToken(String token) async { try { final platform = Platform.isIOS ? 'IOS' : 'ANDROID'; - await ApiClient.instance.post('/device-tokens', data: { + await ApiClient.instance.post('/api/v1/device-tokens', data: { 'platform': platform, 'channel': 'FCM', 'token': token, @@ -78,7 +78,7 @@ class PushService { Future unregisterToken() async { if (_fcmToken == null) return; try { - await ApiClient.instance.delete('/device-tokens', data: { + await ApiClient.instance.delete('/api/v1/device-tokens', data: { 'token': _fcmToken, }); debugPrint('[PushService] Token 已注销'); diff --git a/frontend/admin-app/lib/core/services/ai_chat_service.dart b/frontend/admin-app/lib/core/services/ai_chat_service.dart new file mode 100644 index 0000000..0dfaa95 --- /dev/null +++ b/frontend/admin-app/lib/core/services/ai_chat_service.dart @@ -0,0 +1,64 @@ +import 'package:flutter/foundation.dart'; +import '../network/api_client.dart'; + +/// AI 聊天服务 +class AiChatService { + final ApiClient _apiClient; + + AiChatService({ApiClient? apiClient}) + : _apiClient = apiClient ?? ApiClient.instance; + + /// 发送消息并获取AI回复 + Future sendMessage(String message, {String? context}) async { + try { + final body = {'message': message}; + if (context != null) body['context'] = context; + + final response = await _apiClient.post('/api/v1/ai/chat', data: body); + final data = response.data is Map ? response.data : {}; + final inner = data['data'] ?? data; + return AiChatResponse.fromJson(inner as Map); + } catch (e) { + debugPrint('[AiChatService] sendMessage 失败: $e'); + rethrow; + } + } + + /// 获取AI定价建议 + Future> getPricingSuggestion({ + required double faceValue, + String? category, + }) async { + try { + final response = await _apiClient.post('/api/v1/ai/pricing/suggest', data: { + 'faceValue': faceValue, + if (category != null) 'category': category, + }); + final data = response.data is Map ? response.data : {}; + return (data['data'] ?? data) as Map; + } catch (e) { + debugPrint('[AiChatService] getPricingSuggestion 失败: $e'); + rethrow; + } + } +} + +class AiChatResponse { + final String message; + final String? intent; + final Map? metadata; + + const AiChatResponse({ + required this.message, + this.intent, + this.metadata, + }); + + factory AiChatResponse.fromJson(Map json) { + return AiChatResponse( + message: json['message'] ?? json['reply'] ?? '', + intent: json['intent'], + metadata: json['metadata'] as Map?, + ); + } +} diff --git a/frontend/admin-app/lib/core/services/auth_service.dart b/frontend/admin-app/lib/core/services/auth_service.dart new file mode 100644 index 0000000..c05bd36 --- /dev/null +++ b/frontend/admin-app/lib/core/services/auth_service.dart @@ -0,0 +1,99 @@ +import 'package:flutter/foundation.dart'; +import '../network/api_client.dart'; + +/// 认证服务 +class AuthService { + final ApiClient _apiClient; + + AuthService({ApiClient? apiClient}) + : _apiClient = apiClient ?? ApiClient.instance; + + /// 发送短信验证码 + Future sendSmsCode(String phone) async { + try { + await _apiClient.post('/api/v1/auth/send-sms-code', data: { + 'phone': phone, + }); + return true; + } catch (e) { + debugPrint('[AuthService] sendSmsCode 失败: $e'); + rethrow; + } + } + + /// 手机号+验证码登录 + Future loginByPhone(String phone, String smsCode) async { + try { + final response = await _apiClient.post('/api/v1/auth/login-phone', data: { + 'phone': phone, + 'smsCode': smsCode, + }); + final data = response.data is Map ? response.data : {}; + final inner = data['data'] ?? data; + return LoginResult.fromJson(inner as Map); + } catch (e) { + debugPrint('[AuthService] loginByPhone 失败: $e'); + rethrow; + } + } + + /// 密码登录 + Future loginByPassword(String email, String password) async { + try { + final response = await _apiClient.post('/api/v1/auth/login', data: { + 'email': email, + 'password': password, + }); + final data = response.data is Map ? response.data : {}; + final inner = data['data'] ?? data; + return LoginResult.fromJson(inner as Map); + } catch (e) { + debugPrint('[AuthService] loginByPassword 失败: $e'); + rethrow; + } + } + + /// 刷新 Token + Future refreshToken(String refreshToken) async { + try { + final response = await _apiClient.post('/api/v1/auth/refresh', data: { + 'refreshToken': refreshToken, + }); + final data = response.data is Map ? response.data : {}; + final inner = data['data'] ?? data; + return LoginResult.fromJson(inner as Map); + } catch (e) { + debugPrint('[AuthService] refreshToken 失败: $e'); + rethrow; + } + } + + /// 登出 + Future logout() async { + try { + await _apiClient.post('/api/v1/auth/logout'); + } catch (e) { + debugPrint('[AuthService] logout 失败: $e'); + } + } +} + +class LoginResult { + final String accessToken; + final String? refreshToken; + final Map? user; + + const LoginResult({ + required this.accessToken, + this.refreshToken, + this.user, + }); + + factory LoginResult.fromJson(Map json) { + return LoginResult( + accessToken: json['accessToken'] ?? '', + refreshToken: json['refreshToken'], + user: json['user'] as Map?, + ); + } +} diff --git a/frontend/admin-app/lib/core/services/issuer_analytics_service.dart b/frontend/admin-app/lib/core/services/issuer_analytics_service.dart new file mode 100644 index 0000000..9ad6225 --- /dev/null +++ b/frontend/admin-app/lib/core/services/issuer_analytics_service.dart @@ -0,0 +1,63 @@ +import 'package:flutter/foundation.dart'; +import '../network/api_client.dart'; + +/// 发行商分析服务(用户画像等) +class IssuerAnalyticsService { + final ApiClient _apiClient; + + IssuerAnalyticsService({ApiClient? apiClient}) + : _apiClient = apiClient ?? ApiClient.instance; + + /// 获取用户概览 + Future> getUserOverview() async { + try { + final response = await _apiClient.get('/api/v1/issuers/me/analytics/users'); + final data = response.data is Map ? response.data : {}; + return (data['data'] ?? data) as Map; + } catch (e) { + debugPrint('[IssuerAnalyticsService] getUserOverview 失败: $e'); + rethrow; + } + } + + /// 获取人口统计 + Future> getDemographics() async { + try { + final response = await _apiClient.get('/api/v1/issuers/me/analytics/demographics'); + final data = response.data is Map ? response.data : {}; + return (data['data'] ?? data) as Map; + } catch (e) { + debugPrint('[IssuerAnalyticsService] getDemographics 失败: $e'); + rethrow; + } + } + + /// 获取复购分析 + Future> getRepurchaseAnalysis() async { + try { + final response = await _apiClient.get('/api/v1/issuers/me/analytics/repurchase'); + final data = response.data is Map ? response.data : {}; + return (data['data'] ?? data) as Map; + } catch (e) { + debugPrint('[IssuerAnalyticsService] getRepurchaseAnalysis 失败: $e'); + rethrow; + } + } + + /// 获取AI洞察 + Future> getAiInsights() async { + try { + final response = await _apiClient.get('/api/v1/issuers/me/analytics/ai-insights'); + final data = response.data is Map ? response.data : {}; + final inner = data['data'] ?? data; + if (inner is List) return inner.map((e) => e.toString()).toList(); + if (inner is Map && inner['insights'] is List) { + return (inner['insights'] as List).map((e) => e.toString()).toList(); + } + return []; + } catch (e) { + debugPrint('[IssuerAnalyticsService] getAiInsights 失败: $e'); + rethrow; + } + } +} diff --git a/frontend/admin-app/lib/core/services/issuer_coupon_service.dart b/frontend/admin-app/lib/core/services/issuer_coupon_service.dart new file mode 100644 index 0000000..9c03bb2 --- /dev/null +++ b/frontend/admin-app/lib/core/services/issuer_coupon_service.dart @@ -0,0 +1,188 @@ +import 'package:flutter/foundation.dart'; +import '../network/api_client.dart'; + +/// 发行商券管理服务 +class IssuerCouponService { + final ApiClient _apiClient; + + IssuerCouponService({ApiClient? apiClient}) + : _apiClient = apiClient ?? ApiClient.instance; + + /// 获取发行商的券列表 + Future<({List items, int total})> list({ + int page = 1, + int limit = 20, + String? status, + String? search, + }) async { + try { + final params = {'page': page, 'limit': limit}; + if (status != null) params['status'] = status; + if (search != null) params['search'] = search; + + final response = await _apiClient.get( + '/api/v1/coupons', + queryParameters: params, + ); + final data = response.data is Map ? response.data : {}; + final inner = data['data'] ?? data; + final items = (inner['items'] as List?) + ?.map((e) => IssuerCouponModel.fromJson(e as Map)) + .toList() ?? + []; + return (items: items, total: inner['total'] ?? items.length); + } catch (e) { + debugPrint('[IssuerCouponService] list 失败: $e'); + rethrow; + } + } + + /// 创建券 + Future create(Map body) async { + try { + final response = await _apiClient.post('/api/v1/coupons', data: body); + final data = response.data is Map ? response.data : {}; + final inner = data['data'] ?? data; + return IssuerCouponModel.fromJson(inner as Map); + } catch (e) { + debugPrint('[IssuerCouponService] create 失败: $e'); + rethrow; + } + } + + /// 获取券详情 + Future getDetail(String id) async { + try { + final response = await _apiClient.get('/api/v1/coupons/$id'); + final data = response.data is Map ? response.data : {}; + final inner = data['data'] ?? data; + return IssuerCouponModel.fromJson(inner as Map); + } catch (e) { + debugPrint('[IssuerCouponService] getDetail 失败: $e'); + rethrow; + } + } + + /// 更新券状态 + Future updateStatus(String id, String status) async { + try { + await _apiClient.put('/api/v1/coupons/$id/status', data: { + 'status': status, + }); + } catch (e) { + debugPrint('[IssuerCouponService] updateStatus 失败: $e'); + rethrow; + } + } + + /// 批量发行 + Future batchIssue({ + required String templateId, + required int quantity, + required String expiryDate, + }) async { + try { + await _apiClient.post('/api/v1/coupons/batch/issue', data: { + 'templateId': templateId, + 'quantity': quantity, + 'expiryDate': expiryDate, + }); + } catch (e) { + debugPrint('[IssuerCouponService] batchIssue 失败: $e'); + rethrow; + } + } + + /// 批量召回 + Future batchRecall({ + required List couponIds, + required String reason, + }) async { + try { + await _apiClient.post('/api/v1/coupons/batch/recall', data: { + 'couponIds': couponIds, + 'reason': reason, + }); + } catch (e) { + debugPrint('[IssuerCouponService] batchRecall 失败: $e'); + rethrow; + } + } + + /// 批量调价 + Future batchPriceAdjust({ + required List couponIds, + required double adjustPercent, + }) async { + try { + await _apiClient.post('/api/v1/coupons/batch/price-adjust', data: { + 'couponIds': couponIds, + 'adjustPercent': adjustPercent, + }); + } catch (e) { + debugPrint('[IssuerCouponService] batchPriceAdjust 失败: $e'); + rethrow; + } + } +} + +class IssuerCouponModel { + final String id; + final String name; + final String? description; + final String? imageUrl; + final double faceValue; + final double currentPrice; + final int totalSupply; + final int remainingSupply; + final String category; + final String status; + final String couponType; + final DateTime expiryDate; + final bool isTransferable; + final String? brandName; + + const IssuerCouponModel({ + required this.id, + required this.name, + this.description, + this.imageUrl, + required this.faceValue, + required this.currentPrice, + required this.totalSupply, + required this.remainingSupply, + required this.category, + required this.status, + required this.couponType, + required this.expiryDate, + required this.isTransferable, + this.brandName, + }); + + factory IssuerCouponModel.fromJson(Map json) { + return IssuerCouponModel( + id: json['id'] ?? '', + name: json['name'] ?? '', + description: json['description'], + imageUrl: json['imageUrl'], + faceValue: _toDouble(json['faceValue']), + currentPrice: _toDouble(json['currentPrice']), + totalSupply: json['totalSupply'] ?? 0, + remainingSupply: json['remainingSupply'] ?? 0, + category: json['category'] ?? '', + status: json['status'] ?? '', + couponType: json['couponType'] ?? 'utility', + expiryDate: DateTime.tryParse(json['expiryDate']?.toString() ?? '') ?? DateTime.now(), + isTransferable: json['isTransferable'] ?? true, + brandName: json['issuer']?['companyName'] ?? json['brandName'], + ); + } + + int get soldCount => totalSupply - remainingSupply; + + static double _toDouble(dynamic v) { + if (v == null) return 0; + if (v is num) return v.toDouble(); + return double.tryParse(v.toString()) ?? 0; + } +} diff --git a/frontend/admin-app/lib/core/services/issuer_credit_service.dart b/frontend/admin-app/lib/core/services/issuer_credit_service.dart new file mode 100644 index 0000000..ed304d1 --- /dev/null +++ b/frontend/admin-app/lib/core/services/issuer_credit_service.dart @@ -0,0 +1,76 @@ +import 'package:flutter/foundation.dart'; +import '../network/api_client.dart'; + +/// 发行商信用评分服务 +class IssuerCreditService { + final ApiClient _apiClient; + + IssuerCreditService({ApiClient? apiClient}) + : _apiClient = apiClient ?? ApiClient.instance; + + /// 获取信用评分详情 + Future getCredit() async { + try { + final response = await _apiClient.get('/api/v1/issuers/me/credit'); + final data = response.data is Map ? response.data : {}; + final inner = data['data'] ?? data; + return CreditModel.fromJson(inner as Map); + } catch (e) { + debugPrint('[IssuerCreditService] getCredit 失败: $e'); + rethrow; + } + } +} + +class CreditModel { + final String grade; + final int score; + final List factors; + final List tiers; + final int currentTierIndex; + final List suggestions; + + const CreditModel({ + required this.grade, + required this.score, + required this.factors, + required this.tiers, + required this.currentTierIndex, + required this.suggestions, + }); + + factory CreditModel.fromJson(Map json) { + return CreditModel( + grade: json['grade'] ?? 'N/A', + score: json['score'] ?? 0, + factors: (json['factors'] as List?) + ?.map((e) => CreditFactor.fromJson(e as Map)) + .toList() ?? + [], + tiers: (json['tiers'] as List?)?.map((e) => e.toString()).toList() ?? + ['Bronze', 'Silver', 'Gold', 'Platinum', 'Diamond'], + currentTierIndex: json['currentTierIndex'] ?? 0, + suggestions: (json['suggestions'] as List?)?.map((e) => e.toString()).toList() ?? [], + ); + } +} + +class CreditFactor { + final String name; + final double score; + final double weight; + + const CreditFactor({ + required this.name, + required this.score, + required this.weight, + }); + + factory CreditFactor.fromJson(Map json) { + return CreditFactor( + name: json['name'] ?? '', + score: (json['score'] is num) ? (json['score'] as num).toDouble() : 0, + weight: (json['weight'] is num) ? (json['weight'] as num).toDouble() : 0, + ); + } +} diff --git a/frontend/admin-app/lib/core/services/issuer_finance_service.dart b/frontend/admin-app/lib/core/services/issuer_finance_service.dart new file mode 100644 index 0000000..1e10370 --- /dev/null +++ b/frontend/admin-app/lib/core/services/issuer_finance_service.dart @@ -0,0 +1,186 @@ +import 'package:flutter/foundation.dart'; +import '../network/api_client.dart'; + +/// 发行商财务服务 +class IssuerFinanceService { + final ApiClient _apiClient; + + IssuerFinanceService({ApiClient? apiClient}) + : _apiClient = apiClient ?? ApiClient.instance; + + /// 获取可提现余额 + Future getBalance() async { + try { + final response = await _apiClient.get('/api/v1/issuers/me/finance/balance'); + final data = response.data is Map ? response.data : {}; + final inner = data['data'] ?? data; + return FinanceBalance.fromJson(inner as Map); + } catch (e) { + debugPrint('[IssuerFinanceService] getBalance 失败: $e'); + rethrow; + } + } + + /// 获取财务概览 + Future getStats() async { + try { + final response = await _apiClient.get('/api/v1/issuers/me/finance/stats'); + final data = response.data is Map ? response.data : {}; + final inner = data['data'] ?? data; + return FinanceStats.fromJson(inner as Map); + } catch (e) { + debugPrint('[IssuerFinanceService] getStats 失败: $e'); + rethrow; + } + } + + /// 获取交易流水 + Future<({List items, int total})> getTransactions({ + int page = 1, + int limit = 20, + String? type, + }) async { + try { + final params = {'page': page, 'limit': limit}; + if (type != null) params['type'] = type; + + final response = await _apiClient.get( + '/api/v1/issuers/me/finance/transactions', + queryParameters: params, + ); + final data = response.data is Map ? response.data : {}; + final inner = data['data'] ?? data; + final items = (inner['items'] as List?) + ?.map((e) => TransactionModel.fromJson(e as Map)) + .toList() ?? + []; + return (items: items, total: inner['total'] ?? items.length); + } catch (e) { + debugPrint('[IssuerFinanceService] getTransactions 失败: $e'); + rethrow; + } + } + + /// 发起提现 + Future withdraw(double amount) async { + try { + await _apiClient.post('/api/v1/issuers/me/finance/withdraw', data: { + 'amount': amount, + }); + } catch (e) { + debugPrint('[IssuerFinanceService] withdraw 失败: $e'); + rethrow; + } + } + + /// 获取对账数据 + Future> getReconciliation({String period = 'month'}) async { + try { + final response = await _apiClient.get( + '/api/v1/issuers/me/finance/reconciliation', + queryParameters: {'period': period}, + ); + final data = response.data is Map ? response.data : {}; + return (data['data'] ?? data) as Map; + } catch (e) { + debugPrint('[IssuerFinanceService] getReconciliation 失败: $e'); + rethrow; + } + } +} + +class FinanceBalance { + final double withdrawable; + final double pending; + final double total; + + const FinanceBalance({ + required this.withdrawable, + required this.pending, + required this.total, + }); + + factory FinanceBalance.fromJson(Map json) { + return FinanceBalance( + withdrawable: _toDouble(json['withdrawable']), + pending: _toDouble(json['pending']), + total: _toDouble(json['total']), + ); + } + + static double _toDouble(dynamic v) { + if (v == null) return 0; + if (v is num) return v.toDouble(); + return double.tryParse(v.toString()) ?? 0; + } +} + +class FinanceStats { + final double salesAmount; + final double breakageIncome; + final double platformFee; + final double pendingSettlement; + final double withdrawnAmount; + final double totalRevenue; + + const FinanceStats({ + required this.salesAmount, + required this.breakageIncome, + required this.platformFee, + required this.pendingSettlement, + required this.withdrawnAmount, + required this.totalRevenue, + }); + + factory FinanceStats.fromJson(Map json) { + return FinanceStats( + salesAmount: _toDouble(json['salesAmount']), + breakageIncome: _toDouble(json['breakageIncome']), + platformFee: _toDouble(json['platformFee']), + pendingSettlement: _toDouble(json['pendingSettlement']), + withdrawnAmount: _toDouble(json['withdrawnAmount']), + totalRevenue: _toDouble(json['totalRevenue']), + ); + } + + static double _toDouble(dynamic v) { + if (v == null) return 0; + if (v is num) return v.toDouble(); + return double.tryParse(v.toString()) ?? 0; + } +} + +class TransactionModel { + final String id; + final String type; + final double amount; + final String description; + final String status; + final DateTime createdAt; + + const TransactionModel({ + required this.id, + required this.type, + required this.amount, + required this.description, + required this.status, + required this.createdAt, + }); + + factory TransactionModel.fromJson(Map json) { + return TransactionModel( + id: json['id'] ?? '', + type: json['type'] ?? '', + amount: _toDouble(json['amount']), + description: json['description'] ?? '', + status: json['status'] ?? '', + createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? '') ?? DateTime.now(), + ); + } + + static double _toDouble(dynamic v) { + if (v == null) return 0; + if (v is num) return v.toDouble(); + return double.tryParse(v.toString()) ?? 0; + } +} diff --git a/frontend/admin-app/lib/core/services/issuer_service.dart b/frontend/admin-app/lib/core/services/issuer_service.dart new file mode 100644 index 0000000..fdde1fc --- /dev/null +++ b/frontend/admin-app/lib/core/services/issuer_service.dart @@ -0,0 +1,116 @@ +import 'package:flutter/foundation.dart'; +import '../network/api_client.dart'; + +/// 发行商概况服务 +class IssuerService { + final ApiClient _apiClient; + + IssuerService({ApiClient? apiClient}) + : _apiClient = apiClient ?? ApiClient.instance; + + /// 获取发行商资料 + Future getProfile() async { + try { + final response = await _apiClient.get('/api/v1/issuers/me'); + final data = response.data is Map ? response.data : {}; + final inner = data['data'] ?? data; + return IssuerProfile.fromJson(inner as Map); + } catch (e) { + debugPrint('[IssuerService] getProfile 失败: $e'); + rethrow; + } + } + + /// 获取仪表盘统计 + Future getStats() async { + try { + final response = await _apiClient.get('/api/v1/issuers/me/stats'); + final data = response.data is Map ? response.data : {}; + final inner = data['data'] ?? data; + return IssuerStats.fromJson(inner as Map); + } catch (e) { + debugPrint('[IssuerService] getStats 失败: $e'); + rethrow; + } + } + + /// 更新发行商资料 + Future updateProfile(Map updates) async { + try { + await _apiClient.put('/api/v1/issuers/me', data: updates); + } catch (e) { + debugPrint('[IssuerService] updateProfile 失败: $e'); + rethrow; + } + } +} + +class IssuerProfile { + final String id; + final String companyName; + final String? logoUrl; + final String? creditRating; + final String status; + final String? contactEmail; + final String? contactPhone; + + const IssuerProfile({ + required this.id, + required this.companyName, + this.logoUrl, + this.creditRating, + required this.status, + this.contactEmail, + this.contactPhone, + }); + + factory IssuerProfile.fromJson(Map json) { + return IssuerProfile( + id: json['id'] ?? '', + companyName: json['companyName'] ?? '', + logoUrl: json['logoUrl'], + creditRating: json['creditRating'], + status: json['status'] ?? '', + contactEmail: json['contactEmail'], + contactPhone: json['contactPhone'], + ); + } +} + +class IssuerStats { + final int issuedCount; + final double redemptionRate; + final double totalRevenue; + final String creditGrade; + final int creditScore; + final double quotaUsed; + final double quotaTotal; + + const IssuerStats({ + required this.issuedCount, + required this.redemptionRate, + required this.totalRevenue, + required this.creditGrade, + required this.creditScore, + required this.quotaUsed, + required this.quotaTotal, + }); + + factory IssuerStats.fromJson(Map json) { + return IssuerStats( + issuedCount: json['issuedCount'] ?? 0, + redemptionRate: _toDouble(json['redemptionRate']), + totalRevenue: _toDouble(json['totalRevenue']), + creditGrade: json['creditGrade'] ?? 'N/A', + creditScore: json['creditScore'] ?? 0, + quotaUsed: _toDouble(json['quotaUsed']), + quotaTotal: _toDouble(json['quotaTotal']), + ); + } + + static double _toDouble(dynamic v) { + if (v == null) return 0; + if (v is num) return v.toDouble(); + return double.tryParse(v.toString()) ?? 0; + } +} diff --git a/frontend/admin-app/lib/core/services/issuer_store_service.dart b/frontend/admin-app/lib/core/services/issuer_store_service.dart new file mode 100644 index 0000000..4bf8eb4 --- /dev/null +++ b/frontend/admin-app/lib/core/services/issuer_store_service.dart @@ -0,0 +1,201 @@ +import 'package:flutter/foundation.dart'; +import '../network/api_client.dart'; + +/// 门店和员工管理服务 +class IssuerStoreService { + final ApiClient _apiClient; + + IssuerStoreService({ApiClient? apiClient}) + : _apiClient = apiClient ?? ApiClient.instance; + + // ==================== 门店 ==================== + + /// 获取门店列表 + Future> listStores({ + String? level, + String? parentId, + String? status, + }) async { + try { + final params = {}; + if (level != null) params['level'] = level; + if (parentId != null) params['parentId'] = parentId; + if (status != null) params['status'] = status; + + final response = await _apiClient.get( + '/api/v1/issuers/me/stores', + queryParameters: params, + ); + final data = response.data is Map ? response.data : {}; + final inner = data['data'] ?? data; + final list = inner is List ? inner : (inner['items'] ?? []); + return (list as List) + .map((e) => StoreModel.fromJson(e as Map)) + .toList(); + } catch (e) { + debugPrint('[IssuerStoreService] listStores 失败: $e'); + rethrow; + } + } + + /// 创建门店 + Future createStore(Map body) async { + try { + final response = await _apiClient.post('/api/v1/issuers/me/stores', data: body); + final data = response.data is Map ? response.data : {}; + final inner = data['data'] ?? data; + return StoreModel.fromJson(inner as Map); + } catch (e) { + debugPrint('[IssuerStoreService] createStore 失败: $e'); + rethrow; + } + } + + /// 更新门店 + Future updateStore(String id, Map body) async { + try { + await _apiClient.put('/api/v1/issuers/me/stores/$id', data: body); + } catch (e) { + debugPrint('[IssuerStoreService] updateStore 失败: $e'); + rethrow; + } + } + + /// 删除门店 + Future deleteStore(String id) async { + try { + await _apiClient.delete('/api/v1/issuers/me/stores/$id'); + } catch (e) { + debugPrint('[IssuerStoreService] deleteStore 失败: $e'); + rethrow; + } + } + + // ==================== 员工 ==================== + + /// 获取员工列表 + Future> listEmployees({ + String? storeId, + String? role, + }) async { + try { + final params = {}; + if (storeId != null) params['storeId'] = storeId; + if (role != null) params['role'] = role; + + final response = await _apiClient.get( + '/api/v1/issuers/me/employees', + queryParameters: params, + ); + final data = response.data is Map ? response.data : {}; + final inner = data['data'] ?? data; + final list = inner is List ? inner : (inner['items'] ?? []); + return (list as List) + .map((e) => EmployeeModel.fromJson(e as Map)) + .toList(); + } catch (e) { + debugPrint('[IssuerStoreService] listEmployees 失败: $e'); + rethrow; + } + } + + /// 创建员工 + Future createEmployee(Map body) async { + try { + final response = await _apiClient.post('/api/v1/issuers/me/employees', data: body); + final data = response.data is Map ? response.data : {}; + final inner = data['data'] ?? data; + return EmployeeModel.fromJson(inner as Map); + } catch (e) { + debugPrint('[IssuerStoreService] createEmployee 失败: $e'); + rethrow; + } + } + + /// 更新员工 + Future updateEmployee(String id, Map body) async { + try { + await _apiClient.put('/api/v1/issuers/me/employees/$id', data: body); + } catch (e) { + debugPrint('[IssuerStoreService] updateEmployee 失败: $e'); + rethrow; + } + } + + /// 删除员工 + Future deleteEmployee(String id) async { + try { + await _apiClient.delete('/api/v1/issuers/me/employees/$id'); + } catch (e) { + debugPrint('[IssuerStoreService] deleteEmployee 失败: $e'); + rethrow; + } + } +} + +class StoreModel { + final String id; + final String name; + final String? address; + final String? phone; + final String level; + final String? parentId; + final String status; + final DateTime createdAt; + + const StoreModel({ + required this.id, + required this.name, + this.address, + this.phone, + required this.level, + this.parentId, + required this.status, + required this.createdAt, + }); + + factory StoreModel.fromJson(Map json) { + return StoreModel( + id: json['id'] ?? '', + name: json['name'] ?? '', + address: json['address'], + phone: json['phone'], + level: json['level'] ?? 'store', + parentId: json['parentId'], + status: json['status'] ?? 'active', + createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? '') ?? DateTime.now(), + ); + } +} + +class EmployeeModel { + final String id; + final String name; + final String? phone; + final String role; + final String? storeId; + final String status; + final DateTime createdAt; + + const EmployeeModel({ + required this.id, + required this.name, + this.phone, + required this.role, + this.storeId, + required this.status, + required this.createdAt, + }); + + factory EmployeeModel.fromJson(Map json) { + return EmployeeModel( + id: json['id'] ?? '', + name: json['name'] ?? '', + phone: json['phone'], + role: json['role'] ?? 'staff', + storeId: json['storeId'], + status: json['status'] ?? 'active', + createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? '') ?? DateTime.now(), + ); + } +} diff --git a/frontend/admin-app/lib/core/services/notification_service.dart b/frontend/admin-app/lib/core/services/notification_service.dart index a0099df..cbcb3c7 100644 --- a/frontend/admin-app/lib/core/services/notification_service.dart +++ b/frontend/admin-app/lib/core/services/notification_service.dart @@ -144,7 +144,7 @@ class NotificationService { } final response = await _apiClient.get( - '/notifications', + '/api/v1/notifications', queryParameters: queryParams, ); @@ -160,9 +160,9 @@ class NotificationService { /// 获取未读通知数量 Future getUnreadCount() async { try { - final response = await _apiClient.get('/notifications/unread-count'); + final response = await _apiClient.get('/api/v1/notifications/unread-count'); final data = response.data is Map ? response.data : {}; - return data['unreadCount'] ?? data['data']?['unreadCount'] ?? 0; + return data['data']?['count'] ?? data['count'] ?? 0; } catch (e) { debugPrint('[NotificationService] 获取未读数量失败: $e'); return 0; @@ -172,7 +172,7 @@ class NotificationService { /// 标记通知为已读 Future markAsRead(String notificationId) async { try { - await _apiClient.put('/notifications/$notificationId/read'); + await _apiClient.put('/api/v1/notifications/$notificationId/read'); return true; } catch (e) { debugPrint('[NotificationService] 标记已读失败: $e'); @@ -187,7 +187,7 @@ class NotificationService { }) async { try { final response = await _apiClient.get( - '/announcements', + '/api/v1/announcements', queryParameters: {'limit': limit, 'offset': offset}, ); @@ -203,9 +203,9 @@ class NotificationService { /// 获取公告未读数 Future getAnnouncementUnreadCount() async { try { - final response = await _apiClient.get('/announcements/unread-count'); + final response = await _apiClient.get('/api/v1/announcements/unread-count'); final data = response.data is Map ? response.data : {}; - return data['unreadCount'] ?? data['data']?['unreadCount'] ?? 0; + return data['data']?['count'] ?? data['count'] ?? 0; } catch (e) { debugPrint('[NotificationService] 获取公告未读数失败: $e'); return 0; @@ -215,7 +215,7 @@ class NotificationService { /// 标记公告已读 Future markAnnouncementAsRead(String announcementId) async { try { - await _apiClient.put('/announcements/$announcementId/read'); + await _apiClient.put('/api/v1/announcements/$announcementId/read'); return true; } catch (e) { debugPrint('[NotificationService] 标记公告已读失败: $e'); @@ -226,7 +226,7 @@ class NotificationService { /// 全部标记已读 Future markAllAnnouncementsAsRead() async { try { - await _apiClient.put('/announcements/read-all'); + await _apiClient.put('/api/v1/announcements/read-all'); return true; } catch (e) { debugPrint('[NotificationService] 全部标记已读失败: $e'); diff --git a/frontend/admin-app/lib/core/services/redemption_service.dart b/frontend/admin-app/lib/core/services/redemption_service.dart new file mode 100644 index 0000000..41a4fcb --- /dev/null +++ b/frontend/admin-app/lib/core/services/redemption_service.dart @@ -0,0 +1,204 @@ +import 'package:flutter/foundation.dart'; +import '../network/api_client.dart'; + +/// 核销服务 +class RedemptionService { + final ApiClient _apiClient; + + RedemptionService({ApiClient? apiClient}) + : _apiClient = apiClient ?? ApiClient.instance; + + /// 扫码核销 + Future scan(String qrCode) async { + try { + final response = await _apiClient.post('/api/v1/redemptions/scan', data: { + 'qrCode': qrCode, + }); + final data = response.data is Map ? response.data : {}; + final inner = data['data'] ?? data; + return RedemptionResult.fromJson(inner as Map); + } catch (e) { + debugPrint('[RedemptionService] scan 失败: $e'); + rethrow; + } + } + + /// 手动输入核销 + Future manual(String couponCode) async { + try { + final response = await _apiClient.post('/api/v1/redemptions/manual', data: { + 'couponCode': couponCode, + }); + final data = response.data is Map ? response.data : {}; + final inner = data['data'] ?? data; + return RedemptionResult.fromJson(inner as Map); + } catch (e) { + debugPrint('[RedemptionService] manual 失败: $e'); + rethrow; + } + } + + /// 批量核销 + Future batch(List couponCodes) async { + try { + final response = await _apiClient.post('/api/v1/redemptions/batch', data: { + 'couponCodes': couponCodes, + }); + final data = response.data is Map ? response.data : {}; + final inner = data['data'] ?? data; + return BatchRedemptionResult.fromJson(inner as Map); + } catch (e) { + debugPrint('[RedemptionService] batch 失败: $e'); + rethrow; + } + } + + /// 获取核销历史 + Future<({List items, int total})> getHistory({ + int page = 1, + int limit = 20, + String? startDate, + String? endDate, + }) async { + try { + final params = {'page': page, 'limit': limit}; + if (startDate != null) params['startDate'] = startDate; + if (endDate != null) params['endDate'] = endDate; + + final response = await _apiClient.get( + '/api/v1/redemptions', + queryParameters: params, + ); + final data = response.data is Map ? response.data : {}; + final inner = data['data'] ?? data; + final items = (inner['items'] as List?) + ?.map((e) => RedemptionRecord.fromJson(e as Map)) + .toList() ?? + []; + return (items: items, total: inner['total'] ?? items.length); + } catch (e) { + debugPrint('[RedemptionService] getHistory 失败: $e'); + rethrow; + } + } + + /// 获取今日统计 + Future getTodayStats() async { + try { + final response = await _apiClient.get('/api/v1/redemptions/today-stats'); + final data = response.data is Map ? response.data : {}; + final inner = data['data'] ?? data; + return TodayRedemptionStats.fromJson(inner as Map); + } catch (e) { + debugPrint('[RedemptionService] getTodayStats 失败: $e'); + rethrow; + } + } +} + +class RedemptionResult { + final String id; + final String couponName; + final double amount; + final String status; + + const RedemptionResult({ + required this.id, + required this.couponName, + required this.amount, + required this.status, + }); + + factory RedemptionResult.fromJson(Map json) { + return RedemptionResult( + id: json['id'] ?? '', + couponName: json['couponName'] ?? '', + amount: _toDouble(json['amount']), + status: json['status'] ?? 'completed', + ); + } + + static double _toDouble(dynamic v) { + if (v == null) return 0; + if (v is num) return v.toDouble(); + return double.tryParse(v.toString()) ?? 0; + } +} + +class BatchRedemptionResult { + final int successCount; + final int failCount; + final List failedCodes; + + const BatchRedemptionResult({ + required this.successCount, + required this.failCount, + required this.failedCodes, + }); + + factory BatchRedemptionResult.fromJson(Map json) { + return BatchRedemptionResult( + successCount: json['successCount'] ?? 0, + failCount: json['failCount'] ?? 0, + failedCodes: (json['failedCodes'] as List?)?.map((e) => e.toString()).toList() ?? [], + ); + } +} + +class RedemptionRecord { + final String id; + final String couponName; + final double amount; + final String method; + final String status; + final DateTime createdAt; + + const RedemptionRecord({ + required this.id, + required this.couponName, + required this.amount, + required this.method, + required this.status, + required this.createdAt, + }); + + factory RedemptionRecord.fromJson(Map json) { + return RedemptionRecord( + id: json['id'] ?? '', + couponName: json['couponName'] ?? '', + amount: _toDouble(json['amount']), + method: json['method'] ?? 'scan', + status: json['status'] ?? 'completed', + createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? '') ?? DateTime.now(), + ); + } + + static double _toDouble(dynamic v) { + if (v == null) return 0; + if (v is num) return v.toDouble(); + return double.tryParse(v.toString()) ?? 0; + } +} + +class TodayRedemptionStats { + final int count; + final double totalAmount; + + const TodayRedemptionStats({ + required this.count, + required this.totalAmount, + }); + + factory TodayRedemptionStats.fromJson(Map json) { + return TodayRedemptionStats( + count: json['count'] ?? 0, + totalAmount: _toDouble(json['totalAmount']), + ); + } + + static double _toDouble(dynamic v) { + if (v == null) return 0; + if (v is num) return v.toDouble(); + return double.tryParse(v.toString()) ?? 0; + } +} diff --git a/frontend/admin-app/lib/features/ai_agent/presentation/pages/ai_agent_page.dart b/frontend/admin-app/lib/features/ai_agent/presentation/pages/ai_agent_page.dart index f9b0545..5221690 100644 --- a/frontend/admin-app/lib/features/ai_agent/presentation/pages/ai_agent_page.dart +++ b/frontend/admin-app/lib/features/ai_agent/presentation/pages/ai_agent_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../../../app/theme/app_colors.dart'; import '../../../../app/i18n/app_localizations.dart'; +import '../../../../core/services/ai_chat_service.dart'; /// 发行方AI Agent对话页面 /// @@ -15,9 +16,11 @@ class AiAgentPage extends StatefulWidget { class _AiAgentPageState extends State { final _messageController = TextEditingController(); final _scrollController = ScrollController(); + final _aiChatService = AiChatService(); final List<_ChatMessage> _messages = []; bool _initialized = false; + bool _isSending = false; final _quickActionKeys = [ 'ai_agent_action_sales', @@ -26,6 +29,13 @@ class _AiAgentPageState extends State { 'ai_agent_action_quota', ]; + @override + void dispose() { + _messageController.dispose(); + _scrollController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { if (!_initialized) { @@ -53,8 +63,13 @@ class _AiAgentPageState extends State { child: ListView.builder( controller: _scrollController, padding: const EdgeInsets.all(16), - itemCount: _messages.length, - itemBuilder: (context, index) => _buildMessageBubble(_messages[index]), + itemCount: _messages.length + (_isSending ? 1 : 0), + itemBuilder: (context, index) { + if (index == _messages.length && _isSending) { + return _buildTypingIndicator(); + } + return _buildMessageBubble(_messages[index]); + }, ), ), @@ -70,7 +85,7 @@ class _AiAgentPageState extends State { padding: const EdgeInsets.only(right: 8), child: ActionChip( label: Text(action, style: const TextStyle(fontSize: 12)), - onPressed: () => _sendMessage(action), + onPressed: _isSending ? null : () => _sendMessage(action), backgroundColor: AppColors.primarySurface, side: BorderSide.none, ), @@ -91,6 +106,7 @@ class _AiAgentPageState extends State { Expanded( child: TextField( controller: _messageController, + enabled: !_isSending, decoration: InputDecoration( hintText: context.t('ai_agent_input_hint'), border: OutlineInputBorder( @@ -99,7 +115,7 @@ class _AiAgentPageState extends State { ), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), ), - onSubmitted: _sendMessage, + onSubmitted: _isSending ? null : _sendMessage, ), ), const SizedBox(width: 8), @@ -109,8 +125,14 @@ class _AiAgentPageState extends State { shape: BoxShape.circle, ), child: IconButton( - icon: const Icon(Icons.send_rounded, color: Colors.white, size: 20), - onPressed: () => _sendMessage(_messageController.text), + icon: _isSending + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : const Icon(Icons.send_rounded, color: Colors.white, size: 20), + onPressed: _isSending ? null : () => _sendMessage(_messageController.text), ), ), ], @@ -121,6 +143,47 @@ class _AiAgentPageState extends State { ); } + Widget _buildTypingIndicator() { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + gradient: AppColors.primaryGradient, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.auto_awesome_rounded, color: Colors.white, size: 16), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColors.gray50, + borderRadius: BorderRadius.circular(16).copyWith( + topLeft: const Radius.circular(4), + ), + ), + child: const SizedBox( + width: 40, + height: 20, + child: Center( + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ), + ), + ], + ), + ); + } + Widget _buildMessageBubble(_ChatMessage msg) { return Padding( padding: const EdgeInsets.only(bottom: 16), @@ -165,21 +228,47 @@ class _AiAgentPageState extends State { ); } - void _sendMessage(String text) { + Future _sendMessage(String text) async { if (text.trim().isEmpty) return; + setState(() { _messages.add(_ChatMessage(isAi: false, text: text)); _messageController.clear(); + _isSending = true; }); - // Simulate AI response - Future.delayed(const Duration(milliseconds: 800), () { - if (mounted) { - setState(() { - _messages.add(_ChatMessage( - isAi: true, - text: '正在分析您的数据...\n\n根据过去30天的销售数据,您的 ¥25 礼品卡表现最佳,建议在周五下午发布新券以获得最大曝光。当前定价 \$21.25 处于最优区间。', - )); - }); + + _scrollToBottom(); + + try { + final response = await _aiChatService.sendMessage(text.trim()); + if (!mounted) return; + setState(() { + _messages.add(_ChatMessage(isAi: true, text: response.message)); + _isSending = false; + }); + _scrollToBottom(); + } catch (e) { + debugPrint('[AiAgentPage] sendMessage error: $e'); + if (!mounted) return; + setState(() { + _messages.add(_ChatMessage( + isAi: true, + text: '抱歉,暂时无法回复。请稍后重试。', + )); + _isSending = false; + }); + _scrollToBottom(); + } + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); } }); } diff --git a/frontend/admin-app/lib/features/auth/presentation/pages/issuer_login_page.dart b/frontend/admin-app/lib/features/auth/presentation/pages/issuer_login_page.dart index 167f87e..e3aa4c8 100644 --- a/frontend/admin-app/lib/features/auth/presentation/pages/issuer_login_page.dart +++ b/frontend/admin-app/lib/features/auth/presentation/pages/issuer_login_page.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import '../../../../app/theme/app_colors.dart'; import '../../../../app/router.dart'; import '../../../../app/i18n/app_localizations.dart'; +import '../../../../core/services/auth_service.dart'; +import '../../../../core/network/api_client.dart'; /// 发行方登录页 /// @@ -18,6 +20,80 @@ class _IssuerLoginPageState extends State { final _phoneController = TextEditingController(); final _codeController = TextEditingController(); bool _agreedToTerms = false; + bool _isLoading = false; + bool _isSendingCode = false; + int _countdown = 0; + String? _errorMessage; + + final _authService = AuthService(); + + @override + void dispose() { + _phoneController.dispose(); + _codeController.dispose(); + super.dispose(); + } + + Future _sendSmsCode() async { + final phone = _phoneController.text.trim(); + if (phone.isEmpty) return; + + setState(() { + _isSendingCode = true; + _errorMessage = null; + }); + + try { + await _authService.sendSmsCode(phone); + if (!mounted) return; + setState(() { + _isSendingCode = false; + _countdown = 60; + }); + _startCountdown(); + } catch (e) { + debugPrint('[IssuerLoginPage] sendSmsCode error: $e'); + if (!mounted) return; + setState(() { + _isSendingCode = false; + _errorMessage = e.toString(); + }); + } + } + + void _startCountdown() { + Future.doWhile(() async { + await Future.delayed(const Duration(seconds: 1)); + if (!mounted) return false; + setState(() => _countdown--); + return _countdown > 0; + }); + } + + Future _login() async { + final phone = _phoneController.text.trim(); + final code = _codeController.text.trim(); + if (phone.isEmpty || code.isEmpty) return; + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final result = await _authService.loginByPhone(phone, code); + ApiClient.instance.setToken(result.accessToken); + if (!mounted) return; + Navigator.pushReplacementNamed(context, AppRouter.main); + } catch (e) { + debugPrint('[IssuerLoginPage] login error: $e'); + if (!mounted) return; + setState(() { + _isLoading = false; + _errorMessage = e.toString(); + }); + } + } @override Widget build(BuildContext context) { @@ -53,10 +129,35 @@ class _IssuerLoginPageState extends State { ), const SizedBox(height: 40), + // Error Message + if (_errorMessage != null) ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.errorLight, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon(Icons.error_outline, color: AppColors.error, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + _errorMessage!, + style: const TextStyle(fontSize: 13, color: AppColors.error), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + ], + // Phone Input TextField( controller: _phoneController, keyboardType: TextInputType.phone, + enabled: !_isLoading, decoration: InputDecoration( labelText: context.t('login_phone'), prefixIcon: const Icon(Icons.phone_outlined), @@ -72,6 +173,7 @@ class _IssuerLoginPageState extends State { child: TextField( controller: _codeController, keyboardType: TextInputType.number, + enabled: !_isLoading, decoration: InputDecoration( labelText: context.t('login_code'), prefixIcon: const Icon(Icons.lock_outline_rounded), @@ -82,10 +184,20 @@ class _IssuerLoginPageState extends State { SizedBox( height: 52, child: OutlinedButton( - onPressed: () { - // TODO: Send verification code to phone number - }, - child: Text(context.t('login_get_code')), + onPressed: (_isSendingCode || _countdown > 0 || _isLoading) + ? null + : _sendSmsCode, + child: _isSendingCode + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text( + _countdown > 0 + ? '${_countdown}s' + : context.t('login_get_code'), + ), ), ), ], @@ -97,7 +209,9 @@ class _IssuerLoginPageState extends State { children: [ Checkbox( value: _agreedToTerms, - onChanged: (v) => setState(() => _agreedToTerms = v ?? false), + onChanged: _isLoading + ? null + : (v) => setState(() => _agreedToTerms = v ?? false), activeColor: AppColors.primary, ), Expanded( @@ -123,10 +237,17 @@ class _IssuerLoginPageState extends State { width: double.infinity, height: 52, child: ElevatedButton( - onPressed: _agreedToTerms - ? () => Navigator.pushReplacementNamed(context, AppRouter.main) - : null, - child: Text(context.t('login_button'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + onPressed: (_agreedToTerms && !_isLoading) ? _login : null, + child: _isLoading + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Text(context.t('login_button'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), ), ), const SizedBox(height: 16), @@ -134,7 +255,9 @@ class _IssuerLoginPageState extends State { // Register Center( child: TextButton( - onPressed: () => Navigator.pushNamed(context, AppRouter.onboarding), + onPressed: _isLoading + ? null + : () => Navigator.pushNamed(context, AppRouter.onboarding), child: Text(context.t('login_register')), ), ), diff --git a/frontend/admin-app/lib/features/coupon_management/presentation/pages/coupon_detail_page.dart b/frontend/admin-app/lib/features/coupon_management/presentation/pages/coupon_detail_page.dart index 2c73c0c..cb99643 100644 --- a/frontend/admin-app/lib/features/coupon_management/presentation/pages/coupon_detail_page.dart +++ b/frontend/admin-app/lib/features/coupon_management/presentation/pages/coupon_detail_page.dart @@ -1,13 +1,65 @@ import 'package:flutter/material.dart'; import '../../../../app/theme/app_colors.dart'; import '../../../../app/i18n/app_localizations.dart'; +import '../../../../core/services/issuer_coupon_service.dart'; /// 发行方券详情页 /// /// 展示单批次券的详细数据:销量、核销率、二级市场分析 /// 操作:下架、召回、退款、增发 -class IssuerCouponDetailPage extends StatelessWidget { - const IssuerCouponDetailPage({super.key}); +class IssuerCouponDetailPage extends StatefulWidget { + final String? couponId; + + const IssuerCouponDetailPage({super.key, this.couponId}); + + @override + State createState() => _IssuerCouponDetailPageState(); +} + +class _IssuerCouponDetailPageState extends State { + final _couponService = IssuerCouponService(); + + bool _isLoading = true; + String? _error; + IssuerCouponModel? _coupon; + + @override + void initState() { + super.initState(); + _loadDetail(); + } + + Future _loadDetail() async { + final id = widget.couponId; + if (id == null || id.isEmpty) { + setState(() { + _isLoading = false; + _error = 'No coupon ID provided'; + }); + return; + } + + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final coupon = await _couponService.getDetail(id); + if (!mounted) return; + setState(() { + _coupon = coupon; + _isLoading = false; + }); + } catch (e) { + debugPrint('[IssuerCouponDetailPage] loadDetail error: $e'); + if (!mounted) return; + setState(() { + _isLoading = false; + _error = e.toString(); + }); + } + } @override Widget build(BuildContext context) { @@ -29,36 +81,64 @@ class IssuerCouponDetailPage extends StatelessWidget { ), ], ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header Card - _buildHeaderCard(context), - const SizedBox(height: 20), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 48, color: AppColors.textTertiary), + const SizedBox(height: 16), + Text(_error!, style: const TextStyle(color: AppColors.textSecondary)), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadDetail, + child: Text(context.t('retry')), + ), + ], + ), + ) + : RefreshIndicator( + onRefresh: _loadDetail, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header Card + _buildHeaderCard(context), + const SizedBox(height: 20), - // Sales Data - _buildSalesDataCard(context), - const SizedBox(height: 20), + // Sales Data + _buildSalesDataCard(context), + const SizedBox(height: 20), - // Secondary Market Analysis - _buildSecondaryMarketCard(context), - const SizedBox(height: 20), + // Secondary Market Analysis + _buildSecondaryMarketCard(context), + const SizedBox(height: 20), - // Financing Effect - _buildFinancingEffectCard(context), - const SizedBox(height: 20), + // Financing Effect + _buildFinancingEffectCard(context), + const SizedBox(height: 20), - // Redemption Timeline - _buildRedemptionTimeline(context), - ], - ), - ), + // Redemption Timeline + _buildRedemptionTimeline(context), + ], + ), + ), + ), ); } Widget _buildHeaderCard(BuildContext context) { + final coupon = _coupon!; + final soldCount = coupon.soldCount; + final redemptionRate = coupon.totalSupply > 0 + ? ((soldCount / coupon.totalSupply) * 100).toStringAsFixed(1) + : '0.0'; + return Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( @@ -70,10 +150,10 @@ class IssuerCouponDetailPage extends StatelessWidget { children: [ Row( children: [ - const Expanded( + Expanded( child: Text( - '¥25 星巴克礼品卡', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: Colors.white), + coupon.name, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: Colors.white), ), ), Container( @@ -82,23 +162,28 @@ class IssuerCouponDetailPage extends StatelessWidget { color: Colors.white.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(999), ), - child: Text(context.t('coupon_detail_status_on_sale'), style: const TextStyle(fontSize: 12, color: Colors.white, fontWeight: FontWeight.w600)), + child: Text( + coupon.status == 'active' + ? context.t('coupon_detail_status_on_sale') + : coupon.status, + style: const TextStyle(fontSize: 12, color: Colors.white, fontWeight: FontWeight.w600), + ), ), ], ), const SizedBox(height: 6), Text( - '礼品卡 · 面值 \$25 · 发行价 \$21.25', + '${coupon.couponType} · 面值 \$${coupon.faceValue.toStringAsFixed(0)} · 发行价 \$${coupon.currentPrice.toStringAsFixed(2)}', style: TextStyle(fontSize: 13, color: Colors.white.withValues(alpha: 0.8)), ), const SizedBox(height: 16), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - _buildHeaderStat(context.t('coupon_stat_issued'), '5,000'), - _buildHeaderStat(context.t('coupon_stat_sold'), '4,200'), - _buildHeaderStat(context.t('coupon_stat_redeemed'), '3,300'), - _buildHeaderStat(context.t('coupon_stat_rate'), '78.5%'), + _buildHeaderStat(context.t('coupon_stat_issued'), '${coupon.totalSupply}'), + _buildHeaderStat(context.t('coupon_stat_sold'), '$soldCount'), + _buildHeaderStat(context.t('coupon_stat_redeemed'), '${coupon.remainingSupply}'), + _buildHeaderStat(context.t('coupon_stat_rate'), '$redemptionRate%'), ], ), ], @@ -117,6 +202,9 @@ class IssuerCouponDetailPage extends StatelessWidget { } Widget _buildSalesDataCard(BuildContext context) { + final coupon = _coupon!; + final salesIncome = coupon.currentPrice * coupon.soldCount; + return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -129,11 +217,11 @@ class IssuerCouponDetailPage extends StatelessWidget { children: [ Text(context.t('coupon_detail_sales_data'), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), const SizedBox(height: 16), - _buildDataRow(context.t('coupon_detail_sales_income'), '\$89,250'), - _buildDataRow(context.t('coupon_detail_breakage_income'), '\$3,400'), - _buildDataRow(context.t('coupon_detail_platform_fee'), '-\$1,070'), + _buildDataRow(context.t('coupon_detail_sales_income'), '\$${salesIncome.toStringAsFixed(2)}'), + _buildDataRow(context.t('coupon_detail_breakage_income'), '--'), + _buildDataRow(context.t('coupon_detail_platform_fee'), '--'), const Divider(height: 24), - _buildDataRow(context.t('coupon_detail_net_income'), '\$91,580', bold: true), + _buildDataRow(context.t('coupon_detail_net_income'), '\$${salesIncome.toStringAsFixed(2)}', bold: true), const SizedBox(height: 16), // Chart placeholder Container( @@ -162,11 +250,11 @@ class IssuerCouponDetailPage extends StatelessWidget { children: [ Text(context.t('coupon_detail_secondary_market'), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), const SizedBox(height: 16), - _buildDataRow(context.t('coupon_detail_listing_count'), '128'), - _buildDataRow(context.t('coupon_detail_avg_resale_price'), '\$22.80'), - _buildDataRow(context.t('coupon_detail_avg_discount_rate'), '91.2%'), - _buildDataRow(context.t('coupon_detail_resale_volume'), '856'), - _buildDataRow(context.t('coupon_detail_resale_amount'), '\$19,517'), + _buildDataRow(context.t('coupon_detail_listing_count'), '--'), + _buildDataRow(context.t('coupon_detail_avg_resale_price'), '--'), + _buildDataRow(context.t('coupon_detail_avg_discount_rate'), '--'), + _buildDataRow(context.t('coupon_detail_resale_volume'), '--'), + _buildDataRow(context.t('coupon_detail_resale_amount'), '--'), const SizedBox(height: 16), Container( height: 100, @@ -194,22 +282,18 @@ class IssuerCouponDetailPage extends StatelessWidget { children: [ Text(context.t('coupon_detail_financing_effect'), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), const SizedBox(height: 16), - _buildDataRow(context.t('coupon_detail_cash_advance'), '\$89,250'), - _buildDataRow(context.t('coupon_detail_avg_advance_days'), '45 天'), - _buildDataRow(context.t('coupon_detail_financing_cost'), '\$4,463'), - _buildDataRow(context.t('coupon_detail_equiv_annual_rate'), '3.6%'), + _buildDataRow(context.t('coupon_detail_cash_advance'), '--'), + _buildDataRow(context.t('coupon_detail_avg_advance_days'), '--'), + _buildDataRow(context.t('coupon_detail_financing_cost'), '--'), + _buildDataRow(context.t('coupon_detail_equiv_annual_rate'), '--'), ], ), ); } Widget _buildRedemptionTimeline(BuildContext context) { - final events = [ - ('核销 5 张 · 门店A', '10分钟前', AppColors.success), - ('核销 2 张 · 门店B', '25分钟前', AppColors.success), - ('退款 1 张 · 自动退款', '1小时前', AppColors.warning), - ('核销 8 张 · 门店A', '2小时前', AppColors.success), - ]; + // Placeholder - in the future this could come from a dedicated API + final events = <(String, String, Color)>[]; return Container( padding: const EdgeInsets.all(16), @@ -223,20 +307,28 @@ class IssuerCouponDetailPage extends StatelessWidget { children: [ Text(context.t('coupon_detail_recent_redemptions'), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), const SizedBox(height: 12), - ...events.map((e) { - final (desc, time, color) = e; - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Row( - children: [ - Container(width: 8, height: 8, decoration: BoxDecoration(color: color, shape: BoxShape.circle)), - const SizedBox(width: 12), - Expanded(child: Text(desc, style: const TextStyle(fontSize: 13))), - Text(time, style: const TextStyle(fontSize: 11, color: AppColors.textTertiary)), - ], + if (events.isEmpty) + const Padding( + padding: EdgeInsets.all(16), + child: Center( + child: Text('--', style: TextStyle(color: AppColors.textTertiary)), ), - ); - }), + ) + else + ...events.map((e) { + final (desc, time, color) = e; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + Container(width: 8, height: 8, decoration: BoxDecoration(color: color, shape: BoxShape.circle)), + const SizedBox(width: 12), + Expanded(child: Text(desc, style: const TextStyle(fontSize: 13))), + Text(time, style: const TextStyle(fontSize: 11, color: AppColors.textTertiary)), + ], + ), + ); + }), ], ), ); @@ -263,7 +355,20 @@ class IssuerCouponDetailPage extends StatelessWidget { content: Text(context.t('coupon_detail_recall_desc')), actions: [ TextButton(onPressed: () => Navigator.pop(ctx), child: Text(context.t('cancel'))), - ElevatedButton(onPressed: () => Navigator.pop(ctx), child: Text(context.t('coupon_detail_confirm_recall'))), + ElevatedButton( + onPressed: () async { + Navigator.pop(ctx); + if (_coupon != null) { + try { + await _couponService.updateStatus(_coupon!.id, 'recalled'); + if (mounted) _loadDetail(); + } catch (e) { + debugPrint('[IssuerCouponDetailPage] recall error: $e'); + } + } + }, + child: Text(context.t('coupon_detail_confirm_recall')), + ), ], ), ); @@ -285,7 +390,17 @@ class IssuerCouponDetailPage extends StatelessWidget { actions: [ TextButton(onPressed: () => Navigator.pop(ctx), child: Text(context.t('cancel'))), ElevatedButton( - onPressed: () => Navigator.pop(ctx), + onPressed: () async { + Navigator.pop(ctx); + if (_coupon != null) { + try { + await _couponService.updateStatus(_coupon!.id, 'delisted'); + if (mounted) _loadDetail(); + } catch (e) { + debugPrint('[IssuerCouponDetailPage] delist error: $e'); + } + } + }, style: ElevatedButton.styleFrom(backgroundColor: AppColors.error), child: Text(context.t('coupon_detail_confirm_delist')), ), diff --git a/frontend/admin-app/lib/features/coupon_management/presentation/pages/coupon_list_page.dart b/frontend/admin-app/lib/features/coupon_management/presentation/pages/coupon_list_page.dart index 629d2d5..128704b 100644 --- a/frontend/admin-app/lib/features/coupon_management/presentation/pages/coupon_list_page.dart +++ b/frontend/admin-app/lib/features/coupon_management/presentation/pages/coupon_list_page.dart @@ -2,14 +2,94 @@ import 'package:flutter/material.dart'; import '../../../../app/theme/app_colors.dart'; import '../../../../app/router.dart'; import '../../../../app/i18n/app_localizations.dart'; +import '../../../../core/services/issuer_coupon_service.dart'; /// 券管理 - 列表页 /// /// 展示发行方所有券批次,支持按状态筛选 /// 顶部AI建议条 + FAB创建新券 -class CouponListPage extends StatelessWidget { +class CouponListPage extends StatefulWidget { const CouponListPage({super.key}); + @override + State createState() => _CouponListPageState(); +} + +class _CouponListPageState extends State { + final _couponService = IssuerCouponService(); + + bool _isLoading = true; + String? _error; + List _coupons = []; + int _total = 0; + int _currentPage = 1; + String? _selectedStatus; + bool _hasMore = true; + + final _filterStatusMap = { + 0: null, // all + 1: 'active', // on_sale + 2: 'sold_out', // sold_out + 3: 'pending', // pending + 4: 'delisted', // delisted + }; + int _selectedFilterIndex = 0; + + @override + void initState() { + super.initState(); + _loadCoupons(); + } + + Future _loadCoupons({bool loadMore = false}) async { + if (!loadMore) { + setState(() { + _isLoading = true; + _error = null; + _currentPage = 1; + }); + } + + try { + final result = await _couponService.list( + page: _currentPage, + status: _selectedStatus, + ); + if (!mounted) return; + setState(() { + if (loadMore) { + _coupons.addAll(result.items); + } else { + _coupons = result.items; + } + _total = result.total; + _hasMore = _coupons.length < _total; + _isLoading = false; + }); + } catch (e) { + debugPrint('[CouponListPage] loadCoupons error: $e'); + if (!mounted) return; + setState(() { + _isLoading = false; + _error = e.toString(); + }); + } + } + + void _onFilterChanged(int index) { + setState(() { + _selectedFilterIndex = index; + _selectedStatus = _filterStatusMap[index]; + }); + _loadCoupons(); + } + + void _loadMore() { + if (!_hasMore || _isLoading) return; + _currentPage++; + _loadCoupons(loadMore: true); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -40,14 +120,56 @@ class CouponListPage extends StatelessWidget { // Coupon List Expanded( - child: ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 20), - itemCount: _mockCoupons.length, - itemBuilder: (context, index) { - final coupon = _mockCoupons[index]; - return _buildCouponItem(context, coupon); - }, - ), + child: _isLoading && _coupons.isEmpty + ? const Center(child: CircularProgressIndicator()) + : _error != null && _coupons.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 48, color: AppColors.textTertiary), + const SizedBox(height: 16), + Text(_error!, style: const TextStyle(color: AppColors.textSecondary)), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadCoupons, + child: Text(context.t('retry')), + ), + ], + ), + ) + : _coupons.isEmpty + ? Center( + child: Text( + context.t('coupon_list_empty'), + style: const TextStyle(color: AppColors.textSecondary), + ), + ) + : RefreshIndicator( + onRefresh: _loadCoupons, + child: NotificationListener( + onNotification: (notification) { + if (notification is ScrollEndNotification && + notification.metrics.extentAfter < 200) { + _loadMore(); + } + return false; + }, + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 20), + itemCount: _coupons.length + (_hasMore ? 1 : 0), + itemBuilder: (context, index) { + if (index == _coupons.length) { + return const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ); + } + return _buildCouponItem(context, _coupons[index]); + }, + ), + ), + ), ), ], ), @@ -102,16 +224,14 @@ class CouponListPage extends StatelessWidget { scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), child: Row( - children: filters.map((f) { - final isSelected = f == context.t('all'); + children: filters.asMap().entries.map((entry) { + final isSelected = entry.key == _selectedFilterIndex; return Padding( padding: const EdgeInsets.only(right: 8), child: FilterChip( - label: Text(f), + label: Text(entry.value), selected: isSelected, - onSelected: (_) { - // TODO: Apply coupon status filter - }, + onSelected: (_) => _onFilterChanged(entry.key), selectedColor: AppColors.primaryContainer, checkmarkColor: AppColors.primary, ), @@ -121,9 +241,9 @@ class CouponListPage extends StatelessWidget { ); } - Widget _buildCouponItem(BuildContext context, _MockCoupon coupon) { + Widget _buildCouponItem(BuildContext context, IssuerCouponModel coupon) { return GestureDetector( - onTap: () => Navigator.pushNamed(context, AppRouter.couponDetail), + onTap: () => Navigator.pushNamed(context, AppRouter.couponDetail, arguments: coupon.id), child: Container( margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.all(16), @@ -144,7 +264,17 @@ class CouponListPage extends StatelessWidget { color: AppColors.primarySurface, borderRadius: BorderRadius.circular(10), ), - child: const Icon(Icons.confirmation_number_rounded, color: AppColors.primary, size: 22), + child: coupon.imageUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.network( + coupon.imageUrl!, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => + const Icon(Icons.confirmation_number_rounded, color: AppColors.primary, size: 22), + ), + ) + : const Icon(Icons.confirmation_number_rounded, color: AppColors.primary, size: 22), ), const SizedBox(width: 12), Expanded( @@ -157,7 +287,7 @@ class CouponListPage extends StatelessWidget { ), const SizedBox(height: 2), Text( - '${coupon.template} · ${context.t('coupon_face_value')} \$${coupon.faceValue}', + '${coupon.couponType} · ${context.t('coupon_face_value')} \$${coupon.faceValue.toStringAsFixed(0)}', style: const TextStyle(fontSize: 12, color: AppColors.textSecondary), ), ], @@ -170,10 +300,15 @@ class CouponListPage extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - _buildMiniStat(context.t('coupon_stat_issued'), '${coupon.issued}'), - _buildMiniStat(context.t('coupon_stat_sold'), '${coupon.sold}'), - _buildMiniStat(context.t('coupon_stat_redeemed'), '${coupon.redeemed}'), - _buildMiniStat(context.t('coupon_stat_rate'), '${coupon.redemptionRate}%'), + _buildMiniStat(context.t('coupon_stat_issued'), '${coupon.totalSupply}'), + _buildMiniStat(context.t('coupon_stat_sold'), '${coupon.soldCount}'), + _buildMiniStat(context.t('coupon_stat_redeemed'), '${coupon.remainingSupply}'), + _buildMiniStat( + context.t('coupon_stat_rate'), + coupon.totalSupply > 0 + ? '${((coupon.soldCount / coupon.totalSupply) * 100).toStringAsFixed(1)}%' + : '0%', + ), ], ), ], @@ -184,15 +319,23 @@ class CouponListPage extends StatelessWidget { Widget _buildStatusBadge(String status) { Color color; + String label = status; switch (status) { - case '在售中': + case 'active': color = AppColors.success; + label = '在售中'; break; - case '待审核': + case 'pending': color = AppColors.warning; + label = '待审核'; break; - case '已售罄': + case 'sold_out': color = AppColors.info; + label = '已售罄'; + break; + case 'delisted': + color = AppColors.textTertiary; + label = '已下架'; break; default: color = AppColors.textTertiary; @@ -203,7 +346,7 @@ class CouponListPage extends StatelessWidget { color: color.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(999), ), - child: Text(status, style: TextStyle(fontSize: 11, color: color, fontWeight: FontWeight.w600)), + child: Text(label, style: TextStyle(fontSize: 11, color: color, fontWeight: FontWeight.w600)), ); } @@ -217,32 +360,3 @@ class CouponListPage extends StatelessWidget { ); } } - -class _MockCoupon { - final String name; - final String template; - final double faceValue; - final String status; - final int issued; - final int sold; - final int redeemed; - final double redemptionRate; - - const _MockCoupon({ - required this.name, - required this.template, - required this.faceValue, - required this.status, - required this.issued, - required this.sold, - required this.redeemed, - required this.redemptionRate, - }); -} - -const _mockCoupons = [ - _MockCoupon(name: '¥25 星巴克礼品卡', template: '礼品卡', faceValue: 25, status: '在售中', issued: 5000, sold: 4200, redeemed: 3300, redemptionRate: 78.5), - _MockCoupon(name: '¥100 购物代金券', template: '代金券', faceValue: 100, status: '在售中', issued: 2000, sold: 1580, redeemed: 980, redemptionRate: 62.0), - _MockCoupon(name: '8折餐饮折扣券', template: '折扣券', faceValue: 50, status: '待审核', issued: 1000, sold: 0, redeemed: 0, redemptionRate: 0), - _MockCoupon(name: '¥200 储值卡', template: '储值券', faceValue: 200, status: '已售罄', issued: 500, sold: 500, redeemed: 420, redemptionRate: 84.0), -]; diff --git a/frontend/admin-app/lib/features/coupon_management/presentation/pages/create_coupon_page.dart b/frontend/admin-app/lib/features/coupon_management/presentation/pages/create_coupon_page.dart index c15d7e6..607aa82 100644 --- a/frontend/admin-app/lib/features/coupon_management/presentation/pages/create_coupon_page.dart +++ b/frontend/admin-app/lib/features/coupon_management/presentation/pages/create_coupon_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../../../app/theme/app_colors.dart'; import '../../../../app/i18n/app_localizations.dart'; +import '../../../../core/services/issuer_coupon_service.dart'; /// 模板化发券页面 /// @@ -21,10 +22,25 @@ class _CreateCouponPageState extends State { final _faceValueController = TextEditingController(); final _quantityController = TextEditingController(); final _issuePriceController = TextEditingController(); + final _descriptionController = TextEditingController(); bool _transferable = true; int _maxResaleCount = 2; int _refundWindowDays = 7; bool _autoRefund = true; + bool _isSubmitting = false; + DateTime? _expiryDate; + + final _couponService = IssuerCouponService(); + + @override + void dispose() { + _nameController.dispose(); + _faceValueController.dispose(); + _quantityController.dispose(); + _issuePriceController.dispose(); + _descriptionController.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { @@ -230,9 +246,11 @@ class _CreateCouponPageState extends State { firstDate: DateTime.now(), lastDate: DateTime.now().add(const Duration(days: 365)), fieldLabelText: context.t('create_coupon_expiry'), + onDateSaved: (date) => _expiryDate = date, ), const SizedBox(height: 16), TextField( + controller: _descriptionController, maxLines: 3, decoration: InputDecoration(labelText: context.t('create_coupon_description'), hintText: context.t('create_coupon_description_hint')), ), @@ -432,21 +450,29 @@ class _CreateCouponPageState extends State { if (_currentStep > 0) Expanded( child: OutlinedButton( - onPressed: () => setState(() => _currentStep--), + onPressed: _isSubmitting ? null : () => setState(() => _currentStep--), child: Text(context.t('prev_step')), ), ), if (_currentStep > 0) const SizedBox(width: 12), Expanded( child: ElevatedButton( - onPressed: () { - if (_currentStep < 3) { - setState(() => _currentStep++); - } else { - _submitForReview(); - } - }, - child: Text(_currentStep < 3 ? context.t('next') : context.t('onboarding_submit_review')), + onPressed: _isSubmitting + ? null + : () { + if (_currentStep < 3) { + setState(() => _currentStep++); + } else { + _submitForReview(); + } + }, + child: _isSubmitting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : Text(_currentStep < 3 ? context.t('next') : context.t('onboarding_submit_review')), ), ), ], @@ -454,22 +480,56 @@ class _CreateCouponPageState extends State { ); } - void _submitForReview() { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(context.t('create_coupon_submit_success')), - content: Text(context.t('create_coupon_submit_desc')), - actions: [ - TextButton( - onPressed: () { - Navigator.of(ctx).pop(); - Navigator.of(context).pop(); - }, - child: Text(context.t('confirm')), - ), - ], - ), - ); + Future _submitForReview() async { + setState(() => _isSubmitting = true); + + try { + final body = { + 'name': _nameController.text.trim(), + 'couponType': _selectedTemplate ?? 'gift', + 'faceValue': double.tryParse(_faceValueController.text) ?? 0, + 'currentPrice': double.tryParse(_issuePriceController.text) ?? 0, + 'totalSupply': int.tryParse(_quantityController.text) ?? 0, + 'description': _descriptionController.text.trim(), + 'isTransferable': _transferable, + 'maxResaleCount': _maxResaleCount, + 'refundWindowDays': _refundWindowDays, + 'autoRefund': _autoRefund, + if (_expiryDate != null) 'expiryDate': _expiryDate!.toIso8601String(), + }; + + await _couponService.create(body); + + if (!mounted) return; + setState(() => _isSubmitting = false); + + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(context.t('create_coupon_submit_success')), + content: Text(context.t('create_coupon_submit_desc')), + actions: [ + TextButton( + onPressed: () { + Navigator.of(ctx).pop(); + Navigator.of(context).pop(); + }, + child: Text(context.t('confirm')), + ), + ], + ), + ); + } catch (e) { + debugPrint('[CreateCouponPage] submit error: $e'); + if (!mounted) return; + setState(() => _isSubmitting = false); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${context.t('create_coupon_submit_error')}: $e'), + backgroundColor: AppColors.error, + ), + ); + } } } diff --git a/frontend/admin-app/lib/features/credit/presentation/pages/credit_page.dart b/frontend/admin-app/lib/features/credit/presentation/pages/credit_page.dart index d69f9ae..28a7537 100644 --- a/frontend/admin-app/lib/features/credit/presentation/pages/credit_page.dart +++ b/frontend/admin-app/lib/features/credit/presentation/pages/credit_page.dart @@ -1,47 +1,108 @@ import 'package:flutter/material.dart'; import '../../../../app/theme/app_colors.dart'; import '../../../../app/i18n/app_localizations.dart'; +import '../../../../core/services/issuer_credit_service.dart'; /// 信用评级页面 /// /// 四因子信用评分:核销率35% + (1-Breakage率)25% + 市场存续20% + 用户满意度20% /// AI建议列表:信用提升建议 -class CreditPage extends StatelessWidget { +class CreditPage extends StatefulWidget { const CreditPage({super.key}); + @override + State createState() => _CreditPageState(); +} + +class _CreditPageState extends State { + final _creditService = IssuerCreditService(); + + bool _isLoading = true; + String? _error; + CreditModel? _credit; + + @override + void initState() { + super.initState(); + _loadCredit(); + } + + Future _loadCredit() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final credit = await _creditService.getCredit(); + if (!mounted) return; + setState(() { + _credit = credit; + _isLoading = false; + }); + } catch (e) { + debugPrint('[CreditPage] loadCredit error: $e'); + if (!mounted) return; + setState(() { + _isLoading = false; + _error = e.toString(); + }); + } + } + @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text(context.t('credit_title'))), - body: SingleChildScrollView( - padding: const EdgeInsets.all(20), - child: Column( - children: [ - // Score Gauge - _buildScoreGauge(context), - const SizedBox(height: 24), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 48, color: AppColors.textTertiary), + const SizedBox(height: 16), + Text(_error!, style: const TextStyle(color: AppColors.textSecondary)), + const SizedBox(height: 16), + ElevatedButton(onPressed: _loadCredit, child: Text(context.t('retry'))), + ], + ), + ) + : RefreshIndicator( + onRefresh: _loadCredit, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(20), + child: Column( + children: [ + // Score Gauge + _buildScoreGauge(context), + const SizedBox(height: 24), - // Four Factors - _buildFactorsCard(context), - const SizedBox(height: 20), + // Four Factors + _buildFactorsCard(context), + const SizedBox(height: 20), - // Tier Progress - _buildTierProgress(context), - const SizedBox(height: 20), + // Tier Progress + _buildTierProgress(context), + const SizedBox(height: 20), - // AI Suggestions - _buildAiSuggestions(context), - const SizedBox(height: 20), + // AI Suggestions + _buildAiSuggestions(context), + const SizedBox(height: 20), - // Credit History - _buildCreditHistory(context), - ], - ), - ), + // Credit History + _buildCreditHistory(context), + ], + ), + ), + ), ); } Widget _buildScoreGauge(BuildContext context) { + final credit = _credit!; return Container( padding: const EdgeInsets.all(24), decoration: BoxDecoration( @@ -62,12 +123,12 @@ class CreditPage extends StatelessWidget { colors: [AppColors.creditAA, AppColors.creditAA.withValues(alpha: 0.3)], ), ), - child: const Center( + child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text('AA', style: TextStyle(fontSize: 32, fontWeight: FontWeight.w700, color: Colors.white)), - Text('82分', style: TextStyle(fontSize: 14, color: Colors.white70)), + Text(credit.grade, style: const TextStyle(fontSize: 32, fontWeight: FontWeight.w700, color: Colors.white)), + Text('${credit.score}分', style: const TextStyle(fontSize: 14, color: Colors.white70)), ], ), ), @@ -82,12 +143,8 @@ class CreditPage extends StatelessWidget { } Widget _buildFactorsCard(BuildContext context) { - final factors = [ - (context.t('credit_factor_redemption'), 0.85, 0.35, AppColors.success), - (context.t('credit_factor_breakage'), 0.72, 0.25, AppColors.info), - (context.t('credit_factor_market'), 0.90, 0.20, AppColors.primary), - (context.t('credit_factor_satisfaction'), 0.78, 0.20, AppColors.warning), - ]; + final factors = _credit!.factors; + final factorColors = [AppColors.success, AppColors.info, AppColors.primary, AppColors.warning]; return Container( padding: const EdgeInsets.all(16), @@ -101,8 +158,10 @@ class CreditPage extends StatelessWidget { children: [ Text(context.t('credit_factors'), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), const SizedBox(height: 16), - ...factors.map((f) { - final (label, score, weight, color) = f; + ...factors.asMap().entries.map((entry) { + final f = entry.value; + final color = factorColors[entry.key % factorColors.length]; + final score = f.score / 100; return Padding( padding: const EdgeInsets.only(bottom: 16), child: Column( @@ -110,9 +169,9 @@ class CreditPage extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(label, style: const TextStyle(fontSize: 13)), + Text(f.name, style: const TextStyle(fontSize: 13)), Text( - '${(score * 100).toInt()}分 (权重${(weight * 100).toInt()}%)', + '${f.score.toInt()}分 (权重${(f.weight * 100).toInt()}%)', style: const TextStyle(fontSize: 12, color: AppColors.textSecondary), ), ], @@ -121,7 +180,7 @@ class CreditPage extends StatelessWidget { ClipRRect( borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( - value: score, + value: score.clamp(0.0, 1.0), backgroundColor: AppColors.gray100, valueColor: AlwaysStoppedAnimation(color), minHeight: 8, @@ -131,18 +190,20 @@ class CreditPage extends StatelessWidget { ), ); }), + if (factors.isEmpty) + const Padding( + padding: EdgeInsets.all(16), + child: Center(child: Text('暂无评分因子', style: TextStyle(color: AppColors.textTertiary))), + ), ], ), ); } Widget _buildTierProgress(BuildContext context) { - final tiers = [ - (context.t('credit_tier_silver'), AppColors.tierSilver, true), - (context.t('credit_tier_gold'), AppColors.tierGold, true), - (context.t('credit_tier_platinum'), AppColors.tierPlatinum, false), - (context.t('credit_tier_diamond'), AppColors.tierDiamond, false), - ]; + final credit = _credit!; + final tierColors = [AppColors.tierSilver, AppColors.tierGold, AppColors.tierPlatinum, AppColors.tierDiamond]; + final tiers = credit.tiers; return Container( padding: const EdgeInsets.all(16), @@ -158,8 +219,11 @@ class CreditPage extends StatelessWidget { const SizedBox(height: 16), Row( mainAxisAlignment: MainAxisAlignment.spaceAround, - children: tiers.map((t) { - final (name, color, isReached) = t; + children: tiers.asMap().entries.map((entry) { + final index = entry.key; + final name = entry.value; + final isReached = index <= credit.currentTierIndex; + final color = tierColors[index % tierColors.length]; return Column( children: [ Container( @@ -200,11 +264,8 @@ class CreditPage extends StatelessWidget { } Widget _buildAiSuggestions(BuildContext context) { - final suggestions = [ - ('提升核销率', '建议在周末推出限时核销活动,预计可提升核销率5%', Icons.trending_up_rounded), - ('降低Breakage', '当前有12%的券过期未用,建议到期前7天推送提醒', Icons.notification_important_rounded), - ('增加用户满意度', '回复消费者评价可提升满意度评分', Icons.rate_review_rounded), - ]; + final suggestions = _credit!.suggestions; + final icons = [Icons.trending_up_rounded, Icons.notification_important_rounded, Icons.rate_review_rounded]; return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -217,38 +278,45 @@ class CreditPage extends StatelessWidget { ], ), const SizedBox(height: 12), - ...suggestions.map((s) { - final (title, desc, icon) = s; - return Container( - margin: const EdgeInsets.only(bottom: 10), + if (suggestions.isEmpty) + Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: AppColors.primarySurface, borderRadius: BorderRadius.circular(10), ), - child: Row( - children: [ - Icon(icon, color: AppColors.primary, size: 20), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), - const SizedBox(height: 2), - Text(desc, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)), - ], - ), - ), - ], + child: const Center( + child: Text('暂无建议', style: TextStyle(color: AppColors.textSecondary)), ), - ); - }), + ) + else + ...suggestions.asMap().entries.map((entry) { + final icon = icons[entry.key % icons.length]; + final text = entry.value; + return Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColors.primarySurface, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + Icon(icon, color: AppColors.primary, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text(text, style: const TextStyle(fontSize: 13, color: AppColors.textSecondary)), + ), + ], + ), + ); + }), ], ); } Widget _buildCreditHistory(BuildContext context) { + // Credit history not yet provided by API, keeping placeholder return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -261,32 +329,10 @@ class CreditPage extends StatelessWidget { children: [ Text(context.t('credit_history_title'), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), const SizedBox(height: 12), - _buildHistoryItem('信用分 +3', '核销率提升至85%', '2天前', AppColors.success), - _buildHistoryItem('信用分 -1', 'Breakage率微升', '1周前', AppColors.error), - _buildHistoryItem('升级至黄金', '月发行量达100万', '2周前', AppColors.tierGold), - _buildHistoryItem('信用分 +5', '完成首月营业', '1月前', AppColors.success), - ], - ), - ); - } - - Widget _buildHistoryItem(String title, String desc, String time, Color color) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - children: [ - Container(width: 8, height: 8, decoration: BoxDecoration(color: color, shape: BoxShape.circle)), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: color)), - Text(desc, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)), - ], - ), + const Padding( + padding: EdgeInsets.all(16), + child: Center(child: Text('--', style: TextStyle(color: AppColors.textTertiary))), ), - Text(time, style: const TextStyle(fontSize: 11, color: AppColors.textTertiary)), ], ), ); diff --git a/frontend/admin-app/lib/features/dashboard/presentation/pages/issuer_dashboard_page.dart b/frontend/admin-app/lib/features/dashboard/presentation/pages/issuer_dashboard_page.dart index 58a5964..b78295d 100644 --- a/frontend/admin-app/lib/features/dashboard/presentation/pages/issuer_dashboard_page.dart +++ b/frontend/admin-app/lib/features/dashboard/presentation/pages/issuer_dashboard_page.dart @@ -2,14 +2,60 @@ import 'package:flutter/material.dart'; import '../../../../app/theme/app_colors.dart'; import '../../../../app/router.dart'; import '../../../../app/i18n/app_localizations.dart'; +import '../../../../core/services/issuer_service.dart'; /// 发行方数据仪表盘 /// /// 展示:总发行量、核销率、销售收入、信用等级、额度使用 /// AI洞察卡片:智能解读销售数据 -class IssuerDashboardPage extends StatelessWidget { +class IssuerDashboardPage extends StatefulWidget { const IssuerDashboardPage({super.key}); + @override + State createState() => _IssuerDashboardPageState(); +} + +class _IssuerDashboardPageState extends State { + final _issuerService = IssuerService(); + + bool _isLoading = true; + String? _error; + IssuerStats? _stats; + IssuerProfile? _profile; + + @override + void initState() { + super.initState(); + _loadData(); + } + + Future _loadData() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final results = await Future.wait([ + _issuerService.getStats(), + _issuerService.getProfile(), + ]); + if (!mounted) return; + setState(() { + _stats = results[0] as IssuerStats; + _profile = results[1] as IssuerProfile; + _isLoading = false; + }); + } catch (e) { + debugPrint('[IssuerDashboardPage] loadData error: $e'); + if (!mounted) return; + setState(() { + _isLoading = false; + _error = e.toString(); + }); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -24,36 +70,58 @@ class IssuerDashboardPage extends StatelessWidget { ), ], ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Issuer Info Card - _buildIssuerInfoCard(context), - const SizedBox(height: 20), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 48, color: AppColors.textTertiary), + const SizedBox(height: 16), + Text(_error!, style: const TextStyle(color: AppColors.textSecondary)), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadData, + child: Text(context.t('retry')), + ), + ], + ), + ) + : RefreshIndicator( + onRefresh: _loadData, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Issuer Info Card + _buildIssuerInfoCard(context), + const SizedBox(height: 20), - // Stats Grid (2x2) - _buildStatsGrid(context), - const SizedBox(height: 20), + // Stats Grid (2x2) + _buildStatsGrid(context), + const SizedBox(height: 20), - // AI Insight Card - _buildAiInsightCard(context), - const SizedBox(height: 20), + // AI Insight Card + _buildAiInsightCard(context), + const SizedBox(height: 20), - // Credit & Quota - _buildCreditQuotaCard(context), - const SizedBox(height: 20), + // Credit & Quota + _buildCreditQuotaCard(context), + const SizedBox(height: 20), - // Sales Trend Chart Placeholder - _buildSalesTrendCard(context), - const SizedBox(height: 20), + // Sales Trend Chart Placeholder + _buildSalesTrendCard(context), + const SizedBox(height: 20), - // Recent Activity - _buildRecentActivity(context), - ], - ), - ), + // Recent Activity + _buildRecentActivity(context), + ], + ), + ), + ), ); } @@ -73,19 +141,33 @@ class IssuerDashboardPage extends StatelessWidget { color: Colors.white.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(12), ), - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Image.asset('assets/images/logo_icon.png', width: 48, height: 48), - ), + child: _profile?.logoUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.network( + _profile!.logoUrl!, + width: 48, + height: 48, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.asset('assets/images/logo_icon.png', width: 48, height: 48), + ), + ), + ) + : ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.asset('assets/images/logo_icon.png', width: 48, height: 48), + ), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Starbucks China', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: Colors.white), + Text( + _profile?.companyName ?? '--', + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: Colors.white), ), const SizedBox(height: 4), Container( @@ -95,7 +177,9 @@ class IssuerDashboardPage extends StatelessWidget { borderRadius: BorderRadius.circular(999), ), child: Text( - context.t('dashboard_gold_issuer'), + _stats?.creditGrade != null + ? '${_stats!.creditGrade} ${context.t('dashboard_gold_issuer')}' + : context.t('dashboard_gold_issuer'), style: const TextStyle(fontSize: 11, color: Colors.white, fontWeight: FontWeight.w600), ), ), @@ -109,11 +193,32 @@ class IssuerDashboardPage extends StatelessWidget { } Widget _buildStatsGrid(BuildContext context) { - final stats = [ - (context.t('dashboard_total_issued'), '12,580', Icons.confirmation_number_rounded, AppColors.primary), - (context.t('dashboard_redemption_rate'), '78.5%', Icons.check_circle_rounded, AppColors.success), - (context.t('dashboard_sales_revenue'), '\$125,800', Icons.attach_money_rounded, AppColors.info), - (context.t('dashboard_withdrawable'), '\$42,300', Icons.account_balance_wallet_rounded, AppColors.warning), + final stats = _stats; + final statItems = [ + ( + context.t('dashboard_total_issued'), + stats != null ? '${stats.issuedCount}' : '--', + Icons.confirmation_number_rounded, + AppColors.primary, + ), + ( + context.t('dashboard_redemption_rate'), + stats != null ? '${stats.redemptionRate.toStringAsFixed(1)}%' : '--', + Icons.check_circle_rounded, + AppColors.success, + ), + ( + context.t('dashboard_sales_revenue'), + stats != null ? '\$${stats.totalRevenue.toStringAsFixed(0)}' : '--', + Icons.attach_money_rounded, + AppColors.info, + ), + ( + context.t('dashboard_withdrawable'), + stats != null ? '\$${(stats.quotaTotal - stats.quotaUsed).toStringAsFixed(0)}' : '--', + Icons.account_balance_wallet_rounded, + AppColors.warning, + ), ]; return GridView.builder( @@ -125,9 +230,9 @@ class IssuerDashboardPage extends StatelessWidget { crossAxisSpacing: 12, childAspectRatio: 1.6, ), - itemCount: stats.length, + itemCount: statItems.length, itemBuilder: (context, index) { - final (label, value, icon, color) = stats[index]; + final (label, value, icon, color) = statItems[index]; return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -212,6 +317,11 @@ class IssuerDashboardPage extends StatelessWidget { } Widget _buildCreditQuotaCard(BuildContext context) { + final stats = _stats; + final quotaUsed = stats?.quotaUsed ?? 0; + final quotaTotal = stats?.quotaTotal ?? 1; + final quotaPercent = quotaTotal > 0 ? (quotaUsed / quotaTotal) : 0.0; + return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -231,8 +341,11 @@ class IssuerDashboardPage extends StatelessWidget { color: AppColors.creditAA.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(10), ), - child: const Center( - child: Text('AA', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: AppColors.creditAA)), + child: Center( + child: Text( + stats?.creditGrade ?? '--', + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: AppColors.creditAA), + ), ), ), const SizedBox(width: 12), @@ -241,7 +354,10 @@ class IssuerDashboardPage extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(context.t('dashboard_credit_rating'), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), - Text(context.t('dashboard_credit_gap'), style: const TextStyle(fontSize: 12, color: AppColors.textTertiary)), + Text( + stats != null ? '${context.t('dashboard_credit_gap')} (${stats.creditScore}分)' : context.t('dashboard_credit_gap'), + style: const TextStyle(fontSize: 12, color: AppColors.textTertiary), + ), ], ), ), @@ -262,14 +378,17 @@ class IssuerDashboardPage extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(context.t('dashboard_issue_quota'), style: const TextStyle(fontSize: 13, color: AppColors.textSecondary)), - const Text('\$380,000 / \$500,000', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), + Text( + '\$${quotaUsed.toStringAsFixed(0)} / \$${quotaTotal.toStringAsFixed(0)}', + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + ), ], ), const SizedBox(height: 8), ClipRRect( borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( - value: 0.76, + value: quotaPercent.clamp(0.0, 1.0), backgroundColor: AppColors.gray100, valueColor: const AlwaysStoppedAnimation(AppColors.primary), minHeight: 8, @@ -278,7 +397,10 @@ class IssuerDashboardPage extends StatelessWidget { const SizedBox(height: 4), Align( alignment: Alignment.centerRight, - child: Text(context.t('dashboard_used_percent'), style: const TextStyle(fontSize: 11, color: AppColors.textTertiary)), + child: Text( + '${(quotaPercent * 100).toStringAsFixed(0)}%', + style: const TextStyle(fontSize: 11, color: AppColors.textTertiary), + ), ), ], ), diff --git a/frontend/admin-app/lib/features/finance/presentation/pages/finance_page.dart b/frontend/admin-app/lib/features/finance/presentation/pages/finance_page.dart index c0d91ef..09a3f0e 100644 --- a/frontend/admin-app/lib/features/finance/presentation/pages/finance_page.dart +++ b/frontend/admin-app/lib/features/finance/presentation/pages/finance_page.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import '../../../../app/theme/app_colors.dart'; -import '../../../../app/router.dart'; import '../../../../app/i18n/app_localizations.dart'; +import '../../../../core/services/issuer_finance_service.dart'; /// 财务管理页面 /// @@ -57,92 +57,195 @@ class FinancePage extends StatelessWidget { } } -class _OverviewTab extends StatelessWidget { +class _OverviewTab extends StatefulWidget { const _OverviewTab(); @override - Widget build(BuildContext context) { - return SingleChildScrollView( - padding: const EdgeInsets.all(20), - child: Column( - children: [ - // Balance Card - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - gradient: AppColors.primaryGradient, - borderRadius: BorderRadius.circular(16), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(context.t('finance_withdrawable'), style: TextStyle(fontSize: 13, color: Colors.white.withValues(alpha: 0.7))), - const SizedBox(height: 4), - const Text('\$42,300.00', style: TextStyle(fontSize: 32, fontWeight: FontWeight.w700, color: Colors.white)), - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: () { - // TODO: Show withdrawal dialog or navigate to withdrawal flow - }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: AppColors.primary, - ), - child: Text(context.t('finance_withdraw')), - ), - ), - ], - ), - ), - const SizedBox(height: 20), + State<_OverviewTab> createState() => _OverviewTabState(); +} - // Financial Stats - _buildFinanceStatsGrid(context), - const SizedBox(height: 20), +class _OverviewTabState extends State<_OverviewTab> { + final _financeService = IssuerFinanceService(); - // Guarantee Fund - _buildGuaranteeFundCard(context), - const SizedBox(height: 20), + bool _isLoading = true; + String? _error; + FinanceBalance? _balance; + FinanceStats? _stats; - // Revenue Trend - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.surface, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.borderLight), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(context.t('finance_revenue_trend'), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), - const SizedBox(height: 16), - Container( - height: 160, - decoration: BoxDecoration( - color: AppColors.gray50, - borderRadius: BorderRadius.circular(8), - ), - child: Center(child: Text(context.t('finance_revenue_chart'), style: const TextStyle(color: AppColors.textTertiary))), - ), - ], - ), - ), + @override + void initState() { + super.initState(); + _loadData(); + } + + Future _loadData() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final results = await Future.wait([ + _financeService.getBalance(), + _financeService.getStats(), + ]); + if (!mounted) return; + setState(() { + _balance = results[0] as FinanceBalance; + _stats = results[1] as FinanceStats; + _isLoading = false; + }); + } catch (e) { + debugPrint('[FinancePage] loadData error: $e'); + if (!mounted) return; + setState(() { + _isLoading = false; + _error = e.toString(); + }); + } + } + + Future _handleWithdraw() async { + if (_balance == null || _balance!.withdrawable <= 0) return; + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('确认提现'), + content: Text('提现金额: \$${_balance!.withdrawable.toStringAsFixed(2)}'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(context.t('cancel'))), + ElevatedButton(onPressed: () => Navigator.pop(ctx, true), child: Text(context.t('confirm'))), ], ), ); + + if (confirmed != true) return; + + try { + await _financeService.withdraw(_balance!.withdrawable); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('提现申请已提交'), backgroundColor: AppColors.success), + ); + _loadData(); + } catch (e) { + debugPrint('[FinancePage] withdraw error: $e'); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('提现失败: $e'), backgroundColor: AppColors.error), + ); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (_error != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 48, color: AppColors.textTertiary), + const SizedBox(height: 16), + Text(_error!, style: const TextStyle(color: AppColors.textSecondary)), + const SizedBox(height: 16), + ElevatedButton(onPressed: _loadData, child: Text(context.t('retry'))), + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: _loadData, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(20), + child: Column( + children: [ + // Balance Card + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: AppColors.primaryGradient, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.t('finance_withdrawable'), style: TextStyle(fontSize: 13, color: Colors.white.withValues(alpha: 0.7))), + const SizedBox(height: 4), + Text( + '\$${_balance?.withdrawable.toStringAsFixed(2) ?? '0.00'}', + style: const TextStyle(fontSize: 32, fontWeight: FontWeight.w700, color: Colors.white), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _handleWithdraw, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: AppColors.primary, + ), + child: Text(context.t('finance_withdraw')), + ), + ), + ], + ), + ), + const SizedBox(height: 20), + + // Financial Stats + _buildFinanceStatsGrid(context), + const SizedBox(height: 20), + + // Guarantee Fund + _buildGuaranteeFundCard(context), + const SizedBox(height: 20), + + // Revenue Trend + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.borderLight), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.t('finance_revenue_trend'), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), + const SizedBox(height: 16), + Container( + height: 160, + decoration: BoxDecoration( + color: AppColors.gray50, + borderRadius: BorderRadius.circular(8), + ), + child: Center(child: Text(context.t('finance_revenue_chart'), style: const TextStyle(color: AppColors.textTertiary))), + ), + ], + ), + ), + ], + ), + ), + ); } Widget _buildFinanceStatsGrid(BuildContext context) { - final stats = [ - (context.t('finance_sales_income'), '\$125,800', AppColors.success), - (context.t('finance_breakage_income'), '\$8,200', AppColors.info), - (context.t('finance_platform_fee'), '-\$1,510', AppColors.error), - (context.t('finance_pending_settlement'), '\$15,400', AppColors.warning), - (context.t('finance_withdrawn'), '\$66,790', AppColors.textSecondary), - (context.t('finance_total_income'), '\$132,490', AppColors.primary), + final s = _stats; + final statItems = [ + (context.t('finance_sales_income'), '\$${s?.salesAmount.toStringAsFixed(0) ?? '0'}', AppColors.success), + (context.t('finance_breakage_income'), '\$${s?.breakageIncome.toStringAsFixed(0) ?? '0'}', AppColors.info), + (context.t('finance_platform_fee'), '-\$${s?.platformFee.toStringAsFixed(0) ?? '0'}', AppColors.error), + (context.t('finance_pending_settlement'), '\$${s?.pendingSettlement.toStringAsFixed(0) ?? '0'}', AppColors.warning), + (context.t('finance_withdrawn'), '\$${s?.withdrawnAmount.toStringAsFixed(0) ?? '0'}', AppColors.textSecondary), + (context.t('finance_total_income'), '\$${s?.totalRevenue.toStringAsFixed(0) ?? '0'}', AppColors.primary), ]; return GridView.builder( @@ -154,9 +257,9 @@ class _OverviewTab extends StatelessWidget { crossAxisSpacing: 12, childAspectRatio: 2, ), - itemCount: stats.length, + itemCount: statItems.length, itemBuilder: (context, index) { - final (label, value, color) = stats[index]; + final (label, value, color) = statItems[index]; return Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( @@ -196,9 +299,9 @@ class _OverviewTab extends StatelessWidget { ], ), const SizedBox(height: 16), - _buildRow(context.t('finance_guarantee_deposit'), '\$10,000'), - _buildRow(context.t('finance_frozen_sales'), '\$5,200'), - _buildRow(context.t('finance_frozen_ratio'), '20%'), + _buildRow(context.t('finance_guarantee_deposit'), '\$${_balance?.pending.toStringAsFixed(0) ?? '0'}'), + _buildRow(context.t('finance_frozen_sales'), '--'), + _buildRow(context.t('finance_frozen_ratio'), '--'), const SizedBox(height: 12), SwitchListTile( title: Text(context.t('finance_auto_freeze'), style: const TextStyle(fontSize: 14)), @@ -229,120 +332,266 @@ class _OverviewTab extends StatelessWidget { } } -class _TransactionDetailTab extends StatelessWidget { +class _TransactionDetailTab extends StatefulWidget { const _TransactionDetailTab(); @override - Widget build(BuildContext context) { - final transactions = [ - ('售出 ¥25 礼品卡 x5', '+\$106.25', '今天 14:32', AppColors.success), - ('核销结算 ¥100 购物券 x2', '+\$200.00', '今天 12:15', AppColors.success), - ('平台手续费', '-\$3.19', '今天 14:32', AppColors.error), - ('退款 ¥25 礼品卡', '-\$21.25', '今天 10:08', AppColors.warning), - ('售出 ¥50 生活券 x3', '+\$127.50', '昨天 18:45', AppColors.success), - ('提现至银行账户', '-\$5,000.00', '昨天 16:00', AppColors.info), - ]; - - return ListView.separated( - padding: const EdgeInsets.all(20), - itemCount: transactions.length, - separatorBuilder: (_, __) => const Divider(height: 1), - itemBuilder: (context, index) { - final (desc, amount, time, color) = transactions[index]; - return ListTile( - contentPadding: const EdgeInsets.symmetric(vertical: 6), - title: Text(desc, style: const TextStyle(fontSize: 14)), - subtitle: Text(time, style: const TextStyle(fontSize: 12, color: AppColors.textTertiary)), - trailing: Text( - amount, - style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: color), - ), - ); - }, - ); - } + State<_TransactionDetailTab> createState() => _TransactionDetailTabState(); } -class _ReconciliationTab extends StatelessWidget { - const _ReconciliationTab(); +class _TransactionDetailTabState extends State<_TransactionDetailTab> { + final _financeService = IssuerFinanceService(); + + bool _isLoading = true; + String? _error; + List _transactions = []; + + @override + void initState() { + super.initState(); + _loadTransactions(); + } + + Future _loadTransactions() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final result = await _financeService.getTransactions(); + if (!mounted) return; + setState(() { + _transactions = result.items; + _isLoading = false; + }); + } catch (e) { + debugPrint('[FinancePage] loadTransactions error: $e'); + if (!mounted) return; + setState(() { + _isLoading = false; + _error = e.toString(); + }); + } + } @override Widget build(BuildContext context) { - final reports = [ - ('2026年1月对账单', '总收入: \$28,450 | 总支出: \$3,210', '已生成'), - ('2025年12月对账单', '总收入: \$32,100 | 总支出: \$4,080', '已生成'), - ('2025年11月对账单', '总收入: \$25,800 | 总支出: \$2,900', '已生成'), - ]; + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } - return ListView( - padding: const EdgeInsets.all(20), - children: [ - // Generate New - OutlinedButton.icon( - onPressed: () { - // TODO: Trigger reconciliation report generation - }, - icon: const Icon(Icons.add_rounded), - label: Text(context.t('finance_generate_report')), - style: OutlinedButton.styleFrom( - minimumSize: const Size(double.infinity, 48), - ), + if (_error != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 48, color: AppColors.textTertiary), + const SizedBox(height: 16), + Text(_error!, style: const TextStyle(color: AppColors.textSecondary)), + const SizedBox(height: 16), + ElevatedButton(onPressed: _loadTransactions, child: Text(context.t('retry'))), + ], ), - const SizedBox(height: 20), + ); + } - ...reports.map((r) { - final (title, summary, status) = r; - return Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.surface, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.borderLight), + if (_transactions.isEmpty) { + return const Center( + child: Text('暂无交易记录', style: TextStyle(color: AppColors.textSecondary)), + ); + } + + return RefreshIndicator( + onRefresh: _loadTransactions, + child: ListView.separated( + padding: const EdgeInsets.all(20), + itemCount: _transactions.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final tx = _transactions[index]; + final isPositive = tx.amount >= 0; + final color = isPositive ? AppColors.success : AppColors.error; + return ListTile( + contentPadding: const EdgeInsets.symmetric(vertical: 6), + title: Text(tx.description, style: const TextStyle(fontSize: 14)), + subtitle: Text( + _formatTime(tx.createdAt), + style: const TextStyle(fontSize: 12, color: AppColors.textTertiary), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: AppColors.successLight, - borderRadius: BorderRadius.circular(999), - ), - child: Text(status, style: const TextStyle(fontSize: 11, color: AppColors.success)), - ), - ], - ), - const SizedBox(height: 6), - Text(summary, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)), - const SizedBox(height: 12), - Row( - children: [ - TextButton.icon( - onPressed: () { - // TODO: Navigate to reconciliation detail view - }, - icon: const Icon(Icons.visibility_rounded, size: 16), - label: Text(context.t('view')), - ), - TextButton.icon( - onPressed: () { - // TODO: Export reconciliation report - }, - icon: const Icon(Icons.download_rounded, size: 16), - label: Text(context.t('export')), - ), - ], - ), - ], + trailing: Text( + '${isPositive ? '+' : ''}\$${tx.amount.toStringAsFixed(2)}', + style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: color), ), ); - }), - ], + }, + ), + ); + } + + String _formatTime(DateTime dt) { + final now = DateTime.now(); + final isToday = dt.year == now.year && dt.month == now.month && dt.day == now.day; + final timeStr = '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; + if (isToday) return '今天 $timeStr'; + final yesterday = now.subtract(const Duration(days: 1)); + final isYesterday = dt.year == yesterday.year && dt.month == yesterday.month && dt.day == yesterday.day; + if (isYesterday) return '昨天 $timeStr'; + return '${dt.month}/${dt.day} $timeStr'; + } +} + +class _ReconciliationTab extends StatefulWidget { + const _ReconciliationTab(); + + @override + State<_ReconciliationTab> createState() => _ReconciliationTabState(); +} + +class _ReconciliationTabState extends State<_ReconciliationTab> { + final _financeService = IssuerFinanceService(); + + bool _isLoading = true; + String? _error; + Map _reconciliation = {}; + + @override + void initState() { + super.initState(); + _loadReconciliation(); + } + + Future _loadReconciliation() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final data = await _financeService.getReconciliation(); + if (!mounted) return; + setState(() { + _reconciliation = data; + _isLoading = false; + }); + } catch (e) { + debugPrint('[FinancePage] loadReconciliation error: $e'); + if (!mounted) return; + setState(() { + _isLoading = false; + _error = e.toString(); + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (_error != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 48, color: AppColors.textTertiary), + const SizedBox(height: 16), + Text(_error!, style: const TextStyle(color: AppColors.textSecondary)), + const SizedBox(height: 16), + ElevatedButton(onPressed: _loadReconciliation, child: Text(context.t('retry'))), + ], + ), + ); + } + + final reports = (_reconciliation['reports'] as List?)?.cast>() ?? []; + + return RefreshIndicator( + onRefresh: _loadReconciliation, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(20), + children: [ + // Generate New + OutlinedButton.icon( + onPressed: () { + // TODO: Trigger reconciliation report generation + }, + icon: const Icon(Icons.add_rounded), + label: Text(context.t('finance_generate_report')), + style: OutlinedButton.styleFrom( + minimumSize: const Size(double.infinity, 48), + ), + ), + const SizedBox(height: 20), + + if (reports.isEmpty) + const Center( + child: Padding( + padding: EdgeInsets.all(32), + child: Text('暂无对账报表', style: TextStyle(color: AppColors.textSecondary)), + ), + ) + else + ...reports.map((r) { + final title = r['title'] ?? ''; + final summary = r['summary'] ?? ''; + final status = r['status'] ?? '已生成'; + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.borderLight), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: AppColors.successLight, + borderRadius: BorderRadius.circular(999), + ), + child: Text(status, style: const TextStyle(fontSize: 11, color: AppColors.success)), + ), + ], + ), + const SizedBox(height: 6), + Text(summary, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)), + const SizedBox(height: 12), + Row( + children: [ + TextButton.icon( + onPressed: () { + // TODO: Navigate to reconciliation detail view + }, + icon: const Icon(Icons.visibility_rounded, size: 16), + label: Text(context.t('view')), + ), + TextButton.icon( + onPressed: () { + // TODO: Export reconciliation report + }, + icon: const Icon(Icons.download_rounded, size: 16), + label: Text(context.t('export')), + ), + ], + ), + ], + ), + ); + }), + ], + ), ); } } diff --git a/frontend/admin-app/lib/features/redemption/presentation/pages/redemption_page.dart b/frontend/admin-app/lib/features/redemption/presentation/pages/redemption_page.dart index 7dd2647..c663fe9 100644 --- a/frontend/admin-app/lib/features/redemption/presentation/pages/redemption_page.dart +++ b/frontend/admin-app/lib/features/redemption/presentation/pages/redemption_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../../../app/theme/app_colors.dart'; import '../../../../app/i18n/app_localizations.dart'; +import '../../../../core/services/redemption_service.dart'; /// 核销管理页面 /// @@ -33,9 +34,200 @@ class RedemptionPage extends StatelessWidget { } } -class _ScanRedeemTab extends StatelessWidget { +class _ScanRedeemTab extends StatefulWidget { const _ScanRedeemTab(); + @override + State<_ScanRedeemTab> createState() => _ScanRedeemTabState(); +} + +class _ScanRedeemTabState extends State<_ScanRedeemTab> { + final _manualCodeController = TextEditingController(); + final _redemptionService = RedemptionService(); + + bool _isRedeeming = false; + bool _isLoadingStats = true; + TodayRedemptionStats? _todayStats; + + @override + void initState() { + super.initState(); + _loadTodayStats(); + } + + @override + void dispose() { + _manualCodeController.dispose(); + super.dispose(); + } + + Future _loadTodayStats() async { + try { + final stats = await _redemptionService.getTodayStats(); + if (!mounted) return; + setState(() { + _todayStats = stats; + _isLoadingStats = false; + }); + } catch (e) { + debugPrint('[RedemptionPage] loadTodayStats error: $e'); + if (!mounted) return; + setState(() => _isLoadingStats = false); + } + } + + Future _manualRedeem() async { + final code = _manualCodeController.text.trim(); + if (code.isEmpty) return; + + setState(() => _isRedeeming = true); + + try { + final result = await _redemptionService.manual(code); + if (!mounted) return; + setState(() => _isRedeeming = false); + _manualCodeController.clear(); + _loadTodayStats(); + _showRedeemResult(context, result); + } catch (e) { + debugPrint('[RedemptionPage] manualRedeem error: $e'); + if (!mounted) return; + setState(() => _isRedeeming = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('核销失败: $e'), backgroundColor: AppColors.error), + ); + } + } + + void _showRedeemResult(BuildContext context, RedemptionResult result) { + showModalBottomSheet( + context: context, + builder: (ctx) => Container( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + result.status == 'completed' ? Icons.check_circle_rounded : Icons.error_rounded, + color: result.status == 'completed' ? AppColors.success : AppColors.error, + size: 56, + ), + const SizedBox(height: 16), + Text(context.t('redemption_confirm_title'), style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + Text(result.couponName, style: const TextStyle(color: AppColors.textSecondary)), + if (result.amount > 0) ...[ + const SizedBox(height: 4), + Text('\$${result.amount.toStringAsFixed(2)}', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + ], + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => Navigator.pop(ctx), + child: Text(context.t('redemption_confirm_button')), + ), + ), + const SizedBox(height: 12), + ], + ), + ), + ); + } + + Future _showBatchRedeem(BuildContext context) async { + final batchController = TextEditingController(); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (ctx) => DraggableScrollableSheet( + expand: false, + initialChildSize: 0.6, + builder: (_, controller) => StatefulBuilder( + builder: (ctx, setLocalState) { + bool isBatching = false; + + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.t('redemption_batch'), style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + Text(context.t('redemption_batch_desc'), style: const TextStyle(color: AppColors.textSecondary)), + const SizedBox(height: 16), + Expanded( + child: TextField( + controller: batchController, + maxLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + decoration: InputDecoration( + hintText: context.t('redemption_batch_hint'), + border: const OutlineInputBorder(), + ), + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: isBatching + ? null + : () async { + final text = batchController.text.trim(); + if (text.isEmpty) return; + + final codes = text + .split(RegExp(r'[\n,;]+')) + .map((c) => c.trim()) + .where((c) => c.isNotEmpty) + .toList(); + + if (codes.isEmpty) return; + + setLocalState(() => isBatching = true); + + try { + final result = await _redemptionService.batch(codes); + if (!mounted) return; + Navigator.pop(ctx); + _loadTodayStats(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('成功: ${result.successCount}, 失败: ${result.failCount}'), + backgroundColor: result.failCount == 0 ? AppColors.success : AppColors.warning, + ), + ); + } catch (e) { + debugPrint('[RedemptionPage] batch error: $e'); + setLocalState(() => isBatching = false); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('批量核销失败: $e'), backgroundColor: AppColors.error), + ); + } + } + }, + child: isBatching + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : Text(context.t('redemption_batch')), + ), + ), + ], + ), + ); + }, + ), + ), + ); + } + @override Widget build(BuildContext context) { return SingleChildScrollView( @@ -75,6 +267,8 @@ class _ScanRedeemTab extends StatelessWidget { children: [ Expanded( child: TextField( + controller: _manualCodeController, + enabled: !_isRedeeming, decoration: InputDecoration( hintText: context.t('redemption_manual_hint'), prefixIcon: const Icon(Icons.keyboard_rounded), @@ -85,8 +279,14 @@ class _ScanRedeemTab extends StatelessWidget { SizedBox( height: 52, child: ElevatedButton( - onPressed: () => _showRedeemConfirm(context), - child: Text(context.t('redemption_redeem')), + onPressed: _isRedeeming ? null : _manualRedeem, + child: _isRedeeming + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : Text(context.t('redemption_redeem')), ), ), ], @@ -112,19 +312,30 @@ class _ScanRedeemTab extends StatelessWidget { borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.borderLight), ), - child: const Column( + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(context.t('redemption_today_title'), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _StatItem(label: context.t('redemption_today_count'), value: '45'), - _StatItem(label: context.t('redemption_today_amount'), value: '\$1,125'), - _StatItem(label: context.t('redemption_today_stores'), value: '3'), - ], - ), + _isLoadingStats + ? const Center(child: CircularProgressIndicator()) + : Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _StatItem( + label: context.t('redemption_today_count'), + value: '${_todayStats?.count ?? 0}', + ), + _StatItem( + label: context.t('redemption_today_amount'), + value: '\$${_todayStats?.totalAmount.toStringAsFixed(0) ?? '0'}', + ), + _StatItem( + label: context.t('redemption_today_stores'), + value: '--', + ), + ], + ), ], ), ), @@ -132,118 +343,128 @@ class _ScanRedeemTab extends StatelessWidget { ), ); } - - void _showRedeemConfirm(BuildContext context) { - showModalBottomSheet( - context: context, - builder: (ctx) => Container( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.check_circle_rounded, color: AppColors.success, size: 56), - const SizedBox(height: 16), - Text(context.t('redemption_confirm_title'), style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600)), - const SizedBox(height: 8), - const Text('¥25 星巴克礼品卡', style: TextStyle(color: AppColors.textSecondary)), - const SizedBox(height: 24), - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: () => Navigator.pop(ctx), - child: Text(context.t('redemption_confirm_button')), - ), - ), - const SizedBox(height: 12), - ], - ), - ), - ); - } - - void _showBatchRedeem(BuildContext context) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (ctx) => DraggableScrollableSheet( - expand: false, - initialChildSize: 0.6, - builder: (_, controller) => Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(context.t('redemption_batch'), style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600)), - const SizedBox(height: 8), - Text(context.t('redemption_batch_desc'), style: const TextStyle(color: AppColors.textSecondary)), - const SizedBox(height: 16), - Expanded( - child: TextField( - maxLines: null, - expands: true, - textAlignVertical: TextAlignVertical.top, - decoration: InputDecoration( - hintText: context.t('redemption_batch_hint'), - border: OutlineInputBorder(), - ), - ), - ), - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: () => Navigator.pop(ctx), - child: Text(context.t('redemption_batch')), - ), - ), - ], - ), - ), - ), - ); - } } -class _RedeemHistoryTab extends StatelessWidget { +class _RedeemHistoryTab extends StatefulWidget { const _RedeemHistoryTab(); @override - Widget build(BuildContext context) { - final records = [ - ('¥25 礼品卡', '门店A · 收银员张三', '10分钟前', true), - ('¥100 购物券', '门店B · 收银员李四', '25分钟前', true), - ('¥50 生活券', '手动输入', '1小时前', true), - ('¥25 礼品卡', '门店A · 扫码', '2小时前', false), - ]; + State<_RedeemHistoryTab> createState() => _RedeemHistoryTabState(); +} - return ListView.separated( - padding: const EdgeInsets.all(20), - itemCount: records.length, - separatorBuilder: (_, __) => const Divider(height: 1), - itemBuilder: (context, index) { - final (name, source, time, success) = records[index]; - return ListTile( - contentPadding: const EdgeInsets.symmetric(vertical: 8), - leading: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: success ? AppColors.successLight : AppColors.errorLight, - borderRadius: BorderRadius.circular(10), +class _RedeemHistoryTabState extends State<_RedeemHistoryTab> { + final _redemptionService = RedemptionService(); + + bool _isLoading = true; + String? _error; + List _records = []; + + @override + void initState() { + super.initState(); + _loadHistory(); + } + + Future _loadHistory() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final result = await _redemptionService.getHistory(); + if (!mounted) return; + setState(() { + _records = result.items; + _isLoading = false; + }); + } catch (e) { + debugPrint('[RedemptionPage] loadHistory error: $e'); + if (!mounted) return; + setState(() { + _isLoading = false; + _error = e.toString(); + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (_error != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 48, color: AppColors.textTertiary), + const SizedBox(height: 16), + Text(_error!, style: const TextStyle(color: AppColors.textSecondary)), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadHistory, + child: Text(context.t('retry')), ), - child: Icon( - success ? Icons.check_rounded : Icons.close_rounded, - color: success ? AppColors.success : AppColors.error, - size: 20, + ], + ), + ); + } + + if (_records.isEmpty) { + return const Center( + child: Text('暂无核销记录', style: TextStyle(color: AppColors.textSecondary)), + ); + } + + return RefreshIndicator( + onRefresh: _loadHistory, + child: ListView.separated( + padding: const EdgeInsets.all(20), + itemCount: _records.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final record = _records[index]; + final success = record.status == 'completed'; + return ListTile( + contentPadding: const EdgeInsets.symmetric(vertical: 8), + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: success ? AppColors.successLight : AppColors.errorLight, + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + success ? Icons.check_rounded : Icons.close_rounded, + color: success ? AppColors.success : AppColors.error, + size: 20, + ), ), - ), - title: Text(name, style: const TextStyle(fontWeight: FontWeight.w500)), - subtitle: Text(source, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)), - trailing: Text(time, style: const TextStyle(fontSize: 11, color: AppColors.textTertiary)), - ); - }, + title: Text(record.couponName, style: const TextStyle(fontWeight: FontWeight.w500)), + subtitle: Text( + '${record.method} · \$${record.amount.toStringAsFixed(2)}', + style: const TextStyle(fontSize: 12, color: AppColors.textSecondary), + ), + trailing: Text( + _formatTime(record.createdAt), + style: const TextStyle(fontSize: 11, color: AppColors.textTertiary), + ), + ); + }, + ), ); } + + String _formatTime(DateTime dt) { + final now = DateTime.now(); + final diff = now.difference(dt); + if (diff.inMinutes < 60) return '${diff.inMinutes}分钟前'; + if (diff.inHours < 24) return '${diff.inHours}小时前'; + if (diff.inDays < 7) return '${diff.inDays}天前'; + return '${dt.month}/${dt.day}'; + } } class _StatItem extends StatelessWidget { diff --git a/frontend/admin-app/lib/features/settings/presentation/pages/settings_page.dart b/frontend/admin-app/lib/features/settings/presentation/pages/settings_page.dart index 96500d0..46afe23 100644 --- a/frontend/admin-app/lib/features/settings/presentation/pages/settings_page.dart +++ b/frontend/admin-app/lib/features/settings/presentation/pages/settings_page.dart @@ -4,6 +4,9 @@ import '../../../../app/theme/app_colors.dart'; import '../../../../app/router.dart'; import '../../../../app/i18n/app_localizations.dart'; import '../../../../core/updater/update_service.dart'; +import '../../../../core/services/issuer_service.dart'; +import '../../../../core/services/auth_service.dart'; +import '../../../../core/network/api_client.dart'; /// 发行方设置页面(我的) /// @@ -17,11 +20,18 @@ class SettingsPage extends StatefulWidget { class _SettingsPageState extends State { String _appVersion = ''; + bool _isLoadingProfile = true; + IssuerProfile? _profile; + bool _isLoggingOut = false; + + final _issuerService = IssuerService(); + final _authService = AuthService(); @override void initState() { super.initState(); _loadVersion(); + _loadProfile(); } Future _loadVersion() async { @@ -31,6 +41,54 @@ class _SettingsPageState extends State { } } + Future _loadProfile() async { + try { + final profile = await _issuerService.getProfile(); + if (!mounted) return; + setState(() { + _profile = profile; + _isLoadingProfile = false; + }); + } catch (e) { + debugPrint('[SettingsPage] loadProfile error: $e'); + if (!mounted) return; + setState(() => _isLoadingProfile = false); + } + } + + Future _handleLogout() async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('确认退出'), + content: const Text('确定要退出登录吗?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(context.t('cancel'))), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + style: ElevatedButton.styleFrom(backgroundColor: AppColors.error), + child: Text(context.t('confirm')), + ), + ], + ), + ); + + if (confirmed != true) return; + + setState(() => _isLoggingOut = true); + + try { + await _authService.logout(); + } catch (e) { + debugPrint('[SettingsPage] logout error: $e'); + } + + ApiClient.instance.setToken(null); + + if (!mounted) return; + Navigator.pushNamedAndRemoveUntil(context, AppRouter.login, (_) => false); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -97,14 +155,18 @@ class _SettingsPageState extends State { child: SizedBox( width: double.infinity, child: OutlinedButton( - onPressed: () { - Navigator.pushNamedAndRemoveUntil(context, AppRouter.login, (_) => false); - }, + onPressed: _isLoggingOut ? null : _handleLogout, style: OutlinedButton.styleFrom( foregroundColor: AppColors.error, side: const BorderSide(color: AppColors.error), ), - child: Text(context.t('settings_logout')), + child: _isLoggingOut + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(context.t('settings_logout')), ), ), ), @@ -120,24 +182,65 @@ class _SettingsPageState extends State { color: AppColors.surface, child: Row( children: [ - ClipRRect( - borderRadius: BorderRadius.circular(14), - child: Image.asset( - 'assets/images/logo_icon.png', - width: 56, - height: 56, - ), - ), + _profile?.logoUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(14), + child: Image.network( + _profile!.logoUrl!, + width: 56, + height: 56, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => ClipRRect( + borderRadius: BorderRadius.circular(14), + child: Image.asset('assets/images/logo_icon.png', width: 56, height: 56), + ), + ), + ) + : ClipRRect( + borderRadius: BorderRadius.circular(14), + child: Image.asset( + 'assets/images/logo_icon.png', + width: 56, + height: 56, + ), + ), const SizedBox(width: 14), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Starbucks China', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)), - const SizedBox(height: 4), - Text('${context.t('settings_admin')}:张经理', style: const TextStyle(fontSize: 13, color: AppColors.textSecondary)), - ], - ), + child: _isLoadingProfile + ? const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 120, + height: 16, + child: LinearProgressIndicator(), + ), + SizedBox(height: 8), + SizedBox( + width: 80, + height: 12, + child: LinearProgressIndicator(), + ), + ], + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _profile?.companyName ?? '--', + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 4), + Text( + _profile?.contactEmail != null + ? '${context.t('settings_admin')}:${_profile!.contactEmail}' + : _profile?.contactPhone != null + ? '${context.t('settings_admin')}:${_profile!.contactPhone}' + : context.t('settings_admin'), + style: const TextStyle(fontSize: 13, color: AppColors.textSecondary), + ), + ], + ), ), const Icon(Icons.chevron_right_rounded, color: AppColors.textTertiary), ], @@ -163,7 +266,12 @@ class _SettingsPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(context.t('settings_gold_issuer'), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: AppColors.tierGold)), + Text( + _profile?.creditRating != null + ? '${_profile!.creditRating} ${context.t('settings_gold_issuer')}' + : context.t('settings_gold_issuer'), + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: AppColors.tierGold), + ), const Text('手续费率 1.2% · 高级数据分析', style: TextStyle(fontSize: 12, color: AppColors.textSecondary)), ], ), diff --git a/frontend/admin-app/lib/features/store_management/presentation/pages/store_management_page.dart b/frontend/admin-app/lib/features/store_management/presentation/pages/store_management_page.dart index b014bcb..5e92877 100644 --- a/frontend/admin-app/lib/features/store_management/presentation/pages/store_management_page.dart +++ b/frontend/admin-app/lib/features/store_management/presentation/pages/store_management_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../../../app/theme/app_colors.dart'; import '../../../../app/i18n/app_localizations.dart'; +import '../../../../core/services/issuer_store_service.dart'; /// 多门店管理页面 /// @@ -41,80 +42,144 @@ class StoreManagementPage extends StatelessWidget { } } -class _StoreListTab extends StatelessWidget { +class _StoreListTab extends StatefulWidget { const _StoreListTab(); @override - Widget build(BuildContext context) { - final stores = [ - _Store('总部', 'headquarters', '上海市黄浦区', 15, true), - _Store('华东区', 'regional', '上海/杭州/南京', 8, true), - _Store('徐汇门店', 'store', '上海市徐汇区xxx路', 3, true), - _Store('静安门店', 'store', '上海市静安区xxx路', 2, true), - _Store('杭州西湖店', 'store', '杭州市西湖区xxx路', 2, false), - ]; + State<_StoreListTab> createState() => _StoreListTabState(); +} - return ListView.builder( - padding: const EdgeInsets.all(20), - itemCount: stores.length, - itemBuilder: (context, index) { - final store = stores[index]; - return Container( - margin: EdgeInsets.only( - left: store.level == 'regional' ? 20 : store.level == 'store' ? 40 : 0, - bottom: 10, - ), - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: AppColors.surface, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.borderLight), - ), - child: Row( - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: _levelColor(store.level).withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(10), +class _StoreListTabState extends State<_StoreListTab> { + final _storeService = IssuerStoreService(); + + bool _isLoading = true; + String? _error; + List _stores = []; + + @override + void initState() { + super.initState(); + _loadStores(); + } + + Future _loadStores() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final stores = await _storeService.listStores(); + if (!mounted) return; + setState(() { + _stores = stores; + _isLoading = false; + }); + } catch (e) { + debugPrint('[StoreManagementPage] loadStores error: $e'); + if (!mounted) return; + setState(() { + _isLoading = false; + _error = e.toString(); + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (_error != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 48, color: AppColors.textTertiary), + const SizedBox(height: 16), + Text(_error!, style: const TextStyle(color: AppColors.textSecondary)), + const SizedBox(height: 16), + ElevatedButton(onPressed: _loadStores, child: Text(context.t('retry'))), + ], + ), + ); + } + + if (_stores.isEmpty) { + return const Center( + child: Text('暂无门店', style: TextStyle(color: AppColors.textSecondary)), + ); + } + + return RefreshIndicator( + onRefresh: _loadStores, + child: ListView.builder( + padding: const EdgeInsets.all(20), + itemCount: _stores.length, + itemBuilder: (context, index) { + final store = _stores[index]; + return Container( + margin: EdgeInsets.only( + left: store.level == 'regional' ? 20 : store.level == 'store' ? 40 : 0, + bottom: 10, + ), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.borderLight), + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: _levelColor(store.level).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(_levelIcon(store.level), color: _levelColor(store.level), size: 20), ), - child: Icon(_levelIcon(store.level), color: _levelColor(store.level), size: 20), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text(store.name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), - decoration: BoxDecoration( - color: store.isActive ? AppColors.successLight : AppColors.gray100, - borderRadius: BorderRadius.circular(999), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(store.name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: store.status == 'active' ? AppColors.successLight : AppColors.gray100, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + store.status == 'active' ? context.t('store_status_open') : context.t('store_status_closed'), + style: TextStyle( + fontSize: 10, + color: store.status == 'active' ? AppColors.success : AppColors.textTertiary, + ), + ), ), - child: Text( - store.isActive ? context.t('store_status_open') : context.t('store_status_closed'), - style: TextStyle(fontSize: 10, color: store.isActive ? AppColors.success : AppColors.textTertiary), - ), - ), - ], - ), - const SizedBox(height: 2), - Text(store.address, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)), - ], + ], + ), + const SizedBox(height: 2), + Text( + store.address ?? store.phone ?? '--', + style: const TextStyle(fontSize: 12, color: AppColors.textSecondary), + ), + ], + ), ), - ), - Text('${store.staffCount}${context.t('store_people_unit')}', style: const TextStyle(fontSize: 12, color: AppColors.textTertiary)), - const SizedBox(width: 4), - const Icon(Icons.chevron_right_rounded, size: 18, color: AppColors.textTertiary), - ], - ), - ); - }, + const Icon(Icons.chevron_right_rounded, size: 18, color: AppColors.textTertiary), + ], + ), + ); + }, + ), ); } @@ -135,57 +200,131 @@ class _StoreListTab extends StatelessWidget { } } -class _EmployeeListTab extends StatelessWidget { +class _EmployeeListTab extends StatefulWidget { const _EmployeeListTab(); @override - Widget build(BuildContext context) { - final employees = [ - ('张经理', '管理员', '总部', Icons.admin_panel_settings_rounded, AppColors.primary), - ('李店长', '店长', '徐汇门店', Icons.manage_accounts_rounded, AppColors.info), - ('王店长', '店长', '静安门店', Icons.manage_accounts_rounded, AppColors.info), - ('赵收银', '收银员', '徐汇门店', Icons.point_of_sale_rounded, AppColors.success), - ('钱收银', '收银员', '徐汇门店', Icons.point_of_sale_rounded, AppColors.success), - ('孙收银', '收银员', '静安门店', Icons.point_of_sale_rounded, AppColors.success), - ]; + State<_EmployeeListTab> createState() => _EmployeeListTabState(); +} - return ListView.separated( - padding: const EdgeInsets.all(20), - itemCount: employees.length, - separatorBuilder: (_, __) => const Divider(height: 1), - itemBuilder: (context, index) { - final (name, role, store, icon, color) = employees[index]; - return ListTile( - contentPadding: const EdgeInsets.symmetric(vertical: 4), - leading: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(10), +class _EmployeeListTabState extends State<_EmployeeListTab> { + final _storeService = IssuerStoreService(); + + bool _isLoading = true; + String? _error; + List _employees = []; + + @override + void initState() { + super.initState(); + _loadEmployees(); + } + + Future _loadEmployees() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final employees = await _storeService.listEmployees(); + if (!mounted) return; + setState(() { + _employees = employees; + _isLoading = false; + }); + } catch (e) { + debugPrint('[StoreManagementPage] loadEmployees error: $e'); + if (!mounted) return; + setState(() { + _isLoading = false; + _error = e.toString(); + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (_error != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 48, color: AppColors.textTertiary), + const SizedBox(height: 16), + Text(_error!, style: const TextStyle(color: AppColors.textSecondary)), + const SizedBox(height: 16), + ElevatedButton(onPressed: _loadEmployees, child: Text(context.t('retry'))), + ], + ), + ); + } + + if (_employees.isEmpty) { + return const Center( + child: Text('暂无员工', style: TextStyle(color: AppColors.textSecondary)), + ); + } + + return RefreshIndicator( + onRefresh: _loadEmployees, + child: ListView.separated( + padding: const EdgeInsets.all(20), + itemCount: _employees.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final emp = _employees[index]; + final (icon, color) = _roleIconColor(emp.role); + return ListTile( + contentPadding: const EdgeInsets.symmetric(vertical: 4), + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, color: color, size: 20), ), - child: Icon(icon, color: color, size: 20), - ), - title: Text(name, style: const TextStyle(fontWeight: FontWeight.w500)), - subtitle: Text('$role · $store', style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)), - trailing: PopupMenuButton( - itemBuilder: (ctx) => [ - PopupMenuItem(value: 'edit', child: Text(context.t('store_emp_edit'))), - PopupMenuItem(value: 'delete', child: Text(context.t('store_emp_remove'))), - ], - ), - ); - }, + title: Text(emp.name, style: const TextStyle(fontWeight: FontWeight.w500)), + subtitle: Text( + '${emp.role} · ${emp.storeId ?? '--'}', + style: const TextStyle(fontSize: 12, color: AppColors.textSecondary), + ), + trailing: PopupMenuButton( + onSelected: (value) async { + if (value == 'delete') { + try { + await _storeService.deleteEmployee(emp.id); + if (mounted) _loadEmployees(); + } catch (e) { + debugPrint('[StoreManagementPage] deleteEmployee error: $e'); + } + } + }, + itemBuilder: (ctx) => [ + PopupMenuItem(value: 'edit', child: Text(context.t('store_emp_edit'))), + PopupMenuItem(value: 'delete', child: Text(context.t('store_emp_remove'))), + ], + ), + ); + }, + ), ); } -} -class _Store { - final String name; - final String level; - final String address; - final int staffCount; - final bool isActive; - - _Store(this.name, this.level, this.address, this.staffCount, this.isActive); + (IconData, Color) _roleIconColor(String role) { + switch (role) { + case 'admin': + return (Icons.admin_panel_settings_rounded, AppColors.primary); + case 'manager': + return (Icons.manage_accounts_rounded, AppColors.info); + default: + return (Icons.point_of_sale_rounded, AppColors.success); + } + } } diff --git a/frontend/admin-web/src/app/providers.tsx b/frontend/admin-web/src/app/providers.tsx index 792b2ab..77bf7b4 100644 --- a/frontend/admin-web/src/app/providers.tsx +++ b/frontend/admin-web/src/app/providers.tsx @@ -1,16 +1,26 @@ 'use client'; -import React from 'react'; +import React, { useState } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { AuthProvider } from '@/lib/auth-context'; -/** - * 全局 Providers 包装 - * - * 后续在此添加: - * - Redux Provider (RTK) - * - React Query Provider - * - Theme Provider - * - Auth Provider - */ export function Providers({ children }: { children: React.ReactNode }) { - return <>{children}; + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + retry: 1, + refetchOnWindowFocus: false, + }, + }, + }), + ); + + return ( + + {children} + + ); } diff --git a/frontend/admin-web/src/lib/api-client.ts b/frontend/admin-web/src/lib/api-client.ts new file mode 100644 index 0000000..9731cbb --- /dev/null +++ b/frontend/admin-web/src/lib/api-client.ts @@ -0,0 +1,64 @@ +import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080'; + +class ApiClient { + private client: AxiosInstance; + + constructor() { + this.client = axios.create({ + baseURL: API_BASE_URL, + timeout: 30000, + headers: { 'Content-Type': 'application/json' }, + }); + + this.client.interceptors.request.use((config) => { + if (typeof window !== 'undefined') { + const token = localStorage.getItem('admin_token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + } + return config; + }); + + this.client.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401 && typeof window !== 'undefined') { + localStorage.removeItem('admin_token'); + localStorage.removeItem('admin_user'); + window.location.href = '/login'; + } + return Promise.reject(error); + }, + ); + } + + async get(url: string, config?: AxiosRequestConfig): Promise { + const response: AxiosResponse = await this.client.get(url, config); + return response.data?.data ?? response.data; + } + + async post(url: string, data?: unknown, config?: AxiosRequestConfig): Promise { + const response: AxiosResponse = await this.client.post(url, data, config); + return response.data?.data ?? response.data; + } + + async put(url: string, data?: unknown, config?: AxiosRequestConfig): Promise { + const response: AxiosResponse = await this.client.put(url, data, config); + return response.data?.data ?? response.data; + } + + async patch(url: string, data?: unknown, config?: AxiosRequestConfig): Promise { + const response: AxiosResponse = await this.client.patch(url, data, config); + return response.data?.data ?? response.data; + } + + async delete(url: string, config?: AxiosRequestConfig): Promise { + const response: AxiosResponse = await this.client.delete(url, config); + return response.data?.data ?? response.data; + } +} + +export const apiClient = new ApiClient(); diff --git a/frontend/admin-web/src/lib/auth-context.tsx b/frontend/admin-web/src/lib/auth-context.tsx new file mode 100644 index 0000000..add5ab5 --- /dev/null +++ b/frontend/admin-web/src/lib/auth-context.tsx @@ -0,0 +1,85 @@ +'use client'; + +import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; +import { apiClient } from './api-client'; + +interface AdminUser { + id: string; + email: string; + name: string; + role: string; +} + +interface AuthContextType { + user: AdminUser | null; + token: string | null; + isAuthenticated: boolean; + isLoading: boolean; + login: (email: string, password: string) => Promise; + logout: () => void; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null); + const [token, setToken] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const savedToken = localStorage.getItem('admin_token'); + const savedUser = localStorage.getItem('admin_user'); + if (savedToken && savedUser) { + setToken(savedToken); + try { + setUser(JSON.parse(savedUser)); + } catch { + localStorage.removeItem('admin_user'); + } + } + setIsLoading(false); + }, []); + + const login = useCallback(async (email: string, password: string) => { + const result = await apiClient.post<{ accessToken: string; user: AdminUser }>( + '/api/v1/auth/login', + { email, password }, + ); + const { accessToken, user: adminUser } = result; + localStorage.setItem('admin_token', accessToken); + localStorage.setItem('admin_user', JSON.stringify(adminUser)); + setToken(accessToken); + setUser(adminUser); + }, []); + + const logout = useCallback(() => { + localStorage.removeItem('admin_token'); + localStorage.removeItem('admin_user'); + setToken(null); + setUser(null); + apiClient.post('/api/v1/auth/logout').catch(() => {}); + }, []); + + return ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within AuthProvider'); + } + return context; +} diff --git a/frontend/admin-web/src/lib/use-api.ts b/frontend/admin-web/src/lib/use-api.ts new file mode 100644 index 0000000..817e9a8 --- /dev/null +++ b/frontend/admin-web/src/lib/use-api.ts @@ -0,0 +1,65 @@ +'use client'; + +import { useQuery, useMutation, useQueryClient, UseQueryOptions } from '@tanstack/react-query'; +import { apiClient } from './api-client'; + +/** + * GET 请求 hook — 基于 @tanstack/react-query + * + * @example + * const { data, isLoading, error } = useApi('/api/v1/admin/dashboard/stats'); + */ +export function useApi( + url: string | null, + options?: Omit, 'queryKey' | 'queryFn'> & { + params?: Record; + }, +) { + const { params, ...queryOptions } = options ?? {}; + + return useQuery({ + queryKey: [url, params], + queryFn: async () => { + if (!url) throw new Error('URL is required'); + return apiClient.get(url, { params }); + }, + enabled: !!url, + staleTime: 30_000, + ...queryOptions, + }); +} + +/** + * POST/PUT/DELETE mutation hook + * + * @example + * const { mutateAsync } = useApiMutation('POST', '/api/v1/admin/coupons/123/approve'); + * await mutateAsync({ reason: 'Approved' }); + */ +export function useApiMutation( + method: 'POST' | 'PUT' | 'PATCH' | 'DELETE', + url: string, + options?: { invalidateKeys?: string[] }, +) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data?: unknown) => { + switch (method) { + case 'POST': + return apiClient.post(url, data); + case 'PUT': + return apiClient.put(url, data); + case 'PATCH': + return apiClient.patch(url, data); + case 'DELETE': + return apiClient.delete(url); + } + }, + onSuccess: () => { + options?.invalidateKeys?.forEach((key) => { + queryClient.invalidateQueries({ queryKey: [key] }); + }); + }, + }); +} diff --git a/frontend/admin-web/src/views/agent/AgentPanelPage.tsx b/frontend/admin-web/src/views/agent/AgentPanelPage.tsx index 5bbed3a..36c0328 100644 --- a/frontend/admin-web/src/views/agent/AgentPanelPage.tsx +++ b/frontend/admin-web/src/views/agent/AgentPanelPage.tsx @@ -1,5 +1,8 @@ +'use client'; + import React from 'react'; import { t } from '@/i18n/locales'; +import { useApi } from '@/lib/use-api'; /** * D7. AI Agent管理面板 - 平台管理员的AI Agent运营监控 @@ -7,31 +10,27 @@ import { t } from '@/i18n/locales'; * Agent会话统计、常见问题Top10、响应质量监控、模型配置 */ -const agentStats = [ - { label: t('agent_today_sessions'), value: '3,456', change: '+18%', color: 'var(--color-primary)' }, - { label: t('agent_avg_response'), value: '1.2s', change: '-0.3s', color: 'var(--color-success)' }, - { label: t('agent_satisfaction'), value: '94.5%', change: '+2.1%', color: 'var(--color-info)' }, - { label: t('agent_human_takeover'), value: '3.2%', change: '-0.5%', color: 'var(--color-warning)' }, -]; +interface AgentPanelData { + stats: { label: string; value: string; change: string; color: string }[]; + topQuestions: { question: string; count: number; category: string }[]; + modules: { name: string; status: string; accuracy: string; desc: string }[]; +} -const topQuestions = [ - { question: '如何购买券?', count: 234, category: '使用指引' }, - { question: '推荐高折扣券', count: 189, category: '智能推券' }, - { question: '我的券快过期了', count: 156, category: '到期管理' }, - { question: '如何出售我的券?', count: 134, category: '交易指引' }, - { question: '退款怎么操作?', count: 98, category: '售后服务' }, -]; - -const agentModules = [ - { name: '智能推券', status: 'active', accuracy: '92%', desc: '根据用户画像推荐券' }, - { name: '比价分析', status: 'active', accuracy: '96%', desc: '三因子定价模型分析' }, - { name: '投资教育', status: 'active', accuracy: '89%', desc: '券投资知识科普' }, - { name: '客服对话', status: 'active', accuracy: '91%', desc: '常见问题自动应答' }, - { name: '发行方助手', status: 'active', accuracy: '94%', desc: '发券建议/定价优化' }, - { name: '风险预警', status: 'beta', accuracy: '87%', desc: '异常交易智能预警' }, -]; +const loadingBox: React.CSSProperties = { + display: 'flex', alignItems: 'center', justifyContent: 'center', + padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)', +}; export const AgentPanelPage: React.FC = () => { + const { data, isLoading, error } = useApi('/api/v1/ai/admin/agent/stats'); + + if (error) return
Error: {error.message}
; + if (isLoading) return
Loading...
; + + const agentStats = data?.stats ?? []; + const topQuestions = data?.topQuestions ?? []; + const agentModules = data?.modules ?? []; + return (

{t('agent_title')}

diff --git a/frontend/admin-web/src/views/analytics/ConsumerProtectionPage.tsx b/frontend/admin-web/src/views/analytics/ConsumerProtectionPage.tsx index 5d4c144..82fcbd1 100644 --- a/frontend/admin-web/src/views/analytics/ConsumerProtectionPage.tsx +++ b/frontend/admin-web/src/views/analytics/ConsumerProtectionPage.tsx @@ -1,5 +1,8 @@ +'use client'; + import React from 'react'; import { t } from '@/i18n/locales'; +import { useApi } from '@/lib/use-api'; /** * 消费者保护分析仪表盘 @@ -7,61 +10,67 @@ import { t } from '@/i18n/locales'; * 投诉统计、分类分析、满意度趋势、保障基金、退款合规 */ -const stats = [ - { label: t('cp_total_complaints'), value: '234', change: '-5.2%', trend: 'down' as const, color: 'var(--color-error)' }, - { label: t('cp_resolved'), value: '198', change: '+12.3%', trend: 'up' as const, color: 'var(--color-success)' }, - { label: t('cp_processing'), value: '28', change: '-8.1%', trend: 'down' as const, color: 'var(--color-warning)' }, - { label: t('cp_avg_resolution_time'), value: '2.3d', change: '-0.4d', trend: 'down' as const, color: 'var(--color-info)' }, -]; +interface ConsumerProtectionData { + stats: { label: string; value: string; change: string; trend: 'up' | 'down'; color: string; invertTrend?: boolean }[]; + complaintCategories: { name: string; count: number; percent: number; color: string }[]; + csatTrend: { month: string; score: number }[]; + recentComplaints: { id: string; severity: string; category: string; title: string; status: string; assignee: string; created: string }[]; + nonCompliantIssuers: { rank: number; issuer: string; violations: number; refundRate: string; avgDelay: string; riskLevel: string }[]; +} -const complaintCategories = [ - { name: t('cp_cat_redeem_fail'), count: 82, percent: 35, color: 'var(--color-error)' }, - { name: t('cp_cat_refund_dispute'), count: 68, percent: 29, color: 'var(--color-warning)' }, - { name: t('cp_cat_fake_coupon'), count: 49, percent: 21, color: 'var(--color-primary)' }, - { name: t('cp_cat_other'), count: 35, percent: 15, color: 'var(--color-gray-400)' }, -]; - -const csatTrend = [ - { month: '9月', score: 4.1 }, - { month: '10月', score: 4.2 }, - { month: '11月', score: 4.0 }, - { month: '12月', score: 4.3 }, - { month: '1月', score: 4.4 }, - { month: '2月', score: 4.5 }, -]; - -const recentComplaints = [ - { id: 'CMP-0234', severity: t('severity_high'), category: t('cp_cat_fake_coupon'), title: 'Brand denies issuing this coupon', status: t('cp_processing'), assignee: 'Zhang Ming', created: '2026-02-10' }, - { id: 'CMP-0233', severity: t('severity_high'), category: t('cp_cat_refund_dispute'), title: 'Merchant refused service after redemption', status: t('cp_processing'), assignee: 'Li Hua', created: '2026-02-10' }, - { id: 'CMP-0232', severity: t('severity_medium'), category: t('cp_cat_redeem_fail'), title: 'QR code scan no response - store system failure', status: t('cp_resolved'), assignee: 'Wang Fang', created: '2026-02-09' }, - { id: 'CMP-0231', severity: t('severity_low'), category: t('cp_cat_other'), title: 'Coupon info mismatch with actual service', status: t('cp_resolved'), assignee: 'Zhao Li', created: '2026-02-09' }, - { id: 'CMP-0230', severity: t('severity_high'), category: t('cp_cat_refund_dispute'), title: 'Expired coupon refund denied, user claims no reminder', status: t('cp_processing'), assignee: 'Zhang Ming', created: '2026-02-09' }, - { id: 'CMP-0229', severity: t('severity_medium'), category: t('cp_cat_redeem_fail'), title: 'Cross-region redemption failed, no restriction noted', status: t('cp_resolved'), assignee: 'Li Hua', created: '2026-02-08' }, - { id: 'CMP-0228', severity: t('severity_low'), category: t('cp_cat_other'), title: 'Gifted coupon not received by recipient', status: t('cp_resolved'), assignee: 'Wang Fang', created: '2026-02-08' }, - { id: 'CMP-0227', severity: t('severity_medium'), category: t('cp_cat_fake_coupon'), title: 'Discount amount differs from advertisement', status: t('cp_processing'), assignee: 'Zhao Li', created: '2026-02-07' }, -]; - -const severityConfig: Record = { - [t('severity_high')]: { bg: 'var(--color-error-light)', color: 'var(--color-error)' }, - [t('severity_medium')]: { bg: 'var(--color-warning-light)', color: 'var(--color-warning)' }, - [t('severity_low')]: { bg: 'var(--color-info-light)', color: 'var(--color-info)' }, +const loadingBox: React.CSSProperties = { + display: 'flex', alignItems: 'center', justifyContent: 'center', + padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)', }; -const statusConfig: Record = { - [t('cp_processing')]: { bg: 'var(--color-warning-light)', color: 'var(--color-warning)' }, - [t('cp_resolved')]: { bg: 'var(--color-success-light)', color: 'var(--color-success)' }, - [t('completed')]: { bg: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' }, +const getSeverityStyle = (severity: string) => { + switch (severity) { + case 'high': return { bg: 'var(--color-error-light)', color: 'var(--color-error)' }; + case 'medium': return { bg: 'var(--color-warning-light)', color: 'var(--color-warning)' }; + case 'low': return { bg: 'var(--color-info-light)', color: 'var(--color-info)' }; + default: return { bg: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' }; + } }; -const nonCompliantIssuers = [ - { rank: 1, issuer: 'Happy Life E-commerce', violations: 12, refundRate: '45%', avgDelay: '5.2d', riskLevel: t('severity_high') }, - { rank: 2, issuer: 'Premium Travel Services', violations: 9, refundRate: '52%', avgDelay: '4.8d', riskLevel: t('severity_high') }, - { rank: 3, issuer: 'Star Digital Official', violations: 7, refundRate: '61%', avgDelay: '3.5d', riskLevel: t('severity_medium') }, - { rank: 4, issuer: 'Gourmet Restaurant Group', violations: 5, refundRate: '68%', avgDelay: '2.9d', riskLevel: t('severity_medium') }, - { rank: 5, issuer: 'Joy Entertainment Media', violations: 4, refundRate: '72%', avgDelay: '2.1d', riskLevel: t('severity_low') }, -]; +const getSeverityLabel = (severity: string) => { + const map: Record string> = { + high: () => t('severity_high'), + medium: () => t('severity_medium'), + low: () => t('severity_low'), + }; + return map[severity]?.() ?? severity; +}; + +const getComplaintStatusStyle = (status: string) => { + switch (status) { + case 'processing': return { bg: 'var(--color-warning-light)', color: 'var(--color-warning)' }; + case 'resolved': return { bg: 'var(--color-success-light)', color: 'var(--color-success)' }; + case 'completed': return { bg: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' }; + default: return { bg: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' }; + } +}; + +const getComplaintStatusLabel = (status: string) => { + const map: Record string> = { + processing: () => t('cp_processing'), + resolved: () => t('cp_resolved'), + completed: () => t('completed'), + }; + return map[status]?.() ?? status; +}; export const ConsumerProtectionPage: React.FC = () => { + const { data, isLoading, error } = useApi('/api/v1/admin/insurance/stats'); + + if (error) return
Error: {error.message}
; + if (isLoading) return
Loading...
; + + const stats = data?.stats ?? []; + const complaintCategories = data?.complaintCategories ?? []; + const csatTrend = data?.csatTrend ?? []; + const recentComplaints = data?.recentComplaints ?? []; + const nonCompliantIssuers = data?.nonCompliantIssuers ?? []; + return (
@@ -89,7 +98,7 @@ export const ConsumerProtectionPage: React.FC = () => {
{ }}>
{t('cp_csat')}
- 4.5 + + {csatTrend.length > 0 ? csatTrend[csatTrend.length - 1].score : '-'} + /5.0 - +0.1 + {csatTrend.length >= 2 && ( + + {(csatTrend[csatTrend.length - 1].score - csatTrend[csatTrend.length - 2].score) >= 0 ? '+' : ''} + {(csatTrend[csatTrend.length - 1].score - csatTrend[csatTrend.length - 2].score).toFixed(1)} + + )}
{csatTrend.map(item => ( @@ -193,7 +209,7 @@ export const ConsumerProtectionPage: React.FC = () => { justifyContent: 'center', color: 'var(--color-text-tertiary)', }}> - Recharts 仪表盘图 (基金池 $520K / 已用 $78K / 使用率 15%) + Recharts 仪表盘图 (基金池 / 已用 / 使用率)
@@ -235,8 +251,8 @@ export const ConsumerProtectionPage: React.FC = () => { {recentComplaints.map(row => { - const sev = severityConfig[row.severity]; - const st = statusConfig[row.status]; + const sev = getSeverityStyle(row.severity); + const st = getComplaintStatusStyle(row.status); return ( @@ -249,7 +265,7 @@ export const ConsumerProtectionPage: React.FC = () => { background: sev.bg, color: sev.color, font: 'var(--text-caption)', - }}>{row.severity} + }}>{getSeverityLabel(row.severity)} {row.category} {row.title} @@ -260,7 +276,7 @@ export const ConsumerProtectionPage: React.FC = () => { background: st.bg, color: st.color, font: 'var(--text-caption)', - }}>{row.status} + }}>{getComplaintStatusLabel(row.status)} {row.assignee} {row.created} @@ -296,7 +312,7 @@ export const ConsumerProtectionPage: React.FC = () => { {nonCompliantIssuers.map(row => { - const risk = severityConfig[row.riskLevel]; + const risk = getSeverityStyle(row.riskLevel); return ( { background: risk.bg, color: risk.color, font: 'var(--text-caption)', - }}>{row.riskLevel} + }}>{getSeverityLabel(row.riskLevel)} diff --git a/frontend/admin-web/src/views/analytics/CouponAnalyticsPage.tsx b/frontend/admin-web/src/views/analytics/CouponAnalyticsPage.tsx index 172a23a..83e3dc1 100644 --- a/frontend/admin-web/src/views/analytics/CouponAnalyticsPage.tsx +++ b/frontend/admin-web/src/views/analytics/CouponAnalyticsPage.tsx @@ -1,5 +1,8 @@ +'use client'; + import React from 'react'; import { t } from '@/i18n/locales'; +import { useApi } from '@/lib/use-api'; /** * 券分析仪表盘 @@ -7,53 +10,31 @@ import { t } from '@/i18n/locales'; * 券发行/核销/过期统计、品类分布、热销排行、Breakage趋势、二级市场分析 */ -const stats = [ - { label: t('ca_total_coupons'), value: '45,230', change: '+6.5%', trend: 'up' as const, color: 'var(--color-primary)' }, - { label: t('ca_active_coupons'), value: '32,100', change: '+3.2%', trend: 'up' as const, color: 'var(--color-success)' }, - { label: t('ca_redeemed'), value: '8,450', change: '+12.1%', trend: 'up' as const, color: 'var(--color-info)' }, - { label: t('ca_expiring_soon'), value: '2,340', change: '+8.7%', trend: 'up' as const, color: 'var(--color-warning)' }, -]; +interface CouponAnalyticsData { + stats: { label: string; value: string; change: string; trend: 'up' | 'down'; color: string }[]; + categoryDistribution: { name: string; count: number; percent: number; color: string }[]; + topCoupons: { rank: number; brand: string; name: string; sales: number; revenue: string; rating: number }[]; + breakageTrend: { month: string; rate: string }[]; + secondaryMarket: { metric: string; value: string; change: string; trend: 'up' | 'down' }[]; +} -const categoryDistribution = [ - { name: '餐饮', count: 14_474, percent: 32, color: 'var(--color-primary)' }, - { name: '零售', count: 11_308, percent: 25, color: 'var(--color-success)' }, - { name: '娱乐', count: 9_046, percent: 20, color: 'var(--color-info)' }, - { name: '旅游', count: 5_428, percent: 12, color: 'var(--color-warning)' }, - { name: '数码', count: 4_974, percent: 11, color: 'var(--color-error)' }, -]; - -const topCoupons = [ - { rank: 1, brand: '星巴克', name: '大杯拿铁兑换券', sales: 4_230, revenue: '$105,750', rating: 4.8 }, - { rank: 2, brand: 'Amazon', name: '$100电子礼品卡', sales: 3_890, revenue: '$389,000', rating: 4.9 }, - { rank: 3, brand: 'Nike', name: '旗舰店8折券', sales: 2_750, revenue: '$220,000', rating: 4.6 }, - { rank: 4, brand: '海底捞', name: '双人套餐券', sales: 2_340, revenue: '$187,200', rating: 4.7 }, - { rank: 5, brand: 'Target', name: '$30消费券', sales: 2_100, revenue: '$63,000', rating: 4.5 }, - { rank: 6, brand: 'Apple', name: 'App Store $25', sales: 1_980, revenue: '$49,500', rating: 4.8 }, - { rank: 7, brand: '万达影城', name: '双人电影票', sales: 1_750, revenue: '$52,500', rating: 4.4 }, - { rank: 8, brand: 'Uber', name: '$20出行券', sales: 1_620, revenue: '$32,400', rating: 4.3 }, - { rank: 9, brand: '携程', name: '酒店满减券', sales: 1_480, revenue: '$148,000', rating: 4.6 }, - { rank: 10, brand: 'Steam', name: '$50充值卡', sales: 1_310, revenue: '$65,500', rating: 4.7 }, -]; - -const breakageTrend = [ - { month: '9月', rate: '18.2%' }, - { month: '10月', rate: '17.5%' }, - { month: '11月', rate: '16.8%' }, - { month: '12月', rate: '19.3%' }, - { month: '1月', rate: '17.1%' }, - { month: '2月', rate: '16.5%' }, -]; - -const secondaryMarket = [ - { metric: 'Listing Rate', value: '23.5%', change: '+1.2%', trend: 'up' as const }, - { metric: 'Avg Markup', value: '8.3%', change: '-0.5%', trend: 'down' as const }, - { metric: 'Daily Volume', value: '1,230', change: '+15.2%', trend: 'up' as const }, - { metric: 'Daily Amount', value: '$98,400', change: '+11.8%', trend: 'up' as const }, - { metric: 'Avg Fill Time', value: '4.2h', change: '-8.3%', trend: 'down' as const }, - { metric: 'Cancel Rate', value: '12.1%', change: '+0.8%', trend: 'up' as const }, -]; +const loadingBox: React.CSSProperties = { + display: 'flex', alignItems: 'center', justifyContent: 'center', + padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)', +}; export const CouponAnalyticsPage: React.FC = () => { + const { data, isLoading, error } = useApi('/api/v1/admin/analytics/coupons/stats'); + + if (error) return
Error: {error.message}
; + if (isLoading) return
Loading...
; + + const stats = data?.stats ?? []; + const categoryDistribution = data?.categoryDistribution ?? []; + const topCoupons = data?.topCoupons ?? []; + const breakageTrend = data?.breakageTrend ?? []; + const secondaryMarket = data?.secondaryMarket ?? []; + return (

diff --git a/frontend/admin-web/src/views/analytics/MarketMakerPage.tsx b/frontend/admin-web/src/views/analytics/MarketMakerPage.tsx index 4efa96c..f432001 100644 --- a/frontend/admin-web/src/views/analytics/MarketMakerPage.tsx +++ b/frontend/admin-web/src/views/analytics/MarketMakerPage.tsx @@ -1,5 +1,8 @@ +'use client'; + import React from 'react'; import { t } from '@/i18n/locales'; +import { useApi, useApiMutation } from '@/lib/use-api'; /** * 做市商管理仪表盘 @@ -7,61 +10,52 @@ import { t } from '@/i18n/locales'; * 做市商列表、流动性池、订单簿深度、市场健康指标、风险预警 */ -const stats = [ - { label: t('mm_active_makers'), value: '12', change: '+2', trend: 'up' as const, color: 'var(--color-primary)' }, - { label: t('mm_total_liquidity'), value: '$5.2M', change: '+8.3%', trend: 'up' as const, color: 'var(--color-success)' }, - { label: t('mm_daily_volume'), value: '$320K', change: '+12.5%', trend: 'up' as const, color: 'var(--color-info)' }, - { label: t('mm_avg_spread'), value: '1.8%', change: '-0.3%', trend: 'down' as const, color: 'var(--color-warning)' }, -]; +interface MarketMakerData { + stats: { label: string; value: string; change: string; trend: 'up' | 'down'; color: string; invertTrend?: boolean }[]; + marketMakers: { name: string; status: 'active' | 'paused' | 'suspended'; tvl: string; spread: string; volume: string; pnl: string }[]; + liquidityPools: { category: string; tvl: string; percent: number; makers: number; color: string }[]; + healthIndicators: { name: string; value: string; target: string; status: 'good' | 'warning' }[]; + riskAlerts: { time: string; maker: string; type: string; desc: string; severity: 'high' | 'medium' | 'low' }[]; +} -const marketMakers = [ - { name: 'AlphaLiquidity', status: 'active' as const, tvl: '$1,250,000', spread: '1.2%', volume: '$85,000', pnl: '+$12,340' }, - { name: 'BetaMarkets', status: 'active' as const, tvl: '$980,000', spread: '1.5%', volume: '$72,000', pnl: '+$8,920' }, - { name: 'GammaTrading', status: 'active' as const, tvl: '$850,000', spread: '1.8%', volume: '$65,400', pnl: '+$6,780' }, - { name: 'DeltaCapital', status: 'paused' as const, tvl: '$620,000', spread: '2.1%', volume: '$0', pnl: '-$1,230' }, - { name: 'EpsilonFund', status: 'active' as const, tvl: '$540,000', spread: '1.6%', volume: '$43,200', pnl: '+$5,410' }, - { name: 'ZetaPartners', status: 'active' as const, tvl: '$430,000', spread: '2.0%', volume: '$31,800', pnl: '+$3,670' }, - { name: 'EtaVentures', status: 'suspended' as const, tvl: '$0', spread: '-', volume: '$0', pnl: '-$4,560' }, - { name: 'ThetaQuant', status: 'active' as const, tvl: '$280,000', spread: '1.9%', volume: '$22,600', pnl: '+$2,890' }, -]; - -const statusConfig: Record = { - active: { bg: 'var(--color-success-light)', color: 'var(--color-success)', label: t('mm_status_active') }, - paused: { bg: 'var(--color-warning-light)', color: 'var(--color-warning)', label: t('mm_status_paused') }, - suspended: { bg: 'var(--color-error-light)', color: 'var(--color-error)', label: t('mm_status_suspended') }, +const loadingBox: React.CSSProperties = { + display: 'flex', alignItems: 'center', justifyContent: 'center', + padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)', }; -const liquidityPools = [ - { category: '餐饮', tvl: '$1,560,000', percent: 30, makers: 8, color: 'var(--color-primary)' }, - { category: '零售', tvl: '$1,300,000', percent: 25, makers: 7, color: 'var(--color-success)' }, - { category: '娱乐', tvl: '$1,040,000', percent: 20, makers: 6, color: 'var(--color-info)' }, - { category: '旅游', tvl: '$780,000', percent: 15, makers: 5, color: 'var(--color-warning)' }, - { category: '数码', tvl: '$520,000', percent: 10, makers: 4, color: 'var(--color-error)' }, -]; +const getStatusConfig = (status: string) => { + const map: Record string }> = { + active: { bg: 'var(--color-success-light)', color: 'var(--color-success)', label: () => t('mm_status_active') }, + paused: { bg: 'var(--color-warning-light)', color: 'var(--color-warning)', label: () => t('mm_status_paused') }, + suspended: { bg: 'var(--color-error-light)', color: 'var(--color-error)', label: () => t('mm_status_suspended') }, + }; + return map[status] ?? { bg: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)', label: () => status }; +}; -const healthIndicators = [ - { name: 'Bid-Ask 价差', value: '1.8%', target: '< 3.0%', status: 'good' as const }, - { name: '滑点 (Slippage)', value: '0.42%', target: '< 1.0%', status: 'good' as const }, - { name: '成交率 (Fill Rate)', value: '94.7%', target: '> 90%', status: 'good' as const }, - { name: '流动性深度', value: '$5.2M', target: '> $3M', status: 'good' as const }, - { name: '价格偏差', value: '2.1%', target: '< 2.0%', status: 'warning' as const }, - { name: '做市商覆盖率', value: '87%', target: '> 85%', status: 'good' as const }, -]; - -const riskAlerts = [ - { time: '14:25', maker: 'DeltaCapital', type: '流动性撤出', desc: '30分钟内撤出65%流动性,已自动暂停', severity: 'high' as const }, - { time: '13:40', maker: 'EtaVentures', type: '异常交易', desc: '检测到自成交行为,账户已停用待审', severity: 'high' as const }, - { time: '12:15', maker: 'ZetaPartners', type: '价差偏高', desc: '餐饮品类价差达3.2%,超出阈值', severity: 'medium' as const }, - { time: '11:00', maker: 'ThetaQuant', type: 'API延迟', desc: '报价延迟升至800ms,可能影响做市质量', severity: 'low' as const }, -]; - -const severityConfig: Record = { - high: { bg: 'var(--color-error-light)', color: 'var(--color-error)', label: t('severity_high') }, - medium: { bg: 'var(--color-warning-light)', color: 'var(--color-warning)', label: t('severity_medium') }, - low: { bg: 'var(--color-info-light)', color: 'var(--color-info)', label: t('severity_low') }, +const getSeverityConfig = (severity: string) => { + const map: Record string }> = { + high: { bg: 'var(--color-error-light)', color: 'var(--color-error)', label: () => t('severity_high') }, + medium: { bg: 'var(--color-warning-light)', color: 'var(--color-warning)', label: () => t('severity_medium') }, + low: { bg: 'var(--color-info-light)', color: 'var(--color-info)', label: () => t('severity_low') }, + }; + return map[severity] ?? { bg: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)', label: () => severity }; }; export const MarketMakerPage: React.FC = () => { + const { data, isLoading, error } = useApi('/api/v1/admin/mm/list'); + + const suspendMutation = useApiMutation('patch', '/api/v1/admin/mm/suspend', { invalidateKeys: ['/api/v1/admin/mm/list'] }); + const resumeMutation = useApiMutation('patch', '/api/v1/admin/mm/resume', { invalidateKeys: ['/api/v1/admin/mm/list'] }); + + if (error) return
Error: {error.message}
; + if (isLoading) return
Loading...
; + + const stats = data?.stats ?? []; + const marketMakers = data?.marketMakers ?? []; + const liquidityPools = data?.liquidityPools ?? []; + const healthIndicators = data?.healthIndicators ?? []; + const riskAlerts = data?.riskAlerts ?? []; + return (
@@ -89,9 +83,9 @@ export const MarketMakerPage: React.FC = () => {
{stat.change} @@ -126,7 +120,7 @@ export const MarketMakerPage: React.FC = () => { {marketMakers.map(mm => { - const s = statusConfig[mm.status]; + const s = getStatusConfig(mm.status); return ( {mm.name} @@ -137,7 +131,7 @@ export const MarketMakerPage: React.FC = () => { background: s.bg, color: s.color, font: 'var(--text-caption)', - }}>{s.label} + }}>{s.label()} {mm.tvl} {mm.spread} @@ -150,10 +144,10 @@ export const MarketMakerPage: React.FC = () => { {mm.status === 'active' && ( - + )} {mm.status === 'paused' && ( - + )} @@ -283,7 +277,7 @@ export const MarketMakerPage: React.FC = () => {
{riskAlerts.map((alert, i) => { - const sev = severityConfig[alert.severity]; + const sev = getSeverityConfig(alert.severity); return (
{ background: sev.bg, color: sev.color, font: 'var(--text-caption)', - }}>{sev.label} + }}>{sev.label()} {alert.maker} {alert.type}
diff --git a/frontend/admin-web/src/views/analytics/UserAnalyticsPage.tsx b/frontend/admin-web/src/views/analytics/UserAnalyticsPage.tsx index 324c0d9..be2f1f4 100644 --- a/frontend/admin-web/src/views/analytics/UserAnalyticsPage.tsx +++ b/frontend/admin-web/src/views/analytics/UserAnalyticsPage.tsx @@ -1,5 +1,8 @@ -import React from 'react'; +'use client'; + +import React, { useState } from 'react'; import { t } from '@/i18n/locales'; +import { useApi } from '@/lib/use-api'; /** * 用户分析仪表盘 @@ -7,49 +10,32 @@ import { t } from '@/i18n/locales'; * 用户增长趋势、KYC分布、地理分布、留存矩阵、活跃分群 */ -const stats = [ - { label: t('ua_total_users'), value: '128,456', change: '+3.2%', trend: 'up' as const, color: 'var(--color-primary)' }, - { label: 'DAU', value: '12,340', change: '+5.8%', trend: 'up' as const, color: 'var(--color-success)' }, - { label: 'MAU', value: '45,678', change: '+2.1%', trend: 'up' as const, color: 'var(--color-info)' }, - { label: t('ua_new_users_week'), value: '1,234', change: '-1.4%', trend: 'down' as const, color: 'var(--color-warning)' }, -]; +interface UserAnalyticsData { + stats: { label: string; value: string; change: string; trend: 'up' | 'down'; color: string }[]; + kycDistribution: { level: string; count: number; percent: number; color: string }[]; + geoDistribution: { rank: number; region: string; users: string; percent: string }[]; + cohortRetention: { cohort: string; week0: string; week1: string; week2: string; week3: string; week4: string }[]; + segments: { name: string; count: string; percent: number; color: string }[]; +} -const kycDistribution = [ - { level: 'L0 - 未验证', count: 32_114, percent: 25, color: 'var(--color-gray-400)' }, - { level: 'L1 - 基础验证', count: 51_382, percent: 40, color: 'var(--color-info)' }, - { level: 'L2 - 身份验证', count: 33_399, percent: 26, color: 'var(--color-primary)' }, - { level: 'L3 - 高级验证', count: 11_561, percent: 9, color: 'var(--color-success)' }, -]; - -const geoDistribution = [ - { rank: 1, region: '北美', users: '38,536', percent: '30.0%' }, - { rank: 2, region: '东亚', users: '29,545', percent: '23.0%' }, - { rank: 3, region: '东南亚', users: '19,268', percent: '15.0%' }, - { rank: 4, region: '欧洲', users: '14,130', percent: '11.0%' }, - { rank: 5, region: '南美', users: '9,003', percent: '7.0%' }, - { rank: 6, region: '中东', users: '5,138', percent: '4.0%' }, - { rank: 7, region: '南亚', users: '3,854', percent: '3.0%' }, - { rank: 8, region: '非洲', users: '3,854', percent: '3.0%' }, - { rank: 9, region: '大洋洲', users: '2,569', percent: '2.0%' }, - { rank: 10, region: '其他', users: '2,559', percent: '2.0%' }, -]; - -const cohortRetention = [ - { cohort: '第1周 (01/06)', week0: '100%', week1: '68%', week2: '52%', week3: '41%', week4: '35%' }, - { cohort: '第2周 (01/13)', week0: '100%', week1: '71%', week2: '55%', week3: '44%', week4: '38%' }, - { cohort: '第3周 (01/20)', week0: '100%', week1: '65%', week2: '49%', week3: '40%', week4: '-' }, - { cohort: '第4周 (01/27)', week0: '100%', week1: '70%', week2: '53%', week3: '-', week4: '-' }, - { cohort: '第5周 (02/03)', week0: '100%', week1: '67%', week2: '-', week3: '-', week4: '-' }, -]; - -const userSegments = [ - { name: t('ua_segment_high_freq'), count: '8,456', percent: 6.6, color: 'var(--color-primary)' }, - { name: t('ua_segment_occasional'), count: '34,230', percent: 26.6, color: 'var(--color-success)' }, - { name: t('ua_segment_browse'), count: '52,890', percent: 41.2, color: 'var(--color-warning)' }, - { name: t('ua_segment_churned'), count: '32,880', percent: 25.6, color: 'var(--color-error)' }, -]; +const loadingBox: React.CSSProperties = { + display: 'flex', alignItems: 'center', justifyContent: 'center', + padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)', +}; export const UserAnalyticsPage: React.FC = () => { + const [period, setPeriod] = useState('30D'); + const { data, isLoading, error } = useApi('/api/v1/admin/analytics/users/stats', { params: { period } }); + + if (error) return
Error: {error.message}
; + if (isLoading) return
Loading...
; + + const stats = data?.stats ?? []; + const kycDistribution = data?.kycDistribution ?? []; + const geoDistribution = data?.geoDistribution ?? []; + const cohortRetention = data?.cohortRetention ?? []; + const userSegments = data?.segments ?? []; + return (

@@ -94,12 +80,12 @@ export const UserAnalyticsPage: React.FC = () => { {t('ua_growth_trend')}
{['7D', '30D', '90D', '1Y'].map(p => ( - diff --git a/frontend/admin-web/src/views/chain/ChainMonitorPage.tsx b/frontend/admin-web/src/views/chain/ChainMonitorPage.tsx index afe86b5..9668924 100644 --- a/frontend/admin-web/src/views/chain/ChainMonitorPage.tsx +++ b/frontend/admin-web/src/views/chain/ChainMonitorPage.tsx @@ -1,5 +1,8 @@ +'use client'; + import React from 'react'; import { t } from '@/i18n/locales'; +import { useApi } from '@/lib/use-api'; /** * D4. 链上监控 - 合约状态与链上数据 @@ -8,19 +11,16 @@ import { t } from '@/i18n/locales'; * 注:此页面仅对平台管理员可见,发行方/消费者不可见 */ -const contractStats = [ - { label: 'CouponFactory', status: 'Active', txCount: '45,231', lastBlock: '#18,234,567' }, - { label: 'Marketplace', status: 'Active', txCount: '12,890', lastBlock: '#18,234,560' }, - { label: 'RedemptionGateway', status: 'Active', txCount: '38,456', lastBlock: '#18,234,565' }, - { label: 'StablecoinBridge', status: 'Active', txCount: '8,901', lastBlock: '#18,234,555' }, -]; +interface ChainMonitorData { + contracts: { label: string; status: string; txCount: string; lastBlock: string }[]; + events: { event: string; detail: string; hash: string; time: string; type: string }[]; + gasMonitor: { current: string; average: string; todaySpend: string }; +} -const recentEvents = [ - { event: 'Mint', detail: 'Starbucks $25 Gift Card x500', hash: '0xabc...def', time: '2分钟前', type: 'mint' }, - { event: 'Transfer', detail: 'P2P Transfer #1234', hash: '0x123...456', time: '5分钟前', type: 'transfer' }, - { event: 'Redeem', detail: 'Batch Redeem x8', hash: '0x789...abc', time: '8分钟前', type: 'redeem' }, - { event: 'Burn', detail: 'Expired coupons x12', hash: '0xdef...789', time: '15分钟前', type: 'burn' }, -]; +const loadingBox: React.CSSProperties = { + display: 'flex', alignItems: 'center', justifyContent: 'center', + padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)', +}; const eventColors: Record = { mint: 'var(--color-success)', @@ -30,6 +30,15 @@ const eventColors: Record = { }; export const ChainMonitorPage: React.FC = () => { + const { data, isLoading, error } = useApi('/api/v1/admin/chain/contracts'); + + if (error) return
Error: {error.message}
; + if (isLoading) return
Loading...
; + + const contractStats = data?.contracts ?? []; + const recentEvents = data?.events ?? []; + const gasMonitor = data?.gasMonitor ?? { current: '-', average: '-', todaySpend: '-' }; + return (

{t('chain_title')}

@@ -45,7 +54,9 @@ export const ChainMonitorPage: React.FC = () => { {c.label} {c.status}
TX: {c.txCount} · Block: {c.lastBlock}
@@ -60,7 +71,7 @@ export const ChainMonitorPage: React.FC = () => { {recentEvents.map((e, i) => (
{e.event}
@@ -79,9 +90,9 @@ export const ChainMonitorPage: React.FC = () => {

{t('chain_gas_monitor')}

{[ - { label: t('chain_current_gas'), value: '12 gwei', color: 'var(--color-success)' }, - { label: t('chain_today_avg'), value: '18 gwei', color: 'var(--color-info)' }, - { label: t('chain_today_gas_spend'), value: '$1,234', color: 'var(--color-warning)' }, + { label: t('chain_current_gas'), value: gasMonitor.current, color: 'var(--color-success)' }, + { label: t('chain_today_avg'), value: gasMonitor.average, color: 'var(--color-info)' }, + { label: t('chain_today_gas_spend'), value: gasMonitor.todaySpend, color: 'var(--color-warning)' }, ].map(g => (
{g.value}
diff --git a/frontend/admin-web/src/views/compliance/CompliancePage.tsx b/frontend/admin-web/src/views/compliance/CompliancePage.tsx index d0506e2..1bbc406 100644 --- a/frontend/admin-web/src/views/compliance/CompliancePage.tsx +++ b/frontend/admin-web/src/views/compliance/CompliancePage.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { t } from '@/i18n/locales'; +import { useApi } from '@/lib/use-api'; /** * D6. 合规报表 @@ -9,9 +10,58 @@ import { t } from '@/i18n/locales'; * SAR管理、CTR管理、审计日志、监管报表 */ +interface SarRecord { + id: string; + txn: string; + user: string; + amount: number; + type: string; + status: string; + createdAt: string; +} + +interface AuditLog { + time: string; + action: string; + detail: string; + ip: string; +} + +interface ComplianceReport { + title: string; + desc: string; + date: string; + auto: boolean; +} + +interface ComplianceData { + sar: SarRecord[]; + sarPendingCount: number; + auditLogs: AuditLog[]; + reports: ComplianceReport[]; +} + +const loadingBox: React.CSSProperties = { + display: 'flex', alignItems: 'center', justifyContent: 'center', + padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)', +}; + export const CompliancePage: React.FC = () => { const [activeTab, setActiveTab] = useState<'sar' | 'ctr' | 'audit' | 'reports'>('sar'); + const { data: sarData, isLoading: sarLoading } = useApi<{ items: SarRecord[]; pendingCount: number }>( + activeTab === 'sar' ? '/api/v1/admin/compliance/sar' : null, + ); + const { data: auditData, isLoading: auditLoading } = useApi( + activeTab === 'audit' ? '/api/v1/admin/compliance/audit-logs' : null, + ); + const { data: reportsData, isLoading: reportsLoading } = useApi( + activeTab === 'reports' ? '/api/v1/admin/compliance/reports' : null, + ); + + const sarItems = sarData?.items ?? []; + const sarPendingCount = sarData?.pendingCount ?? 0; + return (
@@ -32,7 +82,7 @@ export const CompliancePage: React.FC = () => { {/* Tabs */}
{[ - { key: 'sar', label: t('compliance_tab_sar'), badge: 3 }, + { key: 'sar', label: t('compliance_tab_sar'), badge: sarPendingCount }, { key: 'ctr', label: t('compliance_tab_ctr'), badge: 0 }, { key: 'audit', label: t('compliance_tab_audit'), badge: 0 }, { key: 'reports', label: t('compliance_tab_reports'), badge: 0 }, @@ -75,48 +125,50 @@ export const CompliancePage: React.FC = () => { border: '1px solid var(--color-border-light)', overflow: 'hidden', }}> - - - - {[t('compliance_sar_id'), t('compliance_sar_related_txn'), t('compliance_sar_user'), t('compliance_sar_amount'), t('compliance_sar_risk_type'), t('compliance_sar_status'), t('compliance_sar_created'), t('actions')].map(h => ( - - ))} - - - - {[ - { id: 'SAR-2026-001', txn: 'TXN-8901', user: 'U-045', amount: '$4,560', type: t('risk_type_high_freq'), status: t('compliance_sar_pending') }, - { id: 'SAR-2026-002', txn: 'TXN-8900', user: 'U-078', amount: '$8,900', type: t('risk_type_large_single'), status: t('compliance_sar_submitted') }, - { id: 'SAR-2026-003', txn: 'TXN-8850', user: 'U-012', amount: '$12,000', type: t('risk_type_related_account'), status: t('compliance_sar_submitted') }, - ].map(sar => ( - - - - - - - - - + {sarLoading ? ( +
Loading...
+ ) : ( +
{h}
{sar.id}{sar.txn}{sar.user}{sar.amount} - {sar.type} - - {sar.status} - 2026-02-{10 - parseInt(sar.id.slice(-1))} - -
+ + + {[t('compliance_sar_id'), t('compliance_sar_related_txn'), t('compliance_sar_user'), t('compliance_sar_amount'), t('compliance_sar_risk_type'), t('compliance_sar_status'), t('compliance_sar_created'), t('actions')].map(h => ( + + ))} - ))} - -
{h}
+ + + {sarItems.map(sar => ( + + {sar.id} + {sar.txn} + {sar.user} + ${sar.amount?.toLocaleString()} + + {sar.type} + + + + {sar.status === 'submitted' ? t('compliance_sar_submitted') : t('compliance_sar_pending')} + + + {sar.createdAt} + + + + + ))} + + + )}
)} @@ -153,7 +205,9 @@ export const CompliancePage: React.FC = () => { {t('export')}
- {Array.from({ length: 6 }, (_, i) => ( + {auditLoading ? ( +
Loading...
+ ) : (auditData ?? []).map((log, i) => (
{ alignItems: 'center', gap: 12, }}> - 14:{30 + i}:00 + {log.time} - {[t('compliance_audit_action_login'), t('compliance_audit_action_review'), t('compliance_audit_action_config'), t('compliance_audit_action_freeze'), t('compliance_audit_action_export'), t('compliance_audit_action_query')][i]} + {log.action} - 管理员 admin{i + 1} {['登录系统', '审核发行方ISS-003通过', '修改手续费率为2.5%', '冻结用户U-045', '导出月度报表', '查询OFAC筛查记录'][i]} + {log.detail} - 192.168.1.{100 + i} + {log.ip}
))} @@ -182,45 +236,44 @@ export const CompliancePage: React.FC = () => { {/* Reports Tab */} {activeTab === 'reports' && ( -
- {[ - { title: t('compliance_report_daily'), desc: '每日交易汇总、异常事件', date: '2026-02-10', auto: true }, - { title: t('compliance_report_monthly'), desc: '月度运营指标、合规状态', date: '2026-01-31', auto: true }, - { title: t('compliance_report_sar_quarterly'), desc: '季度可疑活动报告汇总', date: '2025-12-31', auto: false }, - { title: t('compliance_report_annual'), desc: '年度合规审计报告', date: '2025-12-31', auto: false }, - ].map(report => ( -
-
- {report.title} - {report.auto && ( - {t('auto_generated')} - )} + reportsLoading ? ( +
Loading...
+ ) : ( +
+ {(reportsData ?? []).map(report => ( +
+
+ {report.title} + {report.auto && ( + {t('auto_generated')} + )} +
+
{report.desc}
+
+ {t('as_of')} {report.date} + +
-
{report.desc}
-
- {t('as_of')} {report.date} - -
-
- ))} -
+ ))} +
+ ) )}
); diff --git a/frontend/admin-web/src/views/compliance/IpoReadinessPage.tsx b/frontend/admin-web/src/views/compliance/IpoReadinessPage.tsx index 6ebcf72..6560de4 100644 --- a/frontend/admin-web/src/views/compliance/IpoReadinessPage.tsx +++ b/frontend/admin-web/src/views/compliance/IpoReadinessPage.tsx @@ -1,11 +1,11 @@ +'use client'; + import React from 'react'; import { t } from '@/i18n/locales'; +import { useApi } from '@/lib/use-api'; /** * D8.4 IPO准备度检查清单 - 独立页面 - * - * 法律/财务/合规/治理/保险 五大类别 - * Gantt时间线、依赖管理、阻塞项跟踪 */ interface CheckItem { @@ -19,74 +19,45 @@ interface CheckItem { note?: string; } -const categories = [ - { key: 'legal', label: t('ipo_cat_legal'), icon: '§', color: 'var(--color-primary)' }, - { key: 'financial', label: t('ipo_cat_financial'), icon: '$', color: 'var(--color-success)' }, - { key: 'sox', label: t('ipo_cat_sox'), icon: '✓', color: 'var(--color-info)' }, - { key: 'governance', label: t('ipo_cat_governance'), icon: '◆', color: 'var(--color-warning)' }, - { key: 'insurance', label: t('ipo_cat_insurance'), icon: '☂', color: '#FF6B6B' }, -]; +interface IpoData { + overallProgress: { total: number; done: number; inProgress: number; blocked: number; pending: number; percent: number }; + milestones: { name: string; date: string; status: 'done' | 'progress' | 'pending' }[]; + checklistItems: CheckItem[]; + keyContacts: { role: string; name: string; status: string }[]; +} -const overallProgress = { - total: 28, - done: 16, - inProgress: 7, - blocked: 2, - pending: 3, - percent: 72, +const loadingBox: React.CSSProperties = { + display: 'flex', alignItems: 'center', justifyContent: 'center', + padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)', }; -const milestones: { name: string; date: string; status: 'done' | 'progress' | 'pending' }[] = [ - { name: 'S-1初稿提交', date: '2026-Q2', status: 'progress' }, - { name: 'SEC审核期', date: '2026-Q3', status: 'pending' }, - { name: '路演 (Roadshow)', date: '2026-Q3', status: 'pending' }, - { name: '定价 & 上市', date: '2026-Q4', status: 'pending' }, +const categories = [ + { key: 'legal', label: () => t('ipo_cat_legal'), icon: '§', color: 'var(--color-primary)' }, + { key: 'financial', label: () => t('ipo_cat_financial'), icon: '$', color: 'var(--color-success)' }, + { key: 'sox', label: () => t('ipo_cat_sox'), icon: '✓', color: 'var(--color-info)' }, + { key: 'governance', label: () => t('ipo_cat_governance'), icon: '◆', color: 'var(--color-warning)' }, + { key: 'insurance', label: () => t('ipo_cat_insurance'), icon: '☂', color: '#FF6B6B' }, ]; -const checklistItems: CheckItem[] = [ - // Legal - { id: 'L1', item: 'FinCEN MSB牌照', category: 'legal', status: 'done', owner: 'Legal', deadline: '2026-01-15' }, - { id: 'L2', item: 'NY BitLicense申请', category: 'legal', status: 'progress', owner: 'Legal', deadline: '2026-06-30', note: '材料已提交,等待审核' }, - { id: 'L3', item: '各州MTL注册 (48州)', category: 'legal', status: 'progress', owner: 'Legal', deadline: '2026-05-31', note: '已完成38/48州' }, - { id: 'L4', item: 'SEC法律顾问意见书', category: 'legal', status: 'progress', owner: 'External Counsel', deadline: '2026-04-30' }, - { id: 'L5', item: '知识产权审计 (IP Audit)', category: 'legal', status: 'done', owner: 'Legal', deadline: '2026-02-01' }, - { id: 'L6', item: '商标注册 (USPTO)', category: 'legal', status: 'done', owner: 'Legal', deadline: '2026-01-20' }, - // Financial - { id: 'F1', item: '独立审计报告 (Deloitte)', category: 'financial', status: 'progress', owner: 'Finance', deadline: '2026-05-15', dependency: 'F2' }, - { id: 'F2', item: 'GAAP财务报表 (3年)', category: 'financial', status: 'done', owner: 'Finance', deadline: '2026-03-01' }, - { id: 'F3', item: '税务合规证明', category: 'financial', status: 'done', owner: 'Finance', deadline: '2026-02-28' }, - { id: 'F4', item: '收入确认政策 (ASC 606)', category: 'financial', status: 'done', owner: 'Finance', deadline: '2026-02-15' }, - { id: 'F5', item: '估值模型 & 定价区间', category: 'financial', status: 'pending', owner: 'Finance + IB', deadline: '2026-07-31' }, - // SOX - { id: 'S1', item: 'ICFR内部控制框架', category: 'sox', status: 'done', owner: 'Compliance', deadline: '2026-01-31' }, - { id: 'S2', item: 'IT通用控制 (ITGC)', category: 'sox', status: 'done', owner: 'Engineering', deadline: '2026-02-15' }, - { id: 'S3', item: '访问控制审计', category: 'sox', status: 'done', owner: 'Security', deadline: '2026-02-10' }, - { id: 'S4', item: '变更管理流程', category: 'sox', status: 'done', owner: 'Engineering', deadline: '2026-02-01' }, - { id: 'S5', item: 'SOX 404管理层评估', category: 'sox', status: 'progress', owner: 'Compliance', deadline: '2026-05-31', dependency: 'S1' }, - // Governance - { id: 'G1', item: '独立董事会组建 (3+)', category: 'governance', status: 'done', owner: 'Board', deadline: '2026-03-01', note: '4名独立董事已任命' }, - { id: 'G2', item: '审计委员会', category: 'governance', status: 'done', owner: 'Board', deadline: '2026-03-15' }, - { id: 'G3', item: '薪酬委员会', category: 'governance', status: 'done', owner: 'Board', deadline: '2026-03-15' }, - { id: 'G4', item: '公司章程 & 治理政策', category: 'governance', status: 'done', owner: 'Legal', deadline: '2026-02-28' }, - { id: 'G5', item: 'D&O保险', category: 'governance', status: 'blocked', owner: 'Legal', deadline: '2026-04-30', note: '等待承保方最终报价' }, - { id: 'G6', item: 'Insider Trading Policy', category: 'governance', status: 'done', owner: 'Legal', deadline: '2026-03-01' }, - // Insurance - { id: 'I1', item: '消费者保护基金 ≥$2M', category: 'insurance', status: 'done', owner: 'Finance', deadline: '2026-02-01' }, - { id: 'I2', item: 'AML/KYC体系', category: 'insurance', status: 'done', owner: 'Compliance', deadline: '2026-01-15' }, - { id: 'I3', item: '赔付机制运行报告', category: 'insurance', status: 'progress', owner: 'Operations', deadline: '2026-05-01' }, - { id: 'I4', item: 'Cyber保险', category: 'insurance', status: 'blocked', owner: 'Legal', deadline: '2026-04-15', note: '正在比价3家承保方' }, - { id: 'I5', item: '做市商协议签署', category: 'insurance', status: 'pending', owner: 'Finance + IB', deadline: '2026-07-15' }, - { id: 'I6', item: '承销商尽职调查', category: 'insurance', status: 'pending', owner: 'External', deadline: '2026-08-01' }, -]; - -const statusConfig: Record = { - done: { label: t('completed'), bg: 'var(--color-success-light)', fg: 'var(--color-success)' }, - progress: { label: t('in_progress'), bg: 'var(--color-warning-light)', fg: 'var(--color-warning)' }, - blocked: { label: t('blocked'), bg: 'var(--color-error-light)', fg: 'var(--color-error)' }, - pending: { label: t('pending'), bg: 'var(--color-gray-100)', fg: 'var(--color-text-tertiary)' }, +const statusConfig: Record string; bg: string; fg: string }> = { + done: { label: () => t('completed'), bg: 'var(--color-success-light)', fg: 'var(--color-success)' }, + progress: { label: () => t('in_progress'), bg: 'var(--color-warning-light)', fg: 'var(--color-warning)' }, + blocked: { label: () => t('blocked'), bg: 'var(--color-error-light)', fg: 'var(--color-error)' }, + pending: { label: () => t('pending'), bg: 'var(--color-gray-100)', fg: 'var(--color-text-tertiary)' }, }; export const IpoReadinessPage: React.FC = () => { + const { data: ipoData, isLoading, error } = useApi('/api/v1/admin/compliance/reports'); + const { data: insuranceData } = useApi<{ ipoReadiness: number }>('/api/v1/admin/insurance/stats'); + + if (error) return
Error: {error.message}
; + if (isLoading) return
Loading...
; + + const overallProgress = ipoData?.overallProgress ?? { total: 0, done: 0, inProgress: 0, blocked: 0, pending: 0, percent: 0 }; + const milestones = ipoData?.milestones ?? []; + const checklistItems = ipoData?.checklistItems ?? []; + const keyContacts = ipoData?.keyContacts ?? []; + return (

{t('ipo_title')}

@@ -123,9 +94,9 @@ export const IpoReadinessPage: React.FC = () => { {overallProgress.percent}%
-
-
-
+
0 ? (overallProgress.done / overallProgress.total * 100).toFixed(0) : 0}%`, height: '100%', background: 'var(--color-success)' }} /> +
0 ? (overallProgress.inProgress / overallProgress.total * 100).toFixed(0) : 0}%`, height: '100%', background: 'var(--color-warning)' }} /> +
0 ? (overallProgress.blocked / overallProgress.total * 100).toFixed(0) : 0}%`, height: '100%', background: 'var(--color-error)' }} />
{[ @@ -148,6 +119,7 @@ export const IpoReadinessPage: React.FC = () => { {categories.map(cat => { const items = checklistItems.filter(i => i.category === cat.key); const catDone = items.filter(i => i.status === 'done').length; + if (items.length === 0) return null; return (
{ }}> {cat.icon}
- {cat.label} + {cat.label()}
{catDone}/{items.length} {t('ipo_unit_done')}
- @@ -201,7 +172,7 @@ export const IpoReadinessPage: React.FC = () => { {st.label} + }}>{st.label()} ); @@ -222,7 +193,7 @@ export const IpoReadinessPage: React.FC = () => { }}>

{t('ipo_timeline')}

{milestones.map((m, i) => ( -
+
{

{t('ipo_category_progress')}

{categories.map(cat => { const items = checklistItems.filter(i => i.category === cat.key); + if (items.length === 0) return null; const catDone = items.filter(i => i.status === 'done').length; const pct = Math.round(catDone / items.length * 100); return (
- {cat.label} + {cat.label()} {pct}%
@@ -292,12 +264,7 @@ export const IpoReadinessPage: React.FC = () => { border: '1px solid var(--color-border-light)', padding: 20, }}>

{t('ipo_key_contacts')}

- {[ - { role: '承销商 (Lead)', name: 'Goldman Sachs', status: '已签约' }, - { role: '审计师', name: 'Deloitte', status: '审计中' }, - { role: '法律顾问', name: 'Skadden, Arps', status: '已签约' }, - { role: 'SEC联络', name: 'SEC Division of Corp Finance', status: '对接中' }, - ].map(c => ( + {keyContacts.map(c => (
{c.role}
diff --git a/frontend/admin-web/src/views/compliance/LicenseManagementPage.tsx b/frontend/admin-web/src/views/compliance/LicenseManagementPage.tsx index 8c9c4d6..01f4dc5 100644 --- a/frontend/admin-web/src/views/compliance/LicenseManagementPage.tsx +++ b/frontend/admin-web/src/views/compliance/LicenseManagementPage.tsx @@ -1,97 +1,77 @@ +'use client'; + import React from 'react'; import { t } from '@/i18n/locales'; +import { useApi } from '@/lib/use-api'; /** * License & Regulatory Permits Management - 牌照与监管许可管理 - * - * 管理平台在各司法管辖区的金融牌照、监管许可证状态, - * 包括MSB、MTL、各州Money Transmitter License等,追踪续期与申请进度。 */ -const licenseStats = [ - { label: t('license_active_count'), value: '12', color: 'var(--color-success)' }, - { label: t('license_pending'), value: '4', color: 'var(--color-info)' }, - { label: t('license_expiring_soon'), value: '2', color: 'var(--color-warning)' }, - { label: t('license_revoked'), value: '0', color: 'var(--color-error)' }, -]; +interface LicenseData { + stats: { label: string; value: string; color: string }[]; + licenses: { id: string; name: string; jurisdiction: string; regBody: string; status: string; issueDate: string; expiryDate: string }[]; + regulatoryBodies: { name: string; fullName: string; scope: string; licenses: number }[]; + renewalAlerts: { license: string; expiryDate: string; daysRemaining: number; urgency: string }[]; + activeLicenseCount: number; + jurisdictionsCovered: number; +} -const licenses = [ - { id: 'LIC-001', name: 'FinCEN MSB Registration', jurisdiction: 'Federal (US)', regBody: 'FinCEN', status: t('license_status_active'), issueDate: '2024-06-01', expiryDate: '2026-06-01' }, - { id: 'LIC-002', name: 'New York BitLicense', jurisdiction: 'New York', regBody: 'NYDFS', status: t('license_status_active'), issueDate: '2024-09-15', expiryDate: '2026-09-15' }, - { id: 'LIC-003', name: 'California MTL', jurisdiction: 'California', regBody: 'DFPI', status: t('license_status_active'), issueDate: '2025-01-10', expiryDate: '2027-01-10' }, - { id: 'LIC-004', name: 'Texas Money Transmitter', jurisdiction: 'Texas', regBody: 'TDSML', status: t('license_status_expiring'), issueDate: '2024-03-20', expiryDate: '2026-03-20' }, - { id: 'LIC-005', name: 'Florida Money Transmitter', jurisdiction: 'Florida', regBody: 'OFR', status: t('license_status_active'), issueDate: '2025-04-01', expiryDate: '2027-04-01' }, - { id: 'LIC-006', name: 'Illinois TOMA', jurisdiction: 'Illinois', regBody: 'IDFPR', status: t('license_status_applying'), issueDate: '-', expiryDate: '-' }, - { id: 'LIC-007', name: 'Washington Money Transmitter', jurisdiction: 'Washington', regBody: 'DFI', status: t('license_status_active'), issueDate: '2025-02-15', expiryDate: '2027-02-15' }, - { id: 'LIC-008', name: 'SEC Broker-Dealer Registration', jurisdiction: 'Federal (US)', regBody: 'SEC / FINRA', status: t('license_status_applying'), issueDate: '-', expiryDate: '-' }, - { id: 'LIC-009', name: 'Georgia Money Transmitter', jurisdiction: 'Georgia', regBody: 'DBF', status: t('license_status_renewal'), issueDate: '2024-02-28', expiryDate: '2026-02-28' }, - { id: 'LIC-010', name: 'Nevada Money Transmitter', jurisdiction: 'Nevada', regBody: 'FID', status: t('license_status_active'), issueDate: '2025-06-01', expiryDate: '2027-06-01' }, -]; - -const regulatoryBodies = [ - { name: 'FinCEN', fullName: 'Financial Crimes Enforcement Network', scope: '联邦反洗钱监管', licenses: 1 }, - { name: 'SEC', fullName: 'Securities and Exchange Commission', scope: '证券交易监管', licenses: 1 }, - { name: 'NYDFS', fullName: 'NY Dept. of Financial Services', scope: '纽约州金融服务', licenses: 1 }, - { name: 'DFPI', fullName: 'CA Dept. of Financial Protection & Innovation', scope: '加州金融保护', licenses: 1 }, - { name: 'FINRA', fullName: 'Financial Industry Regulatory Authority', scope: '经纪商自律监管', licenses: 1 }, - { name: 'OCC', fullName: 'Office of the Comptroller of the Currency', scope: '联邦银行监管', licenses: 0 }, -]; - -const renewalAlerts = [ - { license: 'Texas Money Transmitter', expiryDate: '2026-03-20', daysRemaining: 38, urgency: 'high' }, - { license: 'Georgia Money Transmitter', expiryDate: '2026-02-28', daysRemaining: 18, urgency: 'critical' }, - { license: 'FinCEN MSB Registration', expiryDate: '2026-06-01', daysRemaining: 111, urgency: 'medium' }, - { license: 'New York BitLicense', expiryDate: '2026-09-15', daysRemaining: 217, urgency: 'low' }, -]; +const loadingBox: React.CSSProperties = { + display: 'flex', alignItems: 'center', justifyContent: 'center', + padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)', +}; const getLicenseStatusStyle = (status: string) => { switch (status) { - case t('license_status_active'): - return { background: 'var(--color-success-light)', color: 'var(--color-success)' }; - case t('license_status_applying'): - return { background: 'var(--color-info-light)', color: 'var(--color-info)' }; - case t('license_status_renewal'): - return { background: 'var(--color-warning-light)', color: 'var(--color-warning)' }; - case t('license_status_expiring'): - return { background: 'var(--color-error-light)', color: 'var(--color-error)' }; - case t('license_status_expired'): - return { background: 'var(--color-gray-100)', color: 'var(--color-error)' }; - default: - return { background: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' }; + case 'active': return { background: 'var(--color-success-light)', color: 'var(--color-success)' }; + case 'applying': return { background: 'var(--color-info-light)', color: 'var(--color-info)' }; + case 'renewal': return { background: 'var(--color-warning-light)', color: 'var(--color-warning)' }; + case 'expiring': return { background: 'var(--color-error-light)', color: 'var(--color-error)' }; + case 'expired': return { background: 'var(--color-gray-100)', color: 'var(--color-error)' }; + default: return { background: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' }; } }; +const getLicenseStatusLabel = (status: string) => { + const map: Record string> = { + active: () => t('license_status_active'), + applying: () => t('license_status_applying'), + renewal: () => t('license_status_renewal'), + expiring: () => t('license_status_expiring'), + }; + return map[status]?.() ?? status; +}; + const getUrgencyStyle = (urgency: string) => { switch (urgency) { - case 'critical': - return { background: 'var(--color-error)', color: 'white' }; - case 'high': - return { background: 'var(--color-error-light)', color: 'var(--color-error)' }; - case 'medium': - return { background: 'var(--color-warning-light)', color: 'var(--color-warning)' }; - case 'low': - return { background: 'var(--color-success-light)', color: 'var(--color-success)' }; - default: - return { background: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' }; + case 'critical': return { background: 'var(--color-error)', color: 'white' }; + case 'high': return { background: 'var(--color-error-light)', color: 'var(--color-error)' }; + case 'medium': return { background: 'var(--color-warning-light)', color: 'var(--color-warning)' }; + case 'low': return { background: 'var(--color-success-light)', color: 'var(--color-success)' }; + default: return { background: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' }; } }; export const LicenseManagementPage: React.FC = () => { + const { data, isLoading, error } = useApi('/api/v1/admin/compliance/licenses'); + + if (error) return
Error: {error.message}
; + if (isLoading) return
Loading...
; + + const licenseStats = data?.stats ?? []; + const licenses = data?.licenses ?? []; + const regulatoryBodies = data?.regulatoryBodies ?? []; + const renewalAlerts = data?.renewalAlerts ?? []; + return (

{t('license_title')}

+ padding: '8px 16px', border: 'none', borderRadius: 'var(--radius-full)', + background: 'var(--color-primary)', color: 'white', cursor: 'pointer', font: 'var(--text-label-sm)', + }}>{t('license_new')}
{/* Stats */} @@ -142,7 +122,7 @@ export const LicenseManagementPage: React.FC = () => { {l.status} + }}>{getLicenseStatusLabel(l.status)}
@@ -218,16 +198,13 @@ export const LicenseManagementPage: React.FC = () => { border: 'none', borderRadius: 'var(--radius-sm)', background: 'var(--color-error)', color: 'white', cursor: 'pointer', font: 'var(--text-label-sm)', - }}> - {t('renew_now')} - + }}>{t('renew_now')} )} ))} - {/* Summary */}
- {licenses.filter(l => l.status === t('license_status_active')).length} {t('license_jurisdictions_covered').replace('{0}', String(new Set(licenses.filter(l => l.status === t('license_status_active')).map(l => l.jurisdiction)).size))} + {data?.activeLicenseCount ?? 0} {t('license_jurisdictions_covered').replace('{0}', String(data?.jurisdictionsCovered ?? 0))}
diff --git a/frontend/admin-web/src/views/compliance/SecFilingPage.tsx b/frontend/admin-web/src/views/compliance/SecFilingPage.tsx index 7d1f186..86ad1b5 100644 --- a/frontend/admin-web/src/views/compliance/SecFilingPage.tsx +++ b/frontend/admin-web/src/views/compliance/SecFilingPage.tsx @@ -1,81 +1,74 @@ +'use client'; + import React from 'react'; import { t } from '@/i18n/locales'; +import { useApi } from '@/lib/use-api'; /** * SEC Filing Management - SEC文件提交与管理 - * - * 管理所有SEC申报文件(10-K, 10-Q, S-1, 8-K等), - * 追踪提交状态、审核进度、截止日期,以及自动生成披露文件的状态。 */ -const filingStats = [ - { label: t('sec_filed_count'), value: '24', color: 'var(--color-primary)' }, - { label: t('sec_pending_review'), value: '3', color: 'var(--color-warning)' }, - { label: t('sec_passed'), value: '19', color: 'var(--color-success)' }, - { label: t('sec_next_deadline'), value: '18天', color: 'var(--color-error)' }, -]; +interface SecFilingData { + stats: { label: string; value: string; color: string }[]; + filings: { id: string; formType: string; title: string; filingDate: string; deadline: string; status: string; reviewer: string }[]; + timeline: { date: string; event: string; type: string; done: boolean }[]; + disclosureItems: { name: string; status: string; lastUpdated: string }[]; + disclosureProgress: number; +} -const secFilings = [ - { id: 'SEC-001', formType: 'S-1', title: 'IPO注册声明书', filingDate: '2026-01-15', deadline: '2026-02-28', status: t('sec_status_reviewing'), reviewer: 'SEC Division of Corporation Finance' }, - { id: 'SEC-002', formType: '10-K', title: '2025年度报告', filingDate: '2026-01-30', deadline: '2026-03-31', status: t('sec_status_submitted'), reviewer: 'Internal Audit' }, - { id: 'SEC-003', formType: '10-Q', title: '2025 Q4季度报告', filingDate: '2026-02-01', deadline: '2026-02-15', status: t('sec_status_passed'), reviewer: 'External Auditor' }, - { id: 'SEC-004', formType: '8-K', title: '重大事项披露-战略合作', filingDate: '2026-02-05', deadline: '2026-02-09', status: t('sec_status_passed'), reviewer: 'Legal Counsel' }, - { id: 'SEC-005', formType: 'S-1/A', title: 'S-1修订稿(第2版)', filingDate: '2026-02-08', deadline: '2026-02-28', status: t('sec_status_needs_revision'), reviewer: 'SEC Division of Corporation Finance' }, - { id: 'SEC-006', formType: '10-Q', title: '2026 Q1季度报告', filingDate: '', deadline: '2026-05-15', status: t('sec_status_pending'), reviewer: '-' }, -]; - -const timelineEvents = [ - { date: '2026-02-15', event: '10-Q (Q4 2025) 截止', type: 'deadline', done: true }, - { date: '2026-02-28', event: 'S-1 注册声明审核回复', type: 'deadline', done: false }, - { date: '2026-03-15', event: '8-K 材料事件披露窗口', type: 'info', done: false }, - { date: '2026-03-31', event: '10-K 年度报告截止', type: 'deadline', done: false }, - { date: '2026-04-15', event: 'Proxy Statement 提交', type: 'deadline', done: false }, - { date: '2026-05-15', event: '10-Q (Q1 2026) 截止', type: 'deadline', done: false }, -]; - -const disclosureItems = [ - { name: '风险因素 (Risk Factors)', status: 'done', lastUpdated: '2026-02-05' }, - { name: '管理层讨论与分析 (MD&A)', status: 'done', lastUpdated: '2026-02-03' }, - { name: '财务报表 (Financial Statements)', status: 'progress', lastUpdated: '2026-02-08' }, - { name: '关联交易披露', status: 'progress', lastUpdated: '2026-02-07' }, - { name: '高管薪酬披露', status: 'pending', lastUpdated: '-' }, - { name: '公司治理结构', status: 'done', lastUpdated: '2026-01-28' }, - { name: '法律诉讼披露', status: 'pending', lastUpdated: '-' }, -]; +const loadingBox: React.CSSProperties = { + display: 'flex', alignItems: 'center', justifyContent: 'center', + padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)', +}; const getFilingStatusStyle = (status: string) => { switch (status) { - case t('sec_status_passed'): + case 'passed': return { background: 'var(--color-success-light)', color: 'var(--color-success)' }; - case t('sec_status_reviewing'): + case 'reviewing': return { background: 'var(--color-info-light)', color: 'var(--color-info)' }; - case t('sec_status_submitted'): + case 'submitted': return { background: 'var(--color-primary-light)', color: 'var(--color-primary)' }; - case t('sec_status_needs_revision'): + case 'needs_revision': return { background: 'var(--color-error-light)', color: 'var(--color-error)' }; - case t('sec_status_pending'): + case 'pending': return { background: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' }; default: return { background: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' }; } }; +const getStatusLabel = (status: string) => { + const map: Record string> = { + passed: () => t('sec_status_passed'), + reviewing: () => t('sec_status_reviewing'), + submitted: () => t('sec_status_submitted'), + needs_revision: () => t('sec_status_needs_revision'), + pending: () => t('sec_status_pending'), + }; + return map[status]?.() ?? status; +}; + export const SecFilingPage: React.FC = () => { + const { data, isLoading, error } = useApi('/api/v1/admin/compliance/sec-filing'); + + if (error) return
Error: {error.message}
; + if (isLoading) return
Loading...
; + + const filingStats = data?.stats ?? []; + const secFilings = data?.filings ?? []; + const timelineEvents = data?.timeline ?? []; + const disclosureItems = data?.disclosureItems ?? []; + const disclosureProgress = data?.disclosureProgress ?? 0; + return (

{t('sec_title')}

+ padding: '8px 16px', border: 'none', borderRadius: 'var(--radius-full)', + background: 'var(--color-primary)', color: 'white', cursor: 'pointer', font: 'var(--text-label-sm)', + }}>{t('sec_new_filing')}
{/* Stats */} @@ -126,7 +119,7 @@ export const SecFilingPage: React.FC = () => { {f.status} + }}>{getStatusLabel(f.status)}
@@ -193,10 +186,10 @@ export const SecFilingPage: React.FC = () => {
{t('sec_disclosure_progress')} - 57% + {disclosureProgress}%
-
+
diff --git a/frontend/admin-web/src/views/compliance/SoxCompliancePage.tsx b/frontend/admin-web/src/views/compliance/SoxCompliancePage.tsx index 02638f7..e7bcfb7 100644 --- a/frontend/admin-web/src/views/compliance/SoxCompliancePage.tsx +++ b/frontend/admin-web/src/views/compliance/SoxCompliancePage.tsx @@ -1,111 +1,82 @@ +'use client'; + import React from 'react'; import { t } from '@/i18n/locales'; +import { useApi } from '@/lib/use-api'; /** * SOX Compliance (Sarbanes-Oxley) - SOX合规管理 - * - * 管理SOX 404内部控制合规状态,包括ICFR、ITGC、访问控制、变更管理、运营控制等, - * 追踪测试结果、缺陷修复、审计师审核进度。 */ -const overallScore = 78; +interface SoxData { + overallScore: number; + controlCategories: { + name: string; + description: string; + controls: { name: string; result: string; lastTest: string; nextTest: string }[]; + }[]; + deficiencies: { id: string; control: string; category: string; severity: string; description: string; foundDate: string; dueDate: string; status: string; owner: string }[]; + auditorReview: { phase: string; status: string; date: string; auditor: string }[]; + auditProgress: number; +} -const controlCategories = [ - { - name: 'ICFR', - description: 'Financial Reporting Internal Controls - Revenue recognition, expense allocation, asset valuation', - controls: [ - { name: 'Revenue Recognition', result: t('sox_result_passed'), lastTest: '2026-01-15', nextTest: '2026-04-15' }, - { name: 'Expense Approval', result: t('sox_result_passed'), lastTest: '2026-01-20', nextTest: '2026-04-20' }, - { name: 'Period-end Close', result: t('sox_result_defect'), lastTest: '2026-02-01', nextTest: '2026-03-01' }, - { name: 'Consolidation', result: t('sox_result_passed'), lastTest: '2026-01-25', nextTest: '2026-04-25' }, - ], - }, - { - name: 'ITGC', - description: 'IT General Controls - System development, program change, computer operations, data security', - controls: [ - { name: 'SDLC', result: t('sox_result_passed'), lastTest: '2026-01-10', nextTest: '2026-04-10' }, - { name: 'Change Management', result: t('sox_result_passed'), lastTest: '2026-01-18', nextTest: '2026-04-18' }, - { name: 'Backup & Recovery', result: t('sox_result_defect'), lastTest: '2026-02-03', nextTest: '2026-03-03' }, - { name: 'Logical Security', result: t('sox_result_pending'), lastTest: '-', nextTest: '2026-02-20' }, - ], - }, - { - name: 'Access Control', - description: 'System & data access management - User permissions, privileged accounts, SoD', - controls: [ - { name: 'User Access Review', result: t('sox_result_passed'), lastTest: '2026-02-01', nextTest: '2026-05-01' }, - { name: 'Privileged Access', result: t('sox_result_passed'), lastTest: '2026-01-28', nextTest: '2026-04-28' }, - { name: 'SoD', result: t('sox_result_defect'), lastTest: '2026-02-05', nextTest: '2026-03-05' }, - ], - }, - { - name: 'Change Management', - description: 'Production change approval, testing, deployment process controls', - controls: [ - { name: 'Change Approval', result: t('sox_result_passed'), lastTest: '2026-01-22', nextTest: '2026-04-22' }, - { name: 'Pre-deploy Testing', result: t('sox_result_passed'), lastTest: '2026-01-30', nextTest: '2026-04-30' }, - { name: 'Emergency Change', result: t('sox_result_pending'), lastTest: '-', nextTest: '2026-02-25' }, - ], - }, - { - name: 'Operational Controls', - description: 'Daily operations - Transaction monitoring, reconciliation, exception handling', - controls: [ - { name: 'EOD Reconciliation', result: t('sox_result_passed'), lastTest: '2026-02-08', nextTest: '2026-05-08' }, - { name: 'Anomaly Monitoring', result: t('sox_result_passed'), lastTest: '2026-02-06', nextTest: '2026-05-06' }, - { name: 'Client Fund Segregation', result: t('sox_result_passed'), lastTest: '2026-02-04', nextTest: '2026-05-04' }, - ], - }, -]; - -const deficiencies = [ - { id: 'DEF-001', control: 'Period-end Close', category: 'ICFR', severity: t('sox_severity_major'), description: 'Manual adjustments missing secondary approval', foundDate: '2026-02-01', dueDate: '2026-03-01', status: t('sox_status_remediating'), owner: 'CFO Office' }, - { id: 'DEF-002', control: 'Backup & Recovery', category: 'ITGC', severity: t('sox_severity_minor'), description: 'DR drill not executed quarterly', foundDate: '2026-02-03', dueDate: '2026-03-15', status: t('sox_status_remediating'), owner: 'IT Dept' }, - { id: 'DEF-003', control: 'SoD', category: 'Access Control', severity: t('sox_severity_major'), description: '3 users with both create & approve access', foundDate: '2026-02-05', dueDate: '2026-02-20', status: t('sox_status_pending'), owner: 'Compliance' }, -]; - -const auditorReview = [ - { phase: 'Audit Plan Confirmation', status: 'done', date: '2026-01-05', auditor: 'Deloitte' }, - { phase: 'Walk-through Testing', status: 'done', date: '2026-01-20', auditor: 'Deloitte' }, - { phase: 'Controls Effectiveness Testing', status: 'progress', date: '2026-02-10', auditor: 'Deloitte' }, - { phase: 'Deficiency Assessment', status: 'pending', date: '2026-03-01', auditor: 'Deloitte' }, - { phase: 'Management Report', status: 'pending', date: '2026-03-15', auditor: 'Deloitte' }, - { phase: 'Final Audit Opinion', status: 'pending', date: '2026-04-01', auditor: 'Deloitte' }, -]; +const loadingBox: React.CSSProperties = { + display: 'flex', alignItems: 'center', justifyContent: 'center', + padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)', +}; const getResultStyle = (result: string) => { switch (result) { - case t('sox_result_passed'): - return { background: 'var(--color-success-light)', color: 'var(--color-success)' }; - case t('sox_result_defect'): - return { background: 'var(--color-error-light)', color: 'var(--color-error)' }; - case t('sox_result_pending'): - return { background: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' }; - default: - return { background: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' }; + case 'passed': return { background: 'var(--color-success-light)', color: 'var(--color-success)' }; + case 'defect': return { background: 'var(--color-error-light)', color: 'var(--color-error)' }; + case 'pending': return { background: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' }; + default: return { background: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' }; } }; +const getResultLabel = (result: string) => { + const map: Record string> = { + passed: () => t('sox_result_passed'), + defect: () => t('sox_result_defect'), + pending: () => t('sox_result_pending'), + }; + return map[result]?.() ?? result; +}; + const getSeverityStyle = (severity: string) => { switch (severity) { - case t('sox_severity_major'): - return { background: 'var(--color-error-light)', color: 'var(--color-error)' }; - case t('sox_severity_minor'): - return { background: 'var(--color-warning-light)', color: 'var(--color-warning)' }; - case t('sox_severity_observation'): - return { background: 'var(--color-info-light)', color: 'var(--color-info)' }; - default: - return { background: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' }; + case 'major': return { background: 'var(--color-error-light)', color: 'var(--color-error)' }; + case 'minor': return { background: 'var(--color-warning-light)', color: 'var(--color-warning)' }; + case 'observation': return { background: 'var(--color-info-light)', color: 'var(--color-info)' }; + default: return { background: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' }; } }; +const getSeverityLabel = (severity: string) => { + const map: Record string> = { + major: () => t('sox_severity_major'), + minor: () => t('sox_severity_minor'), + observation: () => t('sox_severity_observation'), + }; + return map[severity]?.() ?? severity; +}; + export const SoxCompliancePage: React.FC = () => { + const { data, isLoading, error } = useApi('/api/v1/admin/compliance/reports'); + + if (error) return
Error: {error.message}
; + if (isLoading) return
Loading...
; + + const overallScore = data?.overallScore ?? 0; + const controlCategories = data?.controlCategories ?? []; + const deficiencies = data?.deficiencies ?? []; + const auditorReview = data?.auditorReview ?? []; + const auditProgress = data?.auditProgress ?? 0; + const totalControls = controlCategories.reduce((sum, cat) => sum + cat.controls.length, 0); - const passedControls = controlCategories.reduce((sum, cat) => sum + cat.controls.filter(c => c.result === t('sox_result_passed')).length, 0); - const defectControls = controlCategories.reduce((sum, cat) => sum + cat.controls.filter(c => c.result === t('sox_result_defect')).length, 0); - const pendingControls = controlCategories.reduce((sum, cat) => sum + cat.controls.filter(c => c.result === t('sox_result_pending')).length, 0); + const passedControls = controlCategories.reduce((sum, cat) => sum + cat.controls.filter(c => c.result === 'passed').length, 0); + const defectControls = controlCategories.reduce((sum, cat) => sum + cat.controls.filter(c => c.result === 'defect').length, 0); + const pendingControls = controlCategories.reduce((sum, cat) => sum + cat.controls.filter(c => c.result === 'pending').length, 0); return (
@@ -113,7 +84,6 @@ export const SoxCompliancePage: React.FC = () => { {/* Compliance Score Gauge + Summary Stats */}
- {/* Gauge */}
{
{t('sox_full_score')}
- {/* Summary Stats */}
{[ { label: t('sox_total_controls'), value: String(totalControls), color: 'var(--color-primary)' }, @@ -166,12 +135,10 @@ export const SoxCompliancePage: React.FC = () => {
{controlCategories.map((cat, catIdx) => (
- {/* Category Header */}
{cat.name}
{cat.description}
- {/* Controls */} @@ -188,7 +155,7 @@ export const SoxCompliancePage: React.FC = () => { {ctrl.result} + }}>{getResultLabel(ctrl.result)} @@ -226,16 +193,18 @@ export const SoxCompliancePage: React.FC = () => { {d.severity} + }}>{getSeverityLabel(d.severity)} @@ -247,7 +216,7 @@ export const SoxCompliancePage: React.FC = () => { {/* Auditor Review Status */}

{t('sox_auditor_progress')}

-
External Auditor: Deloitte
+
External Auditor: {auditorReview[0]?.auditor ?? 'N/A'}
{auditorReview.map((phase, i) => (
{
))} - {/* Progress Bar */}
{t('sox_audit_progress')} - 33% + {auditProgress}%
-
+
diff --git a/frontend/admin-web/src/views/compliance/TaxCompliancePage.tsx b/frontend/admin-web/src/views/compliance/TaxCompliancePage.tsx index 8c1ec1f..9a32579 100644 --- a/frontend/admin-web/src/views/compliance/TaxCompliancePage.tsx +++ b/frontend/admin-web/src/views/compliance/TaxCompliancePage.tsx @@ -1,106 +1,84 @@ +'use client'; + import React from 'react'; import { t } from '@/i18n/locales'; +import { useApi } from '@/lib/use-api'; /** * Tax Compliance Management - 税务合规管理 - * - * 管理平台在各司法管辖区的税务义务,追踪联邦和州级税务申报, - * 包括所得税、销售税、预扣税、交易税等,以及IRS表格提交进度。 */ -const taxStats = [ - { label: t('tax_payable'), value: '$1,245,890', color: 'var(--color-primary)' }, - { label: t('tax_paid'), value: '$982,450', color: 'var(--color-success)' }, - { label: t('tax_compliance_rate'), value: '96.8%', color: 'var(--color-info)' }, - { label: t('tax_pending_items'), value: '5', color: 'var(--color-warning)' }, -]; +interface TaxData { + stats: { label: string; value: string; color: string }[]; + obligations: { jurisdiction: string; taxType: string; period: string; amount: number; paid: number; status: string; dueDate: string }[]; + typeBreakdown: { type: string; federal: string; state: string; total: string; percentage: number }[]; + irsFilings: { form: string; description: string; taxYear: string; deadline: string; status: string; filedDate: string }[]; + deadlines: { date: string; event: string; done: boolean }[]; +} -const taxObligations = [ - { jurisdiction: 'Federal', taxType: 'Corporate Income Tax', period: 'FY 2025', amount: '$425,000', paid: '$425,000', status: t('tax_status_paid'), dueDate: '2026-04-15' }, - { jurisdiction: 'Federal', taxType: 'Employment Tax (FICA)', period: 'Q4 2025', amount: '$68,200', paid: '$68,200', status: t('tax_status_paid'), dueDate: '2026-01-31' }, - { jurisdiction: 'California', taxType: 'State Income Tax', amount: '$187,500', paid: '$187,500', period: 'FY 2025', status: t('tax_status_paid'), dueDate: '2026-04-15' }, - { jurisdiction: 'California', taxType: 'Sales & Use Tax', amount: '$42,300', paid: '$42,300', period: 'Q4 2025', status: t('tax_status_paid'), dueDate: '2026-01-31' }, - { jurisdiction: 'New York', taxType: 'State Income Tax', amount: '$156,800', paid: '$120,000', period: 'FY 2025', status: t('tax_status_partial'), dueDate: '2026-04-15' }, - { jurisdiction: 'New York', taxType: 'Metropolitan Commuter Tax', amount: '$12,400', paid: '$0', period: 'FY 2025', status: t('tax_status_unpaid'), dueDate: '2026-04-15' }, - { jurisdiction: 'Texas', taxType: 'Franchise Tax', amount: '$34,600', paid: '$34,600', period: 'FY 2025', status: t('tax_status_paid'), dueDate: '2026-05-15' }, - { jurisdiction: 'Florida', taxType: 'Sales Tax', amount: '$28,900', paid: '$28,900', period: 'Q4 2025', status: t('tax_status_paid'), dueDate: '2026-01-31' }, - { jurisdiction: 'Federal', taxType: 'Estimated Tax (Q1 2026)', amount: '$263,190', paid: '$0', period: 'Q1 2026', status: t('tax_status_unpaid'), dueDate: '2026-04-15' }, -]; - -const taxTypeBreakdown = [ - { type: 'Income Tax (所得税)', federal: '$425,000', state: '$344,300', total: '$769,300', percentage: 61.7 }, - { type: 'Sales/Use Tax (销售税)', federal: '-', state: '$71,200', total: '$71,200', percentage: 5.7 }, - { type: 'Employment/Withholding Tax (预扣税)', federal: '$68,200', state: '$45,600', total: '$113,800', percentage: 9.1 }, - { type: 'Transaction Tax (交易税)', federal: '$28,400', state: '$0', total: '$28,400', percentage: 2.3 }, - { type: 'Estimated Tax (预估税)', federal: '$263,190', state: '$0', total: '$263,190', percentage: 21.2 }, -]; - -const irsFilings = [ - { form: 'Form 1120', description: 'Corporate Income Tax', taxYear: '2025', deadline: '2026-04-15', status: t('tax_filing_preparing'), filedDate: '-' }, - { form: 'Form 1099-K', description: 'Payment Card & Third-party Network', taxYear: '2025', deadline: '2026-01-31', status: t('tax_filing_submitted'), filedDate: '2026-01-28' }, - { form: 'Form 1099-MISC', description: 'Miscellaneous Income (Contractors)', taxYear: '2025', deadline: '2026-01-31', status: t('tax_filing_submitted'), filedDate: '2026-01-29' }, - { form: 'Form 1099-NEC', description: 'Non-employee Compensation', taxYear: '2025', deadline: '2026-01-31', status: t('tax_filing_submitted'), filedDate: '2026-01-30' }, - { form: 'Form 941', description: 'Employer Quarterly Federal Tax', taxYear: 'Q4 2025', deadline: '2026-01-31', status: t('tax_filing_submitted'), filedDate: '2026-01-25' }, - { form: 'Form W-2', description: 'Wage & Tax Statement', taxYear: '2025', deadline: '2026-01-31', status: t('tax_filing_submitted'), filedDate: '2026-01-27' }, - { form: 'Form 1042-S', description: 'Foreign Person Withholding', taxYear: '2025', deadline: '2026-03-15', status: t('tax_filing_preparing'), filedDate: '-' }, - { form: 'Form 8300', description: 'Cash Payments Over $10,000', taxYear: '2025', deadline: '15 days after txn', status: t('tax_filing_on_demand'), filedDate: '-' }, -]; - -const taxDeadlines = [ - { date: '2026-01-31', event: 'Form 1099-K/1099-MISC/W-2 提交截止', done: true }, - { date: '2026-01-31', event: 'Q4 Employment Tax (Form 941) 截止', done: true }, - { date: '2026-03-15', event: 'Form 1042-S 外国人预扣税申报截止', done: false }, - { date: '2026-04-15', event: 'Form 1120 公司所得税申报截止', done: false }, - { date: '2026-04-15', event: 'Q1 2026 Estimated Tax Payment', done: false }, - { date: '2026-04-15', event: 'CA/NY State Income Tax 截止', done: false }, - { date: '2026-05-15', event: 'Texas Franchise Tax 截止', done: false }, - { date: '2026-06-15', event: 'Q2 2026 Estimated Tax Payment', done: false }, -]; +const loadingBox: React.CSSProperties = { + display: 'flex', alignItems: 'center', justifyContent: 'center', + padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)', +}; const getPaymentStatusStyle = (status: string) => { switch (status) { - case t('tax_status_paid'): - return { background: 'var(--color-success-light)', color: 'var(--color-success)' }; - case t('tax_status_partial'): - return { background: 'var(--color-warning-light)', color: 'var(--color-warning)' }; - case t('tax_status_unpaid'): - return { background: 'var(--color-error-light)', color: 'var(--color-error)' }; - default: - return { background: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' }; + case 'paid': return { background: 'var(--color-success-light)', color: 'var(--color-success)' }; + case 'partial': return { background: 'var(--color-warning-light)', color: 'var(--color-warning)' }; + case 'unpaid': return { background: 'var(--color-error-light)', color: 'var(--color-error)' }; + default: return { background: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' }; } }; +const getPaymentStatusLabel = (status: string) => { + const map: Record string> = { + paid: () => t('tax_status_paid'), + partial: () => t('tax_status_partial'), + unpaid: () => t('tax_status_unpaid'), + }; + return map[status]?.() ?? status; +}; + const getFilingStatusStyle = (status: string) => { switch (status) { - case t('tax_filing_submitted'): - return { background: 'var(--color-success-light)', color: 'var(--color-success)' }; - case t('tax_filing_preparing'): - return { background: 'var(--color-warning-light)', color: 'var(--color-warning)' }; - case t('tax_filing_on_demand'): - return { background: 'var(--color-info-light)', color: 'var(--color-info)' }; - case t('tax_filing_overdue'): - return { background: 'var(--color-error-light)', color: 'var(--color-error)' }; - default: - return { background: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' }; + case 'submitted': return { background: 'var(--color-success-light)', color: 'var(--color-success)' }; + case 'preparing': return { background: 'var(--color-warning-light)', color: 'var(--color-warning)' }; + case 'on_demand': return { background: 'var(--color-info-light)', color: 'var(--color-info)' }; + case 'overdue': return { background: 'var(--color-error-light)', color: 'var(--color-error)' }; + default: return { background: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' }; } }; +const getFilingStatusLabel = (status: string) => { + const map: Record string> = { + submitted: () => t('tax_filing_submitted'), + preparing: () => t('tax_filing_preparing'), + on_demand: () => t('tax_filing_on_demand'), + overdue: () => t('tax_filing_overdue'), + }; + return map[status]?.() ?? status; +}; + export const TaxCompliancePage: React.FC = () => { + const { data, isLoading, error } = useApi('/api/v1/admin/compliance/tax'); + + if (error) return
Error: {error.message}
; + if (isLoading) return
Loading...
; + + const taxStats = data?.stats ?? []; + const taxObligations = data?.obligations ?? []; + const taxTypeBreakdown = data?.typeBreakdown ?? []; + const irsFilings = data?.irsFilings ?? []; + const taxDeadlines = data?.deadlines ?? []; + return (

{t('tax_title')}

+ padding: '8px 16px', border: 'none', borderRadius: 'var(--radius-full)', + background: 'var(--color-primary)', color: 'white', cursor: 'pointer', font: 'var(--text-label-sm)', + }}>{t('tax_export_report')}
{/* Stats */} @@ -116,7 +94,7 @@ export const TaxCompliancePage: React.FC = () => { ))}
- {/* Tax Obligations by Jurisdiction */} + {/* Tax Obligations */}
{
- - + + ))} @@ -160,9 +138,8 @@ export const TaxCompliancePage: React.FC = () => {
{ctrl.lastTest} {ctrl.nextTest} {d.description} {d.dueDate} {d.status} + background: d.status === 'remediating' ? 'var(--color-warning-light)' : 'var(--color-error-light)', + color: d.status === 'remediating' ? 'var(--color-warning)' : 'var(--color-error)', + }}> + {d.status === 'remediating' ? t('sox_status_remediating') : t('sox_status_pending')} + {d.owner}
{tax.taxType} {tax.period}{tax.amount}{tax.paid}${tax.amount?.toLocaleString()}${tax.paid?.toLocaleString()} {tax.dueDate} {tax.status} + }}>{getPaymentStatusLabel(tax.status)}
- {/* Tax Type Breakdown + IRS Filings */} + {/* Tax Type Breakdown + Tax Calendar */}
- {/* Tax Type Breakdown */}
{
- {/* Tax Calendar / Deadlines */}

{t('tax_calendar')}

{taxDeadlines.map((evt, i) => ( @@ -261,7 +237,7 @@ export const TaxCompliancePage: React.FC = () => { {f.status} + }}>{getFilingStatusLabel(f.status)} ))} diff --git a/frontend/admin-web/src/views/coupons/CouponManagementPage.tsx b/frontend/admin-web/src/views/coupons/CouponManagementPage.tsx index 6dbe486..5470f84 100644 --- a/frontend/admin-web/src/views/coupons/CouponManagementPage.tsx +++ b/frontend/admin-web/src/views/coupons/CouponManagementPage.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { t } from '@/i18n/locales'; +import { useApi, useApiMutation } from '@/lib/use-api'; /** * D2. 券管理 - 平台券审核与管理 @@ -22,12 +23,10 @@ interface CouponBatch { createdAt: string; } -const mockCoupons: CouponBatch[] = [ - { id: 'C001', issuer: 'Starbucks', name: '¥25 礼品卡', template: '礼品卡', faceValue: 25, quantity: 5000, sold: 4200, redeemed: 3300, status: 'active', createdAt: '2026-01-15' }, - { id: 'C002', issuer: 'Amazon', name: '¥100 购物券', template: '代金券', faceValue: 100, quantity: 2000, sold: 1580, redeemed: 980, status: 'active', createdAt: '2026-01-20' }, - { id: 'C003', issuer: 'Nike', name: '8折运动券', template: '折扣券', faceValue: 80, quantity: 1000, sold: 0, redeemed: 0, status: 'pending', createdAt: '2026-02-08' }, - { id: 'C004', issuer: 'Walmart', name: '¥50 生活券', template: '代金券', faceValue: 50, quantity: 3000, sold: 3000, redeemed: 2800, status: 'expired', createdAt: '2025-08-01' }, -]; +interface CouponsResponse { + items: CouponBatch[]; + total: number; +} const statusColors: Record = { pending: 'var(--color-warning)', @@ -35,17 +34,41 @@ const statusColors: Record = { suspended: 'var(--color-error)', expired: 'var(--color-text-tertiary)', }; -const statusLabels: Record = { - pending: t('coupon_pending_review'), - active: t('coupon_active'), - suspended: t('coupon_suspended'), - expired: t('coupon_expired'), + +const loadingBox: React.CSSProperties = { + display: 'flex', alignItems: 'center', justifyContent: 'center', + padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)', }; export const CouponManagementPage: React.FC = () => { const [filter, setFilter] = useState('all'); + const [page] = useState(1); + const [limit] = useState(20); - const filtered = filter === 'all' ? mockCoupons : mockCoupons.filter(c => c.status === filter); + const { data, isLoading, error } = useApi('/api/v1/admin/coupons', { + params: { page, limit, status: filter === 'all' ? undefined : filter }, + }); + + const approveMutation = useApiMutation('POST', '', { + invalidateKeys: ['/api/v1/admin/coupons'], + }); + + const rejectMutation = useApiMutation('POST', '', { + invalidateKeys: ['/api/v1/admin/coupons'], + }); + + const suspendMutation = useApiMutation('POST', '', { + invalidateKeys: ['/api/v1/admin/coupons'], + }); + + const coupons = data?.items ?? []; + + const statusLabels: Record = { + pending: t('coupon_pending_review'), + active: t('coupon_active'), + suspended: t('coupon_suspended'), + expired: t('coupon_expired'), + }; return (
@@ -66,49 +89,61 @@ export const CouponManagementPage: React.FC = () => {
{/* Coupon Table */} -
- - - - {[t('coupon_id'), t('coupon_issuer'), t('coupon_name'), t('coupon_template'), t('coupon_face_value'), t('coupon_quantity'), t('coupon_sold'), t('coupon_redeemed'), t('status'), t('actions')].map(h => ( - - ))} - - - - {filtered.map(coupon => ( - - - - - - - - - - - + {error ? ( +
Error: {error.message}
+ ) : isLoading ? ( +
Loading...
+ ) : ( +
+
{h}
{coupon.id}{coupon.issuer}{coupon.name}{coupon.template}${coupon.faceValue}{coupon.quantity.toLocaleString()}{coupon.sold.toLocaleString()}{coupon.redeemed.toLocaleString()} - {statusLabels[coupon.status]} - - {coupon.status === 'pending' && ( -
- - -
- )} - {coupon.status === 'active' && ( - - )} -
+ + + {[t('coupon_id'), t('coupon_issuer'), t('coupon_name'), t('coupon_template'), t('coupon_face_value'), t('coupon_quantity'), t('coupon_sold'), t('coupon_redeemed'), t('status'), t('actions')].map(h => ( + + ))} - ))} - -
{h}
-
+ + + {coupons.map(coupon => ( + + {coupon.id} + {coupon.issuer} + {coupon.name} + {coupon.template} + ${coupon.faceValue} + {coupon.quantity.toLocaleString()} + {coupon.sold.toLocaleString()} + {coupon.redeemed.toLocaleString()} + + {statusLabels[coupon.status]} + + + {coupon.status === 'pending' && ( +
+ + +
+ )} + {coupon.status === 'active' && ( + + )} + + + ))} + + +
+ )}
); }; diff --git a/frontend/admin-web/src/views/dashboard/DashboardPage.tsx b/frontend/admin-web/src/views/dashboard/DashboardPage.tsx index 48eb14c..a3c47a0 100644 --- a/frontend/admin-web/src/views/dashboard/DashboardPage.tsx +++ b/frontend/admin-web/src/views/dashboard/DashboardPage.tsx @@ -1,5 +1,8 @@ +'use client'; + import React from 'react'; import { t } from '@/i18n/locales'; +import { useApi } from '@/lib/use-api'; /** * D1. 平台运营仪表盘 @@ -8,24 +11,60 @@ import { t } from '@/i18n/locales'; * 实时交易流、系统健康状态 */ -interface StatCard { - label: string; - value: string; - change: string; - trend: 'up' | 'down'; - color: string; +interface DashboardStats { + totalVolume: number; + totalAmount: number; + activeUsers: number; + issuerCount: number; + couponCirculation: number; + systemHealthPercent: number; + totalVolumeChange: string; + totalAmountChange: string; + activeUsersChange: string; + issuerCountChange: string; + couponCirculationChange: string; } -const stats: StatCard[] = [ - { label: t('dashboard_total_volume'), value: '156,890', change: '+12.3%', trend: 'up', color: 'var(--color-primary)' }, - { label: t('dashboard_total_amount'), value: '$4,523,456', change: '+8.7%', trend: 'up', color: 'var(--color-success)' }, - { label: t('dashboard_active_users'), value: '28,456', change: '+5.2%', trend: 'up', color: 'var(--color-info)' }, - { label: t('dashboard_issuer_count'), value: '342', change: '+15', trend: 'up', color: 'var(--color-warning)' }, - { label: t('dashboard_coupon_circulation'), value: '1,234,567', change: '-2.1%', trend: 'down', color: 'var(--color-primary-dark)' }, - { label: t('dashboard_system_health'), value: '99.97%', change: 'Normal', trend: 'up', color: 'var(--color-success)' }, -]; +interface RealtimeTrade { + time: string; + type: string; + orderId: string; + amount: number; + status: string; +} + +interface ServiceHealth { + name: string; + status: 'healthy' | 'warning' | 'error'; + latency: string; +} + +const loadingBox: React.CSSProperties = { + display: 'flex', alignItems: 'center', justifyContent: 'center', + padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)', +}; export const DashboardPage: React.FC = () => { + const { data: statsData, isLoading: statsLoading, error: statsError } = useApi('/api/v1/admin/dashboard/stats'); + const { data: tradesData, isLoading: tradesLoading } = useApi('/api/v1/admin/dashboard/realtime-trades'); + const { data: healthData, isLoading: healthLoading } = useApi('/api/v1/admin/dashboard/system-health'); + + const formatNumber = (n: number) => n?.toLocaleString() ?? '-'; + const formatCurrency = (n: number) => `$${n?.toLocaleString() ?? '0'}`; + + const stats = statsData ? [ + { label: t('dashboard_total_volume'), value: formatNumber(statsData.totalVolume), change: statsData.totalVolumeChange, trend: statsData.totalVolumeChange?.startsWith('-') ? 'down' as const : 'up' as const, color: 'var(--color-primary)' }, + { label: t('dashboard_total_amount'), value: formatCurrency(statsData.totalAmount), change: statsData.totalAmountChange, trend: statsData.totalAmountChange?.startsWith('-') ? 'down' as const : 'up' as const, color: 'var(--color-success)' }, + { label: t('dashboard_active_users'), value: formatNumber(statsData.activeUsers), change: statsData.activeUsersChange, trend: statsData.activeUsersChange?.startsWith('-') ? 'down' as const : 'up' as const, color: 'var(--color-info)' }, + { label: t('dashboard_issuer_count'), value: formatNumber(statsData.issuerCount), change: statsData.issuerCountChange, trend: statsData.issuerCountChange?.startsWith('-') ? 'down' as const : 'up' as const, color: 'var(--color-warning)' }, + { label: t('dashboard_coupon_circulation'), value: formatNumber(statsData.couponCirculation), change: statsData.couponCirculationChange, trend: statsData.couponCirculationChange?.startsWith('-') ? 'down' as const : 'up' as const, color: 'var(--color-primary-dark)' }, + { label: t('dashboard_system_health'), value: `${statsData.systemHealthPercent}%`, change: 'Normal', trend: 'up' as const, color: 'var(--color-success)' }, + ] : []; + + if (statsError) { + return
Error: {statsError.message}
; + } + return (

@@ -39,7 +78,9 @@ export const DashboardPage: React.FC = () => { gap: 16, marginBottom: 24, }}> - {stats.map(stat => ( + {statsLoading ? ( +
Loading...
+ ) : stats.map(stat => (
{ animation: 'pulse 2s infinite', }} />
- - - - {[t('dashboard_th_time'), t('dashboard_th_type'), t('dashboard_th_order'), t('dashboard_th_amount'), t('dashboard_th_status')].map(h => ( - - ))} - - - - {[ - { time: '14:32:15', type: t('dashboard_type_purchase'), order: 'GNX-20260210-001234', amount: '$21.25', status: t('dashboard_status_completed') }, - { time: '14:31:58', type: t('dashboard_type_redeem'), order: 'GNX-20260210-001233', amount: '$50.00', status: t('dashboard_status_completed') }, - { time: '14:31:42', type: t('dashboard_type_resell'), order: 'GNX-20260210-001232', amount: '$85.00', status: t('dashboard_status_completed') }, - { time: '14:31:20', type: t('dashboard_type_purchase'), order: 'GNX-20260210-001231', amount: '$42.50', status: t('dashboard_status_processing') }, - { time: '14:30:55', type: t('dashboard_type_transfer'), order: 'GNX-20260210-001230', amount: '$30.00', status: t('dashboard_status_completed') }, - ].map((row, i) => ( - - - - - - + {tradesLoading ? ( +
Loading...
+ ) : ( +
{h}
{row.time}{row.type}{row.order}{row.amount} - - {row.status} - -
+ + + {[t('dashboard_th_time'), t('dashboard_th_type'), t('dashboard_th_order'), t('dashboard_th_amount'), t('dashboard_th_status')].map(h => ( + + ))} - ))} - -
{h}
+ + + {(tradesData ?? []).map((row, i) => ( + + {row.time} + {row.type} + {row.orderId} + ${row.amount?.toFixed(2)} + + + {row.status === 'completed' ? t('dashboard_status_completed') : t('dashboard_status_processing')} + + + + ))} + + + )}

{/* System Health */} @@ -178,13 +217,9 @@ export const DashboardPage: React.FC = () => { padding: 20, }}>
{t('dashboard_system_health')}
- {[ - { name: t('dashboard_service_api'), status: 'healthy', latency: '12ms' }, - { name: t('dashboard_service_db'), status: 'healthy', latency: '3ms' }, - { name: 'Genex Chain', status: 'healthy', latency: '156ms' }, - { name: t('dashboard_service_cache'), status: 'healthy', latency: '1ms' }, - { name: t('dashboard_service_mq'), status: 'warning', latency: '45ms' }, - ].map(service => ( + {healthLoading ? ( +
Loading...
+ ) : (healthData ?? []).map(service => (
{ {service.name} diff --git a/frontend/admin-web/src/views/disputes/DisputePage.tsx b/frontend/admin-web/src/views/disputes/DisputePage.tsx index 3bcd98d..e66ff4b 100644 --- a/frontend/admin-web/src/views/disputes/DisputePage.tsx +++ b/frontend/admin-web/src/views/disputes/DisputePage.tsx @@ -1,5 +1,8 @@ +'use client'; + import React from 'react'; import { t } from '@/i18n/locales'; +import { useApi } from '@/lib/use-api'; /** * D8. 争议处理 @@ -20,28 +23,44 @@ interface Dispute { sla: string; } -const mockDisputes: Dispute[] = [ - { id: 'DSP-001', type: t('dispute_type_buyer'), order: 'GNX-20260208-001200', plaintiff: 'U-012', defendant: 'U-045', amount: '$85.00', status: 'pending', createdAt: '2026-02-09', sla: '23h' }, - { id: 'DSP-002', type: t('dispute_type_refund'), order: 'GNX-20260207-001150', plaintiff: 'U-023', defendant: '-', amount: '$42.50', status: 'processing', createdAt: '2026-02-08', sla: '6h' }, - { id: 'DSP-003', type: t('dispute_type_seller'), order: 'GNX-20260206-001100', plaintiff: 'U-078', defendant: 'U-091', amount: '$120.00', status: 'pending', createdAt: '2026-02-07', sla: '47h' }, - { id: 'DSP-004', type: t('dispute_type_buyer'), order: 'GNX-20260205-001050', plaintiff: 'U-034', defendant: 'U-056', amount: '$30.00', status: 'resolved', createdAt: '2026-02-05', sla: '-' }, - { id: 'DSP-005', type: t('dispute_type_refund'), order: 'GNX-20260204-001000', plaintiff: 'U-067', defendant: '-', amount: '$21.25', status: 'rejected', createdAt: '2026-02-04', sla: '-' }, -]; +interface DisputeData { + items: Dispute[]; + summary: { pending: number; processing: number; resolvedToday: number }; +} -const statusConfig: Record = { - pending: { label: t('dispute_pending'), bg: 'var(--color-warning-light)', color: 'var(--color-warning)' }, - processing: { label: t('dispute_processing'), bg: 'var(--color-info-light)', color: 'var(--color-info)' }, - resolved: { label: t('dispute_resolved'), bg: 'var(--color-success-light)', color: 'var(--color-success)' }, - rejected: { label: t('dispute_rejected'), bg: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' }, +const loadingBox: React.CSSProperties = { + display: 'flex', alignItems: 'center', justifyContent: 'center', + padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)', }; -const typeConfig: Record = { - [t('dispute_type_buyer')]: { bg: 'var(--color-error-light)', color: 'var(--color-error)' }, - [t('dispute_type_seller')]: { bg: 'var(--color-warning-light)', color: 'var(--color-warning)' }, - [t('dispute_type_refund')]: { bg: 'var(--color-info-light)', color: 'var(--color-info)' }, +const getStatusConfig = (status: string) => { + const map: Record string; bg: string; color: string }> = { + pending: { label: () => t('dispute_pending'), bg: 'var(--color-warning-light)', color: 'var(--color-warning)' }, + processing: { label: () => t('dispute_processing'), bg: 'var(--color-info-light)', color: 'var(--color-info)' }, + resolved: { label: () => t('dispute_resolved'), bg: 'var(--color-success-light)', color: 'var(--color-success)' }, + rejected: { label: () => t('dispute_rejected'), bg: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' }, + }; + return map[status] ?? { label: () => status, bg: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' }; +}; + +const getTypeConfig = (type: string) => { + switch (type) { + case 'buyer': return { bg: 'var(--color-error-light)', color: 'var(--color-error)', label: () => t('dispute_type_buyer') }; + case 'seller': return { bg: 'var(--color-warning-light)', color: 'var(--color-warning)', label: () => t('dispute_type_seller') }; + case 'refund': return { bg: 'var(--color-info-light)', color: 'var(--color-info)', label: () => t('dispute_type_refund') }; + default: return { bg: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)', label: () => type }; + } }; export const DisputePage: React.FC = () => { + const { data, isLoading, error } = useApi('/api/v1/admin/disputes'); + + if (error) return
Error: {error.message}
; + if (isLoading) return
Loading...
; + + const disputes = data?.items ?? []; + const summary = data?.summary ?? { pending: 0, processing: 0, resolvedToday: 0 }; + return (
@@ -49,9 +68,9 @@ export const DisputePage: React.FC = () => {
{/* Stats */} {[ - { label: t('dispute_pending'), value: '3', color: 'var(--color-warning)' }, - { label: t('dispute_processing'), value: '1', color: 'var(--color-info)' }, - { label: t('dispute_resolved_today'), value: '5', color: 'var(--color-success)' }, + { label: t('dispute_pending'), value: String(summary.pending), color: 'var(--color-warning)' }, + { label: t('dispute_processing'), value: String(summary.processing), color: 'var(--color-info)' }, + { label: t('dispute_resolved_today'), value: String(summary.resolvedToday), color: 'var(--color-success)' }, ].map(s => (
{ - {mockDisputes.map(d => { - const sc = statusConfig[d.status]; - const tc = typeConfig[d.type]; + {disputes.map(d => { + const sc = getStatusConfig(d.status); + const tc = getTypeConfig(d.type); return ( {d.id} @@ -99,7 +118,7 @@ export const DisputePage: React.FC = () => { {d.type} + }}>{tc.label()} {d.order} {d.plaintiff} @@ -109,7 +128,7 @@ export const DisputePage: React.FC = () => { {sc.label} + }}>{sc.label()} {d.sla !== '-' ? ( diff --git a/frontend/admin-web/src/views/finance/FinanceManagementPage.tsx b/frontend/admin-web/src/views/finance/FinanceManagementPage.tsx index c4db605..1799624 100644 --- a/frontend/admin-web/src/views/finance/FinanceManagementPage.tsx +++ b/frontend/admin-web/src/views/finance/FinanceManagementPage.tsx @@ -1,5 +1,8 @@ +'use client'; + import React from 'react'; import { t } from '@/i18n/locales'; +import { useApi } from '@/lib/use-api'; /** * D3. 财务管理 - 平台级财务总览 @@ -7,28 +10,51 @@ import { t } from '@/i18n/locales'; * 平台收入(手续费)、发行方结算、消费者退款、资金池监控 */ -const financeStats = [ - { label: t('finance_platform_fee'), value: '$234,567', period: t('finance_period_month'), color: 'var(--color-success)' }, - { label: t('finance_pending_settlement'), value: '$1,456,000', period: t('finance_period_cumulative'), color: 'var(--color-warning)' }, - { label: t('finance_consumer_refund'), value: '$12,340', period: t('finance_period_month'), color: 'var(--color-error)' }, - { label: t('finance_pool_balance'), value: '$8,234,567', period: t('finance_period_realtime'), color: 'var(--color-primary)' }, -]; +interface FinanceSummary { + platformFee: number; + pendingSettlement: number; + consumerRefund: number; + poolBalance: number; +} -const recentSettlements = [ - { issuer: 'Starbucks', amount: '$45,200', status: t('finance_status_settled'), time: '2026-02-10 14:00' }, - { issuer: 'Amazon', amount: '$128,000', status: t('finance_status_processing'), time: '2026-02-10 12:00' }, - { issuer: 'Nike', amount: '$23,500', status: t('finance_status_pending'), time: '2026-02-09' }, - { issuer: 'Walmart', amount: '$67,800', status: t('finance_status_settled'), time: '2026-02-08' }, -]; +interface Settlement { + issuer: string; + amount: number; + status: string; + time: string; +} + +const loadingBox: React.CSSProperties = { + display: 'flex', alignItems: 'center', justifyContent: 'center', + padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)', +}; export const FinanceManagementPage: React.FC = () => { + const { data: summaryData, isLoading: summaryLoading, error: summaryError } = useApi('/api/v1/admin/finance/summary'); + const { data: settlementsData, isLoading: settlementsLoading } = useApi('/api/v1/admin/finance/settlements'); + + const formatCurrency = (n: number) => `$${n?.toLocaleString() ?? '0'}`; + + const financeStats = summaryData ? [ + { label: t('finance_platform_fee'), value: formatCurrency(summaryData.platformFee), period: t('finance_period_month'), color: 'var(--color-success)' }, + { label: t('finance_pending_settlement'), value: formatCurrency(summaryData.pendingSettlement), period: t('finance_period_cumulative'), color: 'var(--color-warning)' }, + { label: t('finance_consumer_refund'), value: formatCurrency(summaryData.consumerRefund), period: t('finance_period_month'), color: 'var(--color-error)' }, + { label: t('finance_pool_balance'), value: formatCurrency(summaryData.poolBalance), period: t('finance_period_realtime'), color: 'var(--color-primary)' }, + ] : []; + + if (summaryError) { + return
Error: {summaryError.message}
; + } + return (

{t('finance_title')}

{/* Stats */}
- {financeStats.map(s => ( + {summaryLoading ? ( +
Loading...
+ ) : financeStats.map(s => (
{ border: '1px solid var(--color-border-light)', padding: 20, }}>

{t('finance_settlement_queue')}

- - - {[t('finance_th_issuer'), t('finance_th_amount'), t('finance_th_status'), t('finance_th_time')].map(h => ( - - ))} - - - {recentSettlements.map((s, i) => ( - - - - - - - ))} - -
{h}
{s.issuer}{s.amount} - {s.status} - {s.time}
+ {settlementsLoading ? ( +
Loading...
+ ) : ( + + + {[t('finance_th_issuer'), t('finance_th_amount'), t('finance_th_status'), t('finance_th_time')].map(h => ( + + ))} + + + {(settlementsData ?? []).map((s, i) => ( + + + + + + + ))} + +
{h}
{s.issuer}${s.amount?.toLocaleString()} + + {s.status === 'settled' ? t('finance_status_settled') : s.status === 'processing' ? t('finance_status_processing') : t('finance_status_pending')} + + {s.time}
+ )}
{/* Revenue Chart Placeholder */} diff --git a/frontend/admin-web/src/views/insurance/InsurancePage.tsx b/frontend/admin-web/src/views/insurance/InsurancePage.tsx index 80a6a80..3a3b286 100644 --- a/frontend/admin-web/src/views/insurance/InsurancePage.tsx +++ b/frontend/admin-web/src/views/insurance/InsurancePage.tsx @@ -1,5 +1,8 @@ +'use client'; + import React from 'react'; import { t } from '@/i18n/locales'; +import { useApi } from '@/lib/use-api'; /** * D8. 保险与消费者保护 - 平台保障体系管理 @@ -7,30 +10,47 @@ import { t } from '@/i18n/locales'; * 消费者保护基金、保险机制、赔付记录、IPO准备度 */ -const protectionStats = [ - { label: t('insurance_protection_fund'), value: '$2,345,678', color: 'var(--color-success)' }, - { label: t('insurance_monthly_payout'), value: '$12,340', color: 'var(--color-warning)' }, - { label: t('insurance_payout_rate'), value: '0.08%', color: 'var(--color-info)' }, - { label: t('insurance_ipo_readiness'), value: '72%', color: 'var(--color-primary)' }, -]; +interface InsuranceData { + stats: { label: string; value: string; color: string }[]; + claims: { id: string; user: string; reason: string; amount: string; status: string; date: string }[]; + ipoChecklist: { item: string; status: 'done' | 'progress' | 'pending' }[]; + ipoProgress: number; +} -const recentClaims = [ - { id: 'CLM-001', user: 'User#12345', reason: '发行方破产', amount: '$250', status: t('insurance_status_paid'), date: '2026-02-08' }, - { id: 'CLM-002', user: 'User#23456', reason: '券核销失败', amount: '$100', status: t('insurance_status_processing'), date: '2026-02-09' }, - { id: 'CLM-003', user: 'User#34567', reason: '重复扣款', amount: '$42.50', status: t('insurance_status_paid'), date: '2026-02-07' }, -]; +const loadingBox: React.CSSProperties = { + display: 'flex', alignItems: 'center', justifyContent: 'center', + padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)', +}; -const ipoChecklist = [ - { item: 'SOX合规审计', status: 'done' }, - { item: '消费者保护机制', status: 'done' }, - { item: 'AML/KYC合规体系', status: 'done' }, - { item: 'SEC披露文件准备', status: 'progress' }, - { item: '独立审计报告', status: 'progress' }, - { item: '市场做市商协议', status: 'pending' }, - { item: '牌照申请', status: 'pending' }, -]; +const getClaimStatusStyle = (status: string) => { + switch (status) { + case 'paid': return { background: 'var(--color-success-light)', color: 'var(--color-success)' }; + case 'processing': return { background: 'var(--color-warning-light)', color: 'var(--color-warning)' }; + case 'rejected': return { background: 'var(--color-error-light)', color: 'var(--color-error)' }; + default: return { background: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' }; + } +}; + +const getClaimStatusLabel = (status: string) => { + const map: Record string> = { + paid: () => t('insurance_status_paid'), + processing: () => t('insurance_status_processing'), + rejected: () => t('insurance_status_rejected'), + }; + return map[status]?.() ?? status; +}; export const InsurancePage: React.FC = () => { + const { data, isLoading, error } = useApi('/api/v1/admin/insurance/stats'); + + if (error) return
Error: {error.message}
; + if (isLoading) return
Loading...
; + + const protectionStats = data?.stats ?? []; + const recentClaims = data?.claims ?? []; + const ipoChecklist = data?.ipoChecklist ?? []; + const ipoProgress = data?.ipoProgress ?? 0; + return (

{t('insurance_title')}

@@ -68,9 +88,8 @@ export const InsurancePage: React.FC = () => { {c.status} + ...getClaimStatusStyle(c.status), + }}>{getClaimStatusLabel(c.status)} ))} @@ -88,7 +107,7 @@ export const InsurancePage: React.FC = () => { background: item.status === 'done' ? 'var(--color-success)' : item.status === 'progress' ? 'var(--color-warning)' : 'var(--color-gray-200)', color: item.status === 'pending' ? 'var(--color-text-tertiary)' : 'white', fontSize: 12, }}> - {item.status === 'done' ? '✓' : item.status === 'progress' ? '…' : '○'} + {item.status === 'done' ? '✓' : item.status === 'progress' ? '...' : '○'}
{item.item} {
{t('overall_progress')} - 72% + {ipoProgress}%
-
+
diff --git a/frontend/admin-web/src/views/issuers/IssuerManagementPage.tsx b/frontend/admin-web/src/views/issuers/IssuerManagementPage.tsx index 69bc325..292cde4 100644 --- a/frontend/admin-web/src/views/issuers/IssuerManagementPage.tsx +++ b/frontend/admin-web/src/views/issuers/IssuerManagementPage.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { t } from '@/i18n/locales'; +import { useApi, useApiMutation } from '@/lib/use-api'; /** * D2. 发行方管理 @@ -16,21 +17,38 @@ interface Issuer { status: 'pending' | 'approved' | 'rejected'; submittedAt: string; couponCount: number; - totalVolume: string; + totalVolume: number; } -const mockIssuers: Issuer[] = [ - { id: 'ISS-001', name: 'Starbucks Inc.', creditRating: 'AAA', status: 'approved', submittedAt: '2026-01-15', couponCount: 12, totalVolume: '$128,450' }, - { id: 'ISS-002', name: 'Amazon Corp.', creditRating: 'AA', status: 'approved', submittedAt: '2026-01-20', couponCount: 8, totalVolume: '$456,000' }, - { id: 'ISS-003', name: 'NewBrand LLC', creditRating: '-', status: 'pending', submittedAt: '2026-02-09', couponCount: 0, totalVolume: '-' }, - { id: 'ISS-004', name: 'Target Corp.', creditRating: 'A', status: 'approved', submittedAt: '2026-01-25', couponCount: 5, totalVolume: '$67,200' }, - { id: 'ISS-005', name: 'FakeStore Inc.', creditRating: '-', status: 'rejected', submittedAt: '2026-02-05', couponCount: 0, totalVolume: '-' }, -]; +interface IssuersResponse { + items: Issuer[]; + total: number; +} + +const loadingBox: React.CSSProperties = { + display: 'flex', alignItems: 'center', justifyContent: 'center', + padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)', +}; export const IssuerManagementPage: React.FC = () => { const [tab, setTab] = useState<'all' | 'pending' | 'approved' | 'rejected'>('all'); + const [page] = useState(1); + const [limit] = useState(20); - const filtered = tab === 'all' ? mockIssuers : mockIssuers.filter(i => i.status === tab); + const { data, isLoading, error } = useApi('/api/v1/admin/issuers', { + params: { page, limit, status: tab === 'all' ? undefined : tab }, + }); + + const approveMutation = useApiMutation('POST', '', { + invalidateKeys: ['/api/v1/admin/issuers'], + }); + + const rejectMutation = useApiMutation('POST', '', { + invalidateKeys: ['/api/v1/admin/issuers'], + }); + + const issuers = data?.items ?? []; + const pendingCount = issuers.filter(i => i.status === 'pending').length; const creditColor = (rating: string) => { const map: Record = { @@ -56,6 +74,8 @@ export const IssuerManagementPage: React.FC = () => { return map[status] || status; }; + const formatCurrency = (n: number) => n > 0 ? `$${n.toLocaleString()}` : '-'; + return (
@@ -92,7 +112,7 @@ export const IssuerManagementPage: React.FC = () => { }} > {tabKey === 'all' ? t('all') : statusLabel(tabKey)} - {tabKey === 'pending' && ( + {tabKey === 'pending' && pendingCount > 0 && ( { borderRadius: 'var(--radius-full)', fontSize: 10, }}> - {mockIssuers.filter(i => i.status === 'pending').length} + {pendingCount} )} @@ -108,99 +128,123 @@ export const IssuerManagementPage: React.FC = () => {
{/* Table */} -
- - - - {['ID', t('issuer_company_name'), t('issuer_credit_rating'), t('status'), t('issuer_submit_time'), t('issuer_coupon_count'), t('issuer_total_volume'), t('actions')].map(h => ( - - ))} - - - - {filtered.map(issuer => { - const ss = statusStyle(issuer.status); - return ( - - - - - - - - - + + ); + })} + +
{h}
- {issuer.id} - {issuer.name} - - {issuer.creditRating} - - - - {statusLabel(issuer.status)} - - - {issuer.submittedAt} - {issuer.couponCount} - {issuer.totalVolume} - - - {issuer.status === 'pending' && ( + {error ? ( +
Error: {error.message}
+ ) : isLoading ? ( +
Loading...
+ ) : ( +
+ + + + {['ID', t('issuer_company_name'), t('issuer_credit_rating'), t('status'), t('issuer_submit_time'), t('issuer_coupon_count'), t('issuer_total_volume'), t('actions')].map(h => ( + + ))} + + + + {issuers.map(issuer => { + const ss = statusStyle(issuer.status); + return ( + + + + + + + + + - - ); - })} - -
{h}
+ {issuer.id} + {issuer.name} + + {issuer.creditRating || '-'} + + + + {statusLabel(issuer.status)} + + + {issuer.submittedAt} + {issuer.couponCount} + {formatCurrency(issuer.totalVolume)} + - )} -
-
+ {issuer.status === 'pending' && ( + <> + + + + )} +
+
+ )}
); }; diff --git a/frontend/admin-web/src/views/merchant/MerchantRedemptionPage.tsx b/frontend/admin-web/src/views/merchant/MerchantRedemptionPage.tsx index b1f1c39..074abfd 100644 --- a/frontend/admin-web/src/views/merchant/MerchantRedemptionPage.tsx +++ b/frontend/admin-web/src/views/merchant/MerchantRedemptionPage.tsx @@ -1,5 +1,8 @@ +'use client'; + import React from 'react'; import { t } from '@/i18n/locales'; +import { useApi } from '@/lib/use-api'; /** * D6. 商户核销管理 - 平台视角的核销数据 @@ -7,22 +10,27 @@ import { t } from '@/i18n/locales'; * 核销统计、门店核销排行、异常核销检测 */ -const redemptionStats = [ - { label: t('merchant_today_redemption'), value: '1,234', change: '+15%', color: 'var(--color-success)' }, - { label: t('merchant_today_amount'), value: '$45,600', change: '+8%', color: 'var(--color-primary)' }, - { label: t('merchant_active_stores'), value: '89', change: '+3', color: 'var(--color-info)' }, - { label: t('merchant_abnormal_redemption'), value: '2', change: t('merchant_need_review'), color: 'var(--color-error)' }, -]; +interface MerchantData { + stats: { label: string; value: string; change: string; color: string }[]; + topStores: { rank: number; store: string; count: number; amount: string }[]; + realtimeFeed: { store: string; coupon: string; time: string }[]; +} -const topStores = [ - { rank: 1, store: 'Starbucks 徐汇店', count: 156, amount: '$3,900' }, - { rank: 2, store: 'Amazon Locker #A23', count: 98, amount: '$9,800' }, - { rank: 3, store: 'Nike 南京西路店', count: 67, amount: '$5,360' }, - { rank: 4, store: 'Walmart 浦东店', count: 45, amount: '$2,250' }, - { rank: 5, store: 'Target Downtown', count: 34, amount: '$1,020' }, -]; +const loadingBox: React.CSSProperties = { + display: 'flex', alignItems: 'center', justifyContent: 'center', + padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)', +}; export const MerchantRedemptionPage: React.FC = () => { + const { data, isLoading, error } = useApi('/api/v1/admin/merchant/stats'); + + if (error) return
Error: {error.message}
; + if (isLoading) return
Loading...
; + + const redemptionStats = data?.stats ?? []; + const topStores = data?.topStores ?? []; + const realtimeFeed = data?.realtimeFeed ?? []; + return (

{t('merchant_title')}

@@ -65,13 +73,7 @@ export const MerchantRedemptionPage: React.FC = () => { {/* Realtime Feed */}

{t('merchant_realtime_feed')}

- {[ - { store: 'Starbucks 徐汇店', coupon: '¥25 礼品卡', time: '刚刚' }, - { store: 'Nike 南京西路店', coupon: '¥80 运动券', time: '1分钟前' }, - { store: 'Amazon Locker #A23', coupon: '¥100 购物券', time: '3分钟前' }, - { store: 'Starbucks 徐汇店', coupon: '¥25 礼品卡 x2', time: '5分钟前' }, - { store: 'Walmart 浦东店', coupon: '¥50 生活券', time: '8分钟前' }, - ].map((r, i) => ( + {realtimeFeed.map((r, i) => (
diff --git a/frontend/admin-web/src/views/reports/ReportsPage.tsx b/frontend/admin-web/src/views/reports/ReportsPage.tsx index 1d9643e..5693d51 100644 --- a/frontend/admin-web/src/views/reports/ReportsPage.tsx +++ b/frontend/admin-web/src/views/reports/ReportsPage.tsx @@ -1,5 +1,8 @@ +'use client'; + import React from 'react'; import { t } from '@/i18n/locales'; +import { useApi } from '@/lib/use-api'; /** * D5. 报表中心 - 运营报表、合规报表、数据导出 @@ -8,59 +11,60 @@ import { t } from '@/i18n/locales'; * 包括:SOX审计、SEC Filing、税务合规 */ -const reportCategories = [ - { - title: t('reports_operations'), - icon: '📊', - reports: [ - { name: '日度运营报表', desc: '交易量/金额/用户/核销率', status: t('reports_status_generated'), date: '2026-02-10' }, - { name: '周度运营报表', desc: '周趋势分析', status: t('reports_status_generated'), date: '2026-02-09' }, - { name: '月度运营报表', desc: '月度综合分析', status: t('reports_status_generated'), date: '2026-01-31' }, - ], - }, - { - title: t('reports_compliance'), - icon: '📋', - reports: [ - { name: 'SAR可疑活动报告', desc: '本月可疑交易汇总', status: t('reports_status_pending_review'), date: '2026-02-10' }, - { name: 'CTR大额交易报告', desc: '>$10,000交易申报', status: t('reports_status_submitted'), date: '2026-02-10' }, - { name: 'OFAC筛查报告', desc: '制裁名单筛查结果', status: t('reports_status_generated'), date: '2026-02-09' }, - ], - }, - { - title: t('reports_financial'), - icon: '💰', - reports: [ - { name: '发行方结算报表', desc: '各发行方结算明细', status: t('reports_status_generated'), date: '2026-02-10' }, - { name: '平台收入报表', desc: '手续费/Breakage收入', status: t('reports_status_generated'), date: '2026-01-31' }, - { name: '税务合规报表', desc: '1099-K/消费税汇总', status: t('reports_status_pending_generate'), date: '' }, - ], - }, - { - title: t('reports_audit'), - icon: '🔍', - reports: [ - { name: 'SOX合规检查', desc: '内部控制审计', status: t('reports_status_passed'), date: '2026-01-15' }, - { name: 'SEC Filing', desc: '证券类披露(预留)', status: 'N/A', date: '' }, - { name: '操作审计日志', desc: '管理员操作记录', status: t('reports_status_generated'), date: '2026-02-10' }, - ], - }, -]; +interface Report { + name: string; + desc: string; + status: string; + date: string; +} -const statusStyle = (status: string): React.CSSProperties => { +interface ReportCategory { + title: string; + icon: string; + reports: Report[]; +} + +interface ReportsData { + categories: ReportCategory[]; +} + +const loadingBox: React.CSSProperties = { + display: 'flex', alignItems: 'center', justifyContent: 'center', + padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)', +}; + +const getStatusStyle = (status: string): React.CSSProperties => { const map: Record = { - [t('reports_status_generated')]: { bg: 'var(--color-success-light)', color: 'var(--color-success)' }, - [t('reports_status_submitted')]: { bg: 'var(--color-info-light)', color: 'var(--color-info)' }, - [t('reports_status_passed')]: { bg: 'var(--color-success-light)', color: 'var(--color-success)' }, - [t('reports_status_pending_review')]: { bg: 'var(--color-warning-light)', color: 'var(--color-warning)' }, - [t('reports_status_pending_generate')]: { bg: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' }, + generated: { bg: 'var(--color-success-light)', color: 'var(--color-success)' }, + submitted: { bg: 'var(--color-info-light)', color: 'var(--color-info)' }, + passed: { bg: 'var(--color-success-light)', color: 'var(--color-success)' }, + pending_review: { bg: 'var(--color-warning-light)', color: 'var(--color-warning)' }, + pending_generate: { bg: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' }, 'N/A': { bg: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' }, }; const s = map[status] || map['N/A']; return { padding: '2px 8px', borderRadius: 'var(--radius-full)', background: s.bg, color: s.color, font: 'var(--text-caption)', fontWeight: 600 }; }; +const getStatusLabel = (status: string) => { + const map: Record string> = { + generated: () => t('reports_status_generated'), + submitted: () => t('reports_status_submitted'), + passed: () => t('reports_status_passed'), + pending_review: () => t('reports_status_pending_review'), + pending_generate: () => t('reports_status_pending_generate'), + }; + return map[status]?.() ?? status; +}; + export const ReportsPage: React.FC = () => { + const { data, isLoading, error } = useApi('/api/v1/admin/reports'); + + if (error) return
Error: {error.message}
; + if (isLoading) return
Loading...
; + + const reportCategories = data?.categories ?? []; + return (
@@ -90,9 +94,9 @@ export const ReportsPage: React.FC = () => {
{r.desc}
- {r.status} + {getStatusLabel(r.status)} {r.date && {r.date}} - {r.status !== 'N/A' && r.status !== t('reports_status_pending_generate') && ( + {r.status !== 'N/A' && r.status !== 'pending_generate' && ( + + + + + ))} + + + )}
); diff --git a/frontend/admin-web/src/views/system/SystemManagementPage.tsx b/frontend/admin-web/src/views/system/SystemManagementPage.tsx index 72bdf12..886d77e 100644 --- a/frontend/admin-web/src/views/system/SystemManagementPage.tsx +++ b/frontend/admin-web/src/views/system/SystemManagementPage.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { t } from '@/i18n/locales'; +import { useApi } from '@/lib/use-api'; /** * D7. 系统管理 @@ -9,9 +10,49 @@ import { t } from '@/i18n/locales'; * 管理员账号(RBAC)、系统配置、合约管理、系统监控 */ +interface Admin { + account: string; + name: string; + role: string; + lastLogin: string; + active: boolean; +} + +interface ConfigSection { + title: string; + items: { label: string; value: string }[]; +} + +interface ServiceHealth { + name: string; + status: string; + cpu: string; + mem: string; +} + +interface SystemHealthResponse { + services: ServiceHealth[]; + contracts: { name: string; address: string; version: string; status: string }[]; +} + +const loadingBox: React.CSSProperties = { + display: 'flex', alignItems: 'center', justifyContent: 'center', + padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)', +}; + export const SystemManagementPage: React.FC = () => { const [activeTab, setActiveTab] = useState<'admins' | 'config' | 'contracts' | 'monitor'>('admins'); + const { data: adminsData, isLoading: adminsLoading } = useApi( + activeTab === 'admins' ? '/api/v1/admin/system/admins' : null, + ); + const { data: configData, isLoading: configLoading } = useApi( + activeTab === 'config' ? '/api/v1/admin/system/config' : null, + ); + const { data: healthData, isLoading: healthLoading } = useApi( + activeTab === 'contracts' || activeTab === 'monitor' ? '/api/v1/admin/system/health' : null, + ); + return (

{t('system_title')}

@@ -55,85 +96,83 @@ export const SystemManagementPage: React.FC = () => { background: 'var(--color-primary)', color: 'white', cursor: 'pointer', font: 'var(--text-label-sm)', }}>{t('system_add_admin')}
- - - - {[t('system_th_account'), t('system_th_name'), t('system_th_role'), t('system_th_last_login'), t('system_th_status'), t('actions')].map(h => ( - - ))} - - - - {[ - { account: 'admin@genex.io', name: '超级管理员', role: '超管', lastLogin: '2026-02-10 14:00', active: true }, - { account: 'ops@genex.io', name: '运营', role: '运营管理', lastLogin: '2026-02-10 09:30', active: true }, - { account: 'risk@genex.io', name: '风控', role: '风控审核', lastLogin: '2026-02-09 18:00', active: true }, - { account: 'cs@genex.io', name: '客服', role: '客服处理', lastLogin: '2026-02-08 14:30', active: false }, - ].map(admin => ( - - - - - - - + {adminsLoading ? ( +
Loading...
+ ) : ( +
{h}
{admin.account}{admin.name} - {admin.role} - {admin.lastLogin} - - - -
+ + + {[t('system_th_account'), t('system_th_name'), t('system_th_role'), t('system_th_last_login'), t('system_th_status'), t('actions')].map(h => ( + + ))} - ))} - -
{h}
+ + + {(adminsData ?? []).map(admin => ( + + {admin.account} + {admin.name} + + {admin.role} + + {admin.lastLogin} + + + + + + + + ))} + + + )}
)} {/* System Config */} {activeTab === 'config' && ( -
- {[ - { title: t('system_fee_config'), items: [{ label: '一级市场手续费', value: '2.5%' }, { label: '二级市场手续费', value: '3.0%' }, { label: '提现手续费', value: '1.0%' }] }, - { title: t('system_kyc_config'), items: [{ label: 'L0每日限额', value: '$100' }, { label: 'L1每日限额', value: '$1,000' }, { label: 'L2每日限额', value: '$10,000' }] }, - { title: t('system_trade_limit_config'), items: [{ label: '单笔最大金额', value: '$50,000' }, { label: '每日最大金额', value: '$100,000' }, { label: '大额交易阈值', value: '$10,000' }] }, - { title: t('system_params'), items: [{ label: 'Utility Track价格上限', value: '≤面值' }, { label: '最大转售次数', value: '5次' }, { label: 'Breakage阈值', value: '3年' }] }, - ].map(section => ( -
-
- {section.title} - -
- {section.items.map((item, i) => ( -
0 ? '1px solid var(--color-border-light)' : 'none', - }}> - {item.label} - {item.value} + configLoading ? ( +
Loading...
+ ) : ( +
+ {(configData ?? []).map(section => ( +
+
+ {section.title} +
- ))} -
- ))} -
+ {section.items.map((item, i) => ( +
0 ? '1px solid var(--color-border-light)' : 'none', + }}> + {item.label} + {item.value} +
+ ))} +
+ ))} +
+ ) )} {/* Contract Management */} @@ -145,12 +184,9 @@ export const SystemManagementPage: React.FC = () => { padding: 20, }}>
{t('system_contract_status')}
- {[ - { name: 'CouponNFT', address: '0x1234...abcd', version: 'v1.2.0', status: t('system_running') }, - { name: 'Settlement', address: '0x5678...efgh', version: 'v1.1.0', status: t('system_running') }, - { name: 'Marketplace', address: '0x9abc...ijkl', version: 'v1.0.0', status: t('system_running') }, - { name: 'Oracle', address: '0xdef0...mnop', version: 'v1.0.0', status: t('system_running') }, - ].map(c => ( + {healthLoading ? ( +
Loading...
+ ) : (healthData?.contracts ?? []).map(c => (
{ padding: 20, }}>
{t('system_health_check')}
- {[ - { name: 'API Gateway', status: 'healthy', cpu: '23%', mem: '45%' }, - { name: 'Auth Service', status: 'healthy', cpu: '12%', mem: '34%' }, - { name: 'Trading Engine', status: 'healthy', cpu: '56%', mem: '67%' }, - { name: 'Genex Chain Node', status: 'healthy', cpu: '34%', mem: '78%' }, - { name: 'Redis Cache', status: 'healthy', cpu: '8%', mem: '52%' }, - ].map(s => ( + {healthLoading ? ( +
Loading...
+ ) : (healthData?.services ?? []).map(s => (
- + {s.name} CPU {s.cpu} MEM {s.mem} diff --git a/frontend/admin-web/src/views/trading/TradingMonitorPage.tsx b/frontend/admin-web/src/views/trading/TradingMonitorPage.tsx index c5a8c62..5a81a79 100644 --- a/frontend/admin-web/src/views/trading/TradingMonitorPage.tsx +++ b/frontend/admin-web/src/views/trading/TradingMonitorPage.tsx @@ -1,5 +1,8 @@ -import React from 'react'; +'use client'; + +import React, { useState } from 'react'; import { t } from '@/i18n/locales'; +import { useApi } from '@/lib/use-api'; /** * D4. 交易监控 @@ -7,19 +10,61 @@ import { t } from '@/i18n/locales'; * 实时交易流、交易统计、订单管理 */ +interface TradingStats { + todayVolume: number; + todayAmount: number; + avgDiscount: number; + largeTrades: number; +} + +interface Order { + orderId: string; + type: string; + couponName: string; + buyer: string; + seller: string; + amount: number; + status: string; + time: string; +} + +interface OrdersResponse { + items: Order[]; + total: number; +} + +const loadingBox: React.CSSProperties = { + display: 'flex', alignItems: 'center', justifyContent: 'center', + padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)', +}; + export const TradingMonitorPage: React.FC = () => { + const [page] = useState(1); + const [limit] = useState(20); + + const { data: statsData, isLoading: statsLoading } = useApi('/api/v1/admin/trades/stats'); + const { data: ordersData, isLoading: ordersLoading, error } = useApi('/api/v1/admin/trades/orders', { + params: { page, limit }, + }); + + const tradingStats = statsData ? [ + { label: t('trading_today_volume'), value: statsData.todayVolume?.toLocaleString() ?? '0', color: 'var(--color-primary)' }, + { label: t('trading_today_amount'), value: `$${statsData.todayAmount?.toLocaleString() ?? '0'}`, color: 'var(--color-success)' }, + { label: t('trading_avg_discount'), value: `${statsData.avgDiscount ?? 0}%`, color: 'var(--color-info)' }, + { label: t('trading_large_trades'), value: String(statsData.largeTrades ?? 0), color: 'var(--color-warning)' }, + ] : []; + + const orders = ordersData?.items ?? []; + return (

{t('trading_title')}

{/* Stats Row */}
- {[ - { label: t('trading_today_volume'), value: '2,456', color: 'var(--color-primary)' }, - { label: t('trading_today_amount'), value: '$156,789', color: 'var(--color-success)' }, - { label: t('trading_avg_discount'), value: '82.3%', color: 'var(--color-info)' }, - { label: t('trading_large_trades'), value: '12', color: 'var(--color-warning)' }, - ].map(s => ( + {statsLoading ? ( +
Loading...
+ ) : tradingStats.map(s => (
{ }} />
- - - - {[t('trading_th_order_id'), t('trading_th_type'), t('trading_th_coupon_name'), t('trading_th_buyer'), t('trading_th_seller'), t('trading_th_amount'), t('trading_th_status'), t('trading_th_time')].map(h => ( - - ))} - - - - {Array.from({ length: 8 }, (_, i) => ( - - - - - - - - - + {error ? ( +
Error: {error.message}
+ ) : ordersLoading ? ( +
Loading...
+ ) : ( +
{h}
- GNX-20260210-{String(1200 - i).padStart(6, '0')} - - {[t('trading_type_purchase'), t('trading_type_resell'), t('trading_type_redeem'), t('trading_type_transfer')][i % 4]} - - {['星巴克 $25', 'Amazon $100', 'Nike $80', 'Target $30'][i % 4]} - U-{String(i + 1).padStart(3, '0')} - {i % 4 === 1 ? `U-${String(10 + i).padStart(3, '0')}` : '-'} - - ${[21.25, 85.00, 68.00, 24.00][i % 4].toFixed(2)} - - - {i < 6 ? t('trading_status_completed') : t('trading_status_dispute')} - - - 14:{30 + i} -
+ + + {[t('trading_th_order_id'), t('trading_th_type'), t('trading_th_coupon_name'), t('trading_th_buyer'), t('trading_th_seller'), t('trading_th_amount'), t('trading_th_status'), t('trading_th_time')].map(h => ( + + ))} - ))} - -
{h}
+ + + {orders.map((order, i) => ( + + + {order.orderId} + + + {order.type} + + + {order.couponName} + + {order.buyer} + + {order.seller || '-'} + + + ${order.amount?.toFixed(2)} + + + + {order.status === 'completed' ? t('trading_status_completed') : t('trading_status_dispute')} + + + + {order.time} + + + ))} + + + )}
); diff --git a/frontend/admin-web/src/views/users/UserManagementPage.tsx b/frontend/admin-web/src/views/users/UserManagementPage.tsx index ecd7f8b..a82f9ed 100644 --- a/frontend/admin-web/src/views/users/UserManagementPage.tsx +++ b/frontend/admin-web/src/views/users/UserManagementPage.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { t } from '@/i18n/locales'; +import { useApi, useApiMutation } from '@/lib/use-api'; /** * D3. 用户管理 @@ -15,28 +16,37 @@ interface User { email: string; kycLevel: number; couponCount: number; - totalTraded: string; + totalTraded: number; riskTags: string[]; createdAt: string; } -const mockUsers: User[] = [ - { id: 'U-001', phone: '138****1234', email: 'john@mail.com', kycLevel: 2, couponCount: 15, totalTraded: '$2,340', riskTags: [], createdAt: '2026-01-10' }, - { id: 'U-002', phone: '139****5678', email: 'jane@mail.com', kycLevel: 1, couponCount: 8, totalTraded: '$890', riskTags: [t('risk_type_high_freq')], createdAt: '2026-01-15' }, - { id: 'U-003', phone: '137****9012', email: 'bob@mail.com', kycLevel: 3, couponCount: 42, totalTraded: '$12,450', riskTags: [], createdAt: '2025-12-01' }, - { id: 'U-004', phone: '136****3456', email: 'alice@mail.com', kycLevel: 0, couponCount: 0, totalTraded: '-', riskTags: [], createdAt: '2026-02-09' }, -]; +interface UsersResponse { + items: User[]; + total: number; +} + +const loadingBox: React.CSSProperties = { + display: 'flex', alignItems: 'center', justifyContent: 'center', + padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)', +}; export const UserManagementPage: React.FC = () => { const [search, setSearch] = useState(''); const [kycFilter, setKycFilter] = useState(null); + const [page] = useState(1); + const [limit] = useState(20); - const filtered = mockUsers.filter(u => { - if (search && !u.phone.includes(search) && !u.email.includes(search) && !u.id.includes(search)) return false; - if (kycFilter !== null && u.kycLevel !== kycFilter) return false; - return true; + const { data, isLoading, error } = useApi('/api/v1/admin/users', { + params: { page, limit, search: search || undefined, kycLevel: kycFilter ?? undefined }, }); + const freezeMutation = useApiMutation('POST', '', { + invalidateKeys: ['/api/v1/admin/users'], + }); + + const users = data?.items ?? []; + const kycBadge = (level: number) => { const colors = ['var(--color-gray-400)', 'var(--color-info)', 'var(--color-primary)', 'var(--color-success)']; return ( @@ -53,6 +63,8 @@ export const UserManagementPage: React.FC = () => { ); }; + const formatCurrency = (n: number) => n > 0 ? `$${n.toLocaleString()}` : '-'; + return (

{t('user_management_title')}

@@ -93,66 +105,84 @@ export const UserManagementPage: React.FC = () => {
{/* Users Table */} -
- - - - {[t('user_id'), t('user_phone'), t('user_email'), t('user_kyc_level'), t('user_coupon_count'), t('user_total_traded'), t('user_risk_tags'), t('user_created_at'), t('actions')].map(h => ( - - ))} - - - - {filtered.map(user => ( - - - - - - - - - - + {error ? ( +
Error: {error.message}
+ ) : isLoading ? ( +
Loading...
+ ) : ( +
+
{h}
{user.id}{user.phone}{user.email}{kycBadge(user.kycLevel)}{user.couponCount}{user.totalTraded} - {user.riskTags.length > 0 - ? user.riskTags.map(tag => ( - {tag} - )) - : - - } - {user.createdAt} - -
+ + + {[t('user_id'), t('user_phone'), t('user_email'), t('user_kyc_level'), t('user_coupon_count'), t('user_total_traded'), t('user_risk_tags'), t('user_created_at'), t('actions')].map(h => ( + + ))} - ))} - -
{h}
-
+ + + {users.map(user => ( + + {user.id} + {user.phone} + {user.email} + {kycBadge(user.kycLevel)} + {user.couponCount} + {formatCurrency(user.totalTraded)} + + {user.riskTags.length > 0 + ? user.riskTags.map(tag => ( + {tag} + )) + : - + } + + {user.createdAt} + + + + + + ))} + + +
+ )}
); }; diff --git a/frontend/genex-mobile/lib/app/i18n/strings/en.dart b/frontend/genex-mobile/lib/app/i18n/strings/en.dart index 8010c83..22c59a4 100644 --- a/frontend/genex-mobile/lib/app/i18n/strings/en.dart +++ b/frontend/genex-mobile/lib/app/i18n/strings/en.dart @@ -113,6 +113,9 @@ const Map en = { 'home.aiRecommendDesc': 'Found 3 great deals based on your preferences', 'home.featuredCoupons': 'Featured Coupons', 'home.viewAllCoupons': 'View All', + 'home.loadFailed': 'Failed to load, please try again', + 'home.retry': 'Retry', + 'home.noCoupons': 'No featured coupons yet', // ============ Market ============ 'market.title': 'Trade', diff --git a/frontend/genex-mobile/lib/app/i18n/strings/ja.dart b/frontend/genex-mobile/lib/app/i18n/strings/ja.dart index d2ab3d3..2074f5a 100644 --- a/frontend/genex-mobile/lib/app/i18n/strings/ja.dart +++ b/frontend/genex-mobile/lib/app/i18n/strings/ja.dart @@ -113,6 +113,9 @@ const Map ja = { 'home.aiRecommendDesc': 'あなたの好みに基づき、コスパの高いクーポンを3枚発見しました', 'home.featuredCoupons': '厳選クーポン', 'home.viewAllCoupons': 'すべて見る', + 'home.loadFailed': '読み込みに失敗しました。もう一度お試しください', + 'home.retry': 'リトライ', + 'home.noCoupons': '厳選クーポンはまだありません', // ============ Market ============ 'market.title': '取引', diff --git a/frontend/genex-mobile/lib/app/i18n/strings/zh_cn.dart b/frontend/genex-mobile/lib/app/i18n/strings/zh_cn.dart index 8bacdbd..b6272b0 100644 --- a/frontend/genex-mobile/lib/app/i18n/strings/zh_cn.dart +++ b/frontend/genex-mobile/lib/app/i18n/strings/zh_cn.dart @@ -113,6 +113,9 @@ const Map zhCN = { 'home.aiRecommendDesc': '根据你的偏好,发现了3张高性价比券', 'home.featuredCoupons': '精选好券', 'home.viewAllCoupons': '查看全部', + 'home.loadFailed': '加载失败,请稍后重试', + 'home.retry': '重试', + 'home.noCoupons': '暂无精选券', // ============ Market ============ 'market.title': '交易', diff --git a/frontend/genex-mobile/lib/app/i18n/strings/zh_tw.dart b/frontend/genex-mobile/lib/app/i18n/strings/zh_tw.dart index 87b7a4e..3862cf3 100644 --- a/frontend/genex-mobile/lib/app/i18n/strings/zh_tw.dart +++ b/frontend/genex-mobile/lib/app/i18n/strings/zh_tw.dart @@ -113,6 +113,9 @@ const Map zhTW = { 'home.aiRecommendDesc': '根據你的偏好,發現了3張高性價比券', 'home.featuredCoupons': '精選好券', 'home.viewAllCoupons': '查看全部', + 'home.loadFailed': '載入失敗,請稍後重試', + 'home.retry': '重試', + 'home.noCoupons': '暫無精選券', // ============ Market ============ 'market.title': '交易', diff --git a/frontend/genex-mobile/lib/core/push/push_service.dart b/frontend/genex-mobile/lib/core/push/push_service.dart index 3a9d19f..4810a91 100644 --- a/frontend/genex-mobile/lib/core/push/push_service.dart +++ b/frontend/genex-mobile/lib/core/push/push_service.dart @@ -63,7 +63,7 @@ class PushService { Future _registerToken(String token) async { try { final platform = Platform.isIOS ? 'IOS' : 'ANDROID'; - await ApiClient.instance.post('/device-tokens', data: { + await ApiClient.instance.post('/api/v1/device-tokens', data: { 'platform': platform, 'channel': 'FCM', 'token': token, @@ -78,7 +78,7 @@ class PushService { Future unregisterToken() async { if (_fcmToken == null) return; try { - await ApiClient.instance.delete('/device-tokens', data: { + await ApiClient.instance.delete('/api/v1/device-tokens', data: { 'token': _fcmToken, }); debugPrint('[PushService] Token 已注销'); diff --git a/frontend/genex-mobile/lib/core/services/coupon_service.dart b/frontend/genex-mobile/lib/core/services/coupon_service.dart new file mode 100644 index 0000000..dd11921 --- /dev/null +++ b/frontend/genex-mobile/lib/core/services/coupon_service.dart @@ -0,0 +1,85 @@ +import 'package:flutter/foundation.dart'; +import '../network/api_client.dart'; +import '../../features/coupons/data/models/coupon_model.dart'; +import '../../features/coupons/data/models/holdings_summary_model.dart'; + +/// 券 API 服务 +class CouponApiService { + final ApiClient _apiClient; + + CouponApiService({ApiClient? apiClient}) + : _apiClient = apiClient ?? ApiClient.instance; + + /// 获取精选/上架券列表(首页用) + Future> getFeaturedCoupons({int page = 1, int limit = 10}) async { + try { + final response = await _apiClient.get( + '/api/v1/coupons', + queryParameters: {'page': page, 'limit': limit, 'status': 'listed'}, + ); + final data = response.data is Map ? response.data : {}; + final inner = data['data'] ?? data; + final items = (inner['items'] as List?) + ?.map((e) => CouponModel.fromJson(e as Map)) + .toList() ?? + []; + return items; + } catch (e) { + debugPrint('[CouponApiService] getFeaturedCoupons 失败: $e'); + rethrow; + } + } + + /// 获取我的持仓列表 + Future<({List items, int total})> getMyHoldings({ + int page = 1, + int limit = 20, + String? status, + }) async { + try { + final queryParams = {'page': page, 'limit': limit}; + if (status != null) queryParams['status'] = status; + + final response = await _apiClient.get( + '/api/v1/coupons/my', + queryParameters: queryParams, + ); + final data = response.data is Map ? response.data : {}; + final inner = data['data'] ?? data; + final items = (inner['items'] as List?) + ?.map((e) => CouponModel.fromJson(e as Map)) + .toList() ?? + []; + return (items: items, total: inner['total'] ?? items.length); + } catch (e) { + debugPrint('[CouponApiService] getMyHoldings 失败: $e'); + rethrow; + } + } + + /// 获取持仓汇总 + Future getHoldingsSummary() async { + try { + final response = await _apiClient.get('/api/v1/coupons/my/summary'); + final data = response.data is Map ? response.data : {}; + final inner = data['data'] ?? data; + return HoldingsSummaryModel.fromJson(inner as Map); + } catch (e) { + debugPrint('[CouponApiService] getHoldingsSummary 失败: $e'); + rethrow; + } + } + + /// 获取券详情 + Future getCouponDetail(String id) async { + try { + final response = await _apiClient.get('/api/v1/coupons/$id'); + final data = response.data is Map ? response.data : {}; + final inner = data['data'] ?? data; + return CouponModel.fromJson(inner as Map); + } catch (e) { + debugPrint('[CouponApiService] getCouponDetail 失败: $e'); + rethrow; + } + } +} diff --git a/frontend/genex-mobile/lib/core/services/notification_service.dart b/frontend/genex-mobile/lib/core/services/notification_service.dart index a0099df..cbcb3c7 100644 --- a/frontend/genex-mobile/lib/core/services/notification_service.dart +++ b/frontend/genex-mobile/lib/core/services/notification_service.dart @@ -144,7 +144,7 @@ class NotificationService { } final response = await _apiClient.get( - '/notifications', + '/api/v1/notifications', queryParameters: queryParams, ); @@ -160,9 +160,9 @@ class NotificationService { /// 获取未读通知数量 Future getUnreadCount() async { try { - final response = await _apiClient.get('/notifications/unread-count'); + final response = await _apiClient.get('/api/v1/notifications/unread-count'); final data = response.data is Map ? response.data : {}; - return data['unreadCount'] ?? data['data']?['unreadCount'] ?? 0; + return data['data']?['count'] ?? data['count'] ?? 0; } catch (e) { debugPrint('[NotificationService] 获取未读数量失败: $e'); return 0; @@ -172,7 +172,7 @@ class NotificationService { /// 标记通知为已读 Future markAsRead(String notificationId) async { try { - await _apiClient.put('/notifications/$notificationId/read'); + await _apiClient.put('/api/v1/notifications/$notificationId/read'); return true; } catch (e) { debugPrint('[NotificationService] 标记已读失败: $e'); @@ -187,7 +187,7 @@ class NotificationService { }) async { try { final response = await _apiClient.get( - '/announcements', + '/api/v1/announcements', queryParameters: {'limit': limit, 'offset': offset}, ); @@ -203,9 +203,9 @@ class NotificationService { /// 获取公告未读数 Future getAnnouncementUnreadCount() async { try { - final response = await _apiClient.get('/announcements/unread-count'); + final response = await _apiClient.get('/api/v1/announcements/unread-count'); final data = response.data is Map ? response.data : {}; - return data['unreadCount'] ?? data['data']?['unreadCount'] ?? 0; + return data['data']?['count'] ?? data['count'] ?? 0; } catch (e) { debugPrint('[NotificationService] 获取公告未读数失败: $e'); return 0; @@ -215,7 +215,7 @@ class NotificationService { /// 标记公告已读 Future markAnnouncementAsRead(String announcementId) async { try { - await _apiClient.put('/announcements/$announcementId/read'); + await _apiClient.put('/api/v1/announcements/$announcementId/read'); return true; } catch (e) { debugPrint('[NotificationService] 标记公告已读失败: $e'); @@ -226,7 +226,7 @@ class NotificationService { /// 全部标记已读 Future markAllAnnouncementsAsRead() async { try { - await _apiClient.put('/announcements/read-all'); + await _apiClient.put('/api/v1/announcements/read-all'); return true; } catch (e) { debugPrint('[NotificationService] 全部标记已读失败: $e'); diff --git a/frontend/genex-mobile/lib/features/coupons/data/models/coupon_model.dart b/frontend/genex-mobile/lib/features/coupons/data/models/coupon_model.dart new file mode 100644 index 0000000..9276cfd --- /dev/null +++ b/frontend/genex-mobile/lib/features/coupons/data/models/coupon_model.dart @@ -0,0 +1,89 @@ +/// 券数据模型 — 对应后端 GET /api/v1/coupons 返回结构 +class CouponModel { + final String id; + final String issuerId; + final String name; + final String? description; + final String? imageUrl; + final double faceValue; + final double currentPrice; + final double? issuePrice; + final int totalSupply; + final int remainingSupply; + final String category; + final String status; + final String couponType; + final DateTime expiryDate; + final bool isTransferable; + final int resaleCount; + final int maxResaleCount; + + // Joined issuer fields + final String? brandName; + final String? brandLogoUrl; + final String? creditRating; + + const CouponModel({ + required this.id, + required this.issuerId, + required this.name, + this.description, + this.imageUrl, + required this.faceValue, + required this.currentPrice, + this.issuePrice, + required this.totalSupply, + required this.remainingSupply, + required this.category, + required this.status, + required this.couponType, + required this.expiryDate, + required this.isTransferable, + this.resaleCount = 0, + this.maxResaleCount = 3, + this.brandName, + this.brandLogoUrl, + this.creditRating, + }); + + factory CouponModel.fromJson(Map json) { + final issuer = json['issuer'] as Map?; + return CouponModel( + id: json['id'] ?? '', + issuerId: json['issuerId'] ?? '', + name: json['name'] ?? '', + description: json['description'], + imageUrl: json['imageUrl'], + faceValue: _toDouble(json['faceValue']), + currentPrice: _toDouble(json['currentPrice']), + issuePrice: json['issuePrice'] != null ? _toDouble(json['issuePrice']) : null, + totalSupply: json['totalSupply'] ?? 0, + remainingSupply: json['remainingSupply'] ?? 0, + category: json['category'] ?? '', + status: json['status'] ?? '', + couponType: json['couponType'] ?? 'utility', + expiryDate: DateTime.tryParse(json['expiryDate']?.toString() ?? '') ?? DateTime.now(), + isTransferable: json['isTransferable'] ?? true, + resaleCount: json['resaleCount'] ?? 0, + maxResaleCount: json['maxResaleCount'] ?? 3, + brandName: issuer?['companyName'], + brandLogoUrl: issuer?['logoUrl'], + creditRating: issuer?['creditRating'], + ); + } + + /// 折扣率 (0~1) + double get discount => faceValue > 0 ? 1 - currentPrice / faceValue : 0; + + /// 是否即将过期(7天内) + bool get isExpiringSoon => expiryDate.difference(DateTime.now()).inDays <= 7; + + /// 是否已过期 + bool get isExpired => expiryDate.isBefore(DateTime.now()); + + static double _toDouble(dynamic value) { + if (value == null) return 0; + if (value is num) return value.toDouble(); + return double.tryParse(value.toString()) ?? 0; + } +} diff --git a/frontend/genex-mobile/lib/features/coupons/data/models/holdings_summary_model.dart b/frontend/genex-mobile/lib/features/coupons/data/models/holdings_summary_model.dart new file mode 100644 index 0000000..6ae2c35 --- /dev/null +++ b/frontend/genex-mobile/lib/features/coupons/data/models/holdings_summary_model.dart @@ -0,0 +1,26 @@ +/// 持仓汇总 — 对应后端 GET /api/v1/coupons/my/summary +class HoldingsSummaryModel { + final int count; + final double totalFaceValue; + final double totalSaved; + + const HoldingsSummaryModel({ + required this.count, + required this.totalFaceValue, + required this.totalSaved, + }); + + factory HoldingsSummaryModel.fromJson(Map json) { + return HoldingsSummaryModel( + count: json['count'] ?? 0, + totalFaceValue: _toDouble(json['totalFaceValue']), + totalSaved: _toDouble(json['totalSaved']), + ); + } + + static double _toDouble(dynamic value) { + if (value == null) return 0; + if (value is num) return value.toDouble(); + return double.tryParse(value.toString()) ?? 0; + } +} diff --git a/frontend/genex-mobile/lib/features/coupons/presentation/pages/home_page.dart b/frontend/genex-mobile/lib/features/coupons/presentation/pages/home_page.dart index 6defd9a..4b2fea5 100644 --- a/frontend/genex-mobile/lib/features/coupons/presentation/pages/home_page.dart +++ b/frontend/genex-mobile/lib/features/coupons/presentation/pages/home_page.dart @@ -6,91 +6,194 @@ import '../../../../shared/widgets/coupon_card.dart'; import '../../../ai_agent/presentation/widgets/ai_fab.dart'; import '../widgets/receive_coupon_sheet.dart'; import '../../../../app/i18n/app_localizations.dart'; +import '../../../../core/services/coupon_service.dart'; +import '../../data/models/coupon_model.dart'; +import '../../data/models/holdings_summary_model.dart'; /// 首页 - 轻量持仓卡 + 分类网格 + AI推荐 + 精选券 /// /// Tab导航:首页/交易/消息/我的 -class HomePage extends StatelessWidget { +class HomePage extends StatefulWidget { const HomePage({super.key}); + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + final CouponApiService _couponService = CouponApiService(); + + List _featuredCoupons = []; + HoldingsSummaryModel? _holdingsSummary; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _loadData(); + } + + Future _loadData() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final results = await Future.wait([ + _couponService.getFeaturedCoupons(limit: 10), + _couponService.getHoldingsSummary(), + ]); + + if (mounted) { + setState(() { + _featuredCoupons = results[0] as List; + _holdingsSummary = results[1] as HoldingsSummaryModel; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + } + @override Widget build(BuildContext context) { return Scaffold( body: Stack( children: [ - CustomScrollView( - slivers: [ - // Floating App Bar - SliverAppBar( - floating: true, - pinned: false, - backgroundColor: AppColors.background, - elevation: 0, - toolbarHeight: 60, - title: _buildSearchBar(context), - actions: [ - IconButton( - icon: const Icon(Icons.qr_code_scanner_rounded, size: 24), - onPressed: () {}, - color: AppColors.textPrimary, - ), - ], - ), + RefreshIndicator( + onRefresh: _loadData, + child: CustomScrollView( + slivers: [ + // Floating App Bar + SliverAppBar( + floating: true, + pinned: false, + backgroundColor: AppColors.background, + elevation: 0, + toolbarHeight: 60, + title: _buildSearchBar(context), + actions: [ + IconButton( + icon: const Icon(Icons.qr_code_scanner_rounded, size: 24), + onPressed: () {}, + color: AppColors.textPrimary, + ), + ], + ), - // Lightweight Position Card (持仓) - SliverToBoxAdapter(child: _buildWalletCard(context)), + // Lightweight Position Card (持仓) + SliverToBoxAdapter(child: _buildWalletCard(context)), - // Category Grid (8 items, 4x2) - SliverToBoxAdapter(child: _buildCategoryGrid(context)), + // Category Grid (8 items, 4x2) + SliverToBoxAdapter(child: _buildCategoryGrid(context)), - // AI Smart Suggestions - SliverToBoxAdapter(child: _buildAiSuggestions(context)), + // AI Smart Suggestions + SliverToBoxAdapter(child: _buildAiSuggestions(context)), - // Section: Featured Coupons - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 24, 20, 12), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(context.t('home.featuredCoupons'), style: AppTypography.h2), - GestureDetector( - onTap: () {}, - child: Text(context.t('home.viewAllCoupons'), style: AppTypography.labelSmall.copyWith( - color: AppColors.primary, - )), - ), - ], + // Section: Featured Coupons + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(context.t('home.featuredCoupons'), style: AppTypography.h2), + GestureDetector( + onTap: () {}, + child: Text(context.t('home.viewAllCoupons'), style: AppTypography.labelSmall.copyWith( + color: AppColors.primary, + )), + ), + ], + ), ), ), - ), - // Coupon List - SliverPadding( - padding: AppSpacing.pagePadding, - sliver: SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) => Padding( - padding: const EdgeInsets.only(bottom: 12), - child: CouponCard( - brandName: _mockBrands[index % _mockBrands.length], - couponName: _mockNames[index % _mockNames.length], - faceValue: _mockFaceValues[index % _mockFaceValues.length], - currentPrice: _mockPrices[index % _mockPrices.length], - creditRating: _mockRatings[index % _mockRatings.length], - expiryDate: DateTime.now().add(Duration(days: (index + 1) * 5)), - onTap: () { - Navigator.pushNamed(context, '/coupon/detail'); - }, + // Coupon List + if (_isLoading) + SliverPadding( + padding: AppSpacing.pagePadding, + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _buildSkeletonCard(), + ), + childCount: 3, + ), + ), + ) + else if (_error != null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + Icon(Icons.cloud_off_rounded, size: 48, color: AppColors.textTertiary), + const SizedBox(height: 12), + Text( + context.t('home.loadFailed'), + style: AppTypography.bodyMedium.copyWith(color: AppColors.textSecondary), + ), + const SizedBox(height: 12), + TextButton( + onPressed: _loadData, + child: Text(context.t('home.retry')), + ), + ], + ), + ), + ) + else if (_featuredCoupons.isEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(40), + child: Center( + child: Text( + context.t('home.noCoupons'), + style: AppTypography.bodyMedium.copyWith(color: AppColors.textTertiary), + ), + ), + ), + ) + else + SliverPadding( + padding: AppSpacing.pagePadding, + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final coupon = _featuredCoupons[index]; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: CouponCard( + brandName: coupon.brandName ?? '', + couponName: coupon.name, + faceValue: coupon.faceValue, + currentPrice: coupon.currentPrice, + creditRating: coupon.creditRating ?? '', + expiryDate: coupon.expiryDate, + onTap: () { + Navigator.pushNamed(context, '/coupon/detail', arguments: coupon.id); + }, + ), + ); + }, + childCount: _featuredCoupons.length, ), ), - childCount: 10, ), - ), - ), - const SliverPadding(padding: EdgeInsets.only(bottom: 100)), - ], + const SliverPadding(padding: EdgeInsets.only(bottom: 100)), + ], + ), ), // AI FAB @@ -109,6 +212,23 @@ class HomePage extends StatelessWidget { ); } + Widget _buildSkeletonCard() { + return Container( + height: 100, + decoration: BoxDecoration( + color: AppColors.gray50, + borderRadius: AppSpacing.borderRadiusMd, + ), + child: const Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); + } + Widget _buildSearchBar(BuildContext context) { return GestureDetector( onTap: () { @@ -141,6 +261,9 @@ class HomePage extends StatelessWidget { // 点击非快捷入口区域打开完整持仓页面 // ============================================================ Widget _buildWalletCard(BuildContext context) { + final count = _holdingsSummary?.count ?? 0; + final totalValue = _holdingsSummary?.totalFaceValue ?? 0; + return GestureDetector( onTap: () => Navigator.pushNamed(context, '/wallet/coupons'), child: Container( @@ -167,7 +290,7 @@ class HomePage extends StatelessWidget { style: AppTypography.bodySmall.copyWith( color: Colors.white.withValues(alpha: 0.7), )), - Text('4', + Text('$count', style: AppTypography.h3.copyWith( color: Colors.white, fontWeight: FontWeight.w700, @@ -176,7 +299,7 @@ class HomePage extends StatelessWidget { style: AppTypography.bodySmall.copyWith( color: Colors.white.withValues(alpha: 0.7), )), - Text('\$235', + Text('\$${totalValue.toStringAsFixed(0)}', style: AppTypography.h3.copyWith( color: Colors.white, fontWeight: FontWeight.w700, @@ -348,10 +471,3 @@ class HomePage extends StatelessWidget { ); } } - -// Mock data -const _mockBrands = ['Starbucks', 'Amazon', 'Walmart', 'Target', 'Nike']; -const _mockNames = ['星巴克 \$25 礼品卡', 'Amazon \$100 购物券', 'Walmart \$50 生活券', 'Target \$30 折扣券', 'Nike \$80 运动券']; -const _mockFaceValues = [25.0, 100.0, 50.0, 30.0, 80.0]; -const _mockPrices = [21.25, 85.0, 42.5, 24.0, 68.0]; -const _mockRatings = ['AAA', 'AA', 'AAA', 'A', 'AA']; diff --git a/frontend/genex-mobile/lib/features/coupons/presentation/pages/wallet_coupons_page.dart b/frontend/genex-mobile/lib/features/coupons/presentation/pages/wallet_coupons_page.dart index e605a5d..597f2f5 100644 --- a/frontend/genex-mobile/lib/features/coupons/presentation/pages/wallet_coupons_page.dart +++ b/frontend/genex-mobile/lib/features/coupons/presentation/pages/wallet_coupons_page.dart @@ -7,6 +7,9 @@ import '../../../../shared/widgets/status_tag.dart'; import '../../../../shared/widgets/empty_state.dart'; import '../widgets/receive_coupon_sheet.dart'; import '../../../../app/i18n/app_localizations.dart'; +import '../../../../core/services/coupon_service.dart'; +import '../../data/models/coupon_model.dart'; +import '../../data/models/holdings_summary_model.dart'; /// 完整持仓页面 - 融合"我的券"所有功能 /// @@ -23,32 +26,91 @@ class WalletCouponsPage extends StatefulWidget { class _WalletCouponsPageState extends State with SingleTickerProviderStateMixin { late TabController _tabController; + final CouponApiService _couponService = CouponApiService(); + + HoldingsSummaryModel? _summary; + Map> _tabCoupons = {}; + Map _tabLoading = {}; + Map _tabError = {}; + + // Tab index → backend status filter + static const _tabStatusFilters = { + 0: null, // 全部 + 1: 'listed', // 可使用 (listed / in_circulation) + 2: 'sold', // 待核销 + 3: 'expired', // 已过期 + }; @override void initState() { super.initState(); _tabController = TabController(length: 4, vsync: this); + _tabController.addListener(_onTabChanged); + _loadSummary(); + _loadTab(0); } @override void dispose() { + _tabController.removeListener(_onTabChanged); _tabController.dispose(); super.dispose(); } + void _onTabChanged() { + if (!_tabController.indexIsChanging) { + final index = _tabController.index; + if (!_tabCoupons.containsKey(index)) { + _loadTab(index); + } + } + } + + Future _loadSummary() async { + try { + final summary = await _couponService.getHoldingsSummary(); + if (mounted) setState(() => _summary = summary); + } catch (e) { + debugPrint('[WalletCouponsPage] loadSummary 失败: $e'); + } + } + + Future _loadTab(int tabIndex) async { + setState(() { + _tabLoading[tabIndex] = true; + _tabError[tabIndex] = null; + }); + + try { + final status = _tabStatusFilters[tabIndex]; + final result = await _couponService.getMyHoldings(status: status); + if (mounted) { + setState(() { + _tabCoupons[tabIndex] = result.items; + _tabLoading[tabIndex] = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _tabError[tabIndex] = e.toString(); + _tabLoading[tabIndex] = false; + }); + } + } + } + @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(context.t('walletCoupons.title')), actions: [ - // 接收券 IconButton( icon: const Icon(Icons.qr_code_rounded, size: 22), onPressed: () => _showReceiveSheet(context), tooltip: context.t('walletCoupons.receiveTip'), ), - // 排序 IconButton( icon: const Icon(Icons.sort_rounded, size: 22), onPressed: () {}, @@ -66,19 +128,11 @@ class _WalletCouponsPageState extends State ), body: Column( children: [ - // 汇总卡片 _buildSummaryCard(), - - // 券列表 Expanded( child: TabBarView( controller: _tabController, - children: [ - _buildCouponList(null), - _buildCouponList(CouponStatus.active), - _buildCouponList(CouponStatus.pending), - _buildCouponList(CouponStatus.expired), - ], + children: List.generate(4, (i) => _buildCouponList(i)), ), ), ], @@ -86,8 +140,11 @@ class _WalletCouponsPageState extends State ); } - /// 顶部汇总卡片:持有数量 + 总面值 + 快捷操作 Widget _buildSummaryCard() { + final count = _summary?.count ?? 0; + final totalFaceValue = _summary?.totalFaceValue ?? 0; + final totalSaved = _summary?.totalSaved ?? 0; + return Container( margin: const EdgeInsets.fromLTRB(16, 12, 16, 4), padding: const EdgeInsets.all(16), @@ -98,11 +155,10 @@ class _WalletCouponsPageState extends State ), child: Row( children: [ - // 持有券数 Expanded( child: Column( children: [ - Text('4', + Text('$count', style: AppTypography.displayLarge.copyWith( color: Colors.white, fontWeight: FontWeight.w700, @@ -115,16 +171,11 @@ class _WalletCouponsPageState extends State ], ), ), - Container( - width: 1, - height: 36, - color: Colors.white.withValues(alpha: 0.2), - ), - // 总面值 + Container(width: 1, height: 36, color: Colors.white.withValues(alpha: 0.2)), Expanded( child: Column( children: [ - Text('\$235', + Text('\$${totalFaceValue.toStringAsFixed(0)}', style: AppTypography.displayLarge.copyWith( color: Colors.white, fontWeight: FontWeight.w700, @@ -137,16 +188,11 @@ class _WalletCouponsPageState extends State ], ), ), - Container( - width: 1, - height: 36, - color: Colors.white.withValues(alpha: 0.2), - ), - // 节省金额 + Container(width: 1, height: 36, color: Colors.white.withValues(alpha: 0.2)), Expanded( child: Column( children: [ - Text('\$38', + Text('\$${totalSaved.toStringAsFixed(0)}', style: AppTypography.displayLarge.copyWith( color: Colors.white, fontWeight: FontWeight.w700, @@ -164,8 +210,30 @@ class _WalletCouponsPageState extends State ); } - Widget _buildCouponList(CouponStatus? filter) { - final coupons = _filterCoupons(filter); + Widget _buildCouponList(int tabIndex) { + final isLoading = _tabLoading[tabIndex] ?? false; + final error = _tabError[tabIndex]; + final coupons = _tabCoupons[tabIndex] ?? []; + + if (isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (error != null) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.cloud_off_rounded, size: 48, color: AppColors.textTertiary), + const SizedBox(height: 12), + TextButton( + onPressed: () => _loadTab(tabIndex), + child: Text(context.t('home.retry')), + ), + ], + ), + ); + } if (coupons.isEmpty) { return EmptyState.noCoupons( @@ -173,20 +241,24 @@ class _WalletCouponsPageState extends State ); } - return ListView.separated( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 100), - itemCount: coupons.length, - separatorBuilder: (_, __) => const SizedBox(height: 12), - itemBuilder: (context, index) { - final coupon = coupons[index]; - return _buildCouponCard(context, coupon); + return RefreshIndicator( + onRefresh: () async { + await Future.wait([_loadSummary(), _loadTab(tabIndex)]); }, + child: ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 100), + itemCount: coupons.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, index) => _buildCouponCard(context, coupons[index]), + ), ); } - Widget _buildCouponCard(BuildContext context, _WalletCouponItem coupon) { + Widget _buildCouponCard(BuildContext context, CouponModel coupon) { + final displayStatus = _mapStatus(coupon.status); + return GestureDetector( - onTap: () => Navigator.pushNamed(context, '/coupon/mine/detail'), + onTap: () => Navigator.pushNamed(context, '/coupon/mine/detail', arguments: coupon.id), child: Container( padding: AppSpacing.cardPadding, decoration: BoxDecoration( @@ -199,7 +271,6 @@ class _WalletCouponsPageState extends State children: [ Row( children: [ - // 券图片占位 Container( width: 56, height: 56, @@ -218,7 +289,7 @@ class _WalletCouponsPageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(coupon.brandName, style: AppTypography.caption), + Text(coupon.brandName ?? '', style: AppTypography.caption), Text(coupon.name, style: AppTypography.labelMedium), const SizedBox(height: 4), Row( @@ -226,7 +297,7 @@ class _WalletCouponsPageState extends State Text('${context.t('walletCoupons.faceValue')} \$${coupon.faceValue.toStringAsFixed(0)}', style: AppTypography.bodySmall), const SizedBox(width: 8), - _statusWidget(coupon.status), + _statusWidget(displayStatus), ], ), ], @@ -236,8 +307,6 @@ class _WalletCouponsPageState extends State color: AppColors.textTertiary, size: 20), ], ), - - // 过期时间 + 快捷操作 const SizedBox(height: 12), Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), @@ -256,7 +325,7 @@ class _WalletCouponsPageState extends State color: _expiryColor(coupon.expiryDate)), ), const Spacer(), - if (coupon.status == CouponStatus.active) ...[ + if (displayStatus == CouponStatus.active) ...[ _quickAction(context.t('myCoupon.transfer'), Icons.card_giftcard_rounded, () { Navigator.pushNamed(context, '/transfer'); }), @@ -274,6 +343,25 @@ class _WalletCouponsPageState extends State ); } + /// 将后端状态映射为前端显示状态 + CouponStatus _mapStatus(String backendStatus) { + switch (backendStatus) { + case 'listed': + case 'in_circulation': + return CouponStatus.active; + case 'sold': + case 'minted': + return CouponStatus.pending; + case 'expired': + case 'recalled': + return CouponStatus.expired; + case 'redeemed': + return CouponStatus.used; + default: + return CouponStatus.active; + } + } + Widget _statusWidget(CouponStatus status) { switch (status) { case CouponStatus.active: @@ -329,67 +417,4 @@ class _WalletCouponsPageState extends State builder: (_) => const ReceiveCouponSheet(), ); } - - List<_WalletCouponItem> _filterCoupons(CouponStatus? filter) { - if (filter == null) return _mockCoupons; - return _mockCoupons.where((c) => c.status == filter).toList(); - } } - -// ============================================================ -// Data Model -// ============================================================ -class _WalletCouponItem { - final String brandName; - final String name; - final double faceValue; - final CouponStatus status; - final DateTime expiryDate; - - const _WalletCouponItem({ - required this.brandName, - required this.name, - required this.faceValue, - required this.status, - required this.expiryDate, - }); -} - -// Mock data -final _mockCoupons = [ - _WalletCouponItem( - brandName: 'Starbucks', - name: '星巴克 \$25 礼品卡', - faceValue: 25.0, - status: CouponStatus.active, - expiryDate: DateTime.now().add(const Duration(days: 30)), - ), - _WalletCouponItem( - brandName: 'Amazon', - name: 'Amazon \$100 购物券', - faceValue: 100.0, - status: CouponStatus.active, - expiryDate: DateTime.now().add(const Duration(days: 45)), - ), - _WalletCouponItem( - brandName: 'Nike', - name: 'Nike \$80 运动券', - faceValue: 80.0, - status: CouponStatus.pending, - expiryDate: DateTime.now().add(const Duration(days: 15)), - ), - _WalletCouponItem( - brandName: 'Target', - name: 'Target \$30 折扣券', - faceValue: 30.0, - status: CouponStatus.active, - expiryDate: DateTime.now().add(const Duration(days: 60)), - ), - _WalletCouponItem( - brandName: 'Walmart', - name: 'Walmart \$50 生活券', - faceValue: 50.0, - status: CouponStatus.expired, - expiryDate: DateTime.now().subtract(const Duration(days: 5)), - ), -]; diff --git a/frontend/miniapp/src/config/index.ts b/frontend/miniapp/src/config/index.ts new file mode 100644 index 0000000..3e44bce --- /dev/null +++ b/frontend/miniapp/src/config/index.ts @@ -0,0 +1,24 @@ +/** + * 环境配置 + */ + +const envConfig = { + development: { + API_BASE_URL: 'http://localhost:8080', + }, + production: { + API_BASE_URL: 'https://api.gogenex.cn', + }, +}; + +const env = (process.env.NODE_ENV as keyof typeof envConfig) || 'development'; + +export const config = { + ...envConfig[env], + /** 请求超时(ms) */ + REQUEST_TIMEOUT: 30000, + /** Token 存储键名 */ + TOKEN_KEY: 'genex_token', + /** 用户信息存储键名 */ + USER_KEY: 'genex_user', +}; diff --git a/frontend/miniapp/src/pages/ai-chat/index.tsx b/frontend/miniapp/src/pages/ai-chat/index.tsx index 91763d9..98ba5e6 100644 --- a/frontend/miniapp/src/pages/ai-chat/index.tsx +++ b/frontend/miniapp/src/pages/ai-chat/index.tsx @@ -1,5 +1,6 @@ -import React from 'react'; +import React, { useState, useRef } from 'react'; import { t } from '@/i18n'; +import { chat } from '../../services/ai'; // Taro mini-program - AI Chat Assistant /** @@ -13,16 +14,13 @@ interface ChatMessage { text: string; } -const MOCK_AI_RESPONSE = - '根据您的偏好和消费习惯,推荐以下高性价比券:\n\n1. 星巴克 ¥25 礼品卡 - 当前售价 ¥21.25(8.5折),信用AAA\n2. Amazon ¥100 购物券 - 当前售价 ¥85(8.5折),信用AA\n\n这两张券的折扣率在同类中最优,且发行方信用等级高。'; - const AiChatPage: React.FC = () => { - const [messages, setMessages] = React.useState([ + const [messages, setMessages] = useState([ { isAi: true, text: t('ai_chat_greeting') }, ]); - const [inputText, setInputText] = React.useState(''); - const scrollId = React.useRef('msg-0'); - const [scrollToId, setScrollToId] = React.useState('msg-0'); + const [inputText, setInputText] = useState(''); + const [isTyping, setIsTyping] = useState(false); + const [scrollToId, setScrollToId] = useState('msg-0'); const suggestions = [ t('ai_chat_suggest1'), @@ -40,24 +38,37 @@ const AiChatPage: React.FC = () => { return next; }); setInputText(''); + setIsTyping(true); - setTimeout(() => { - setMessages((prev) => { - const next = [...prev, { isAi: true, text: MOCK_AI_RESPONSE }]; - const aiIdx = next.length - 1; - setScrollToId(`msg-${aiIdx}`); - return next; - }); - }, 800); + chat(userText) + .then((response) => { + const aiText = response.message || t('ai_chat_greeting'); + setMessages((prev) => { + const next = [...prev, { isAi: true, text: aiText }]; + const aiIdx = next.length - 1; + setScrollToId(`msg-${aiIdx}`); + return next; + }); + }) + .catch(() => { + setMessages((prev) => { + const next = [...prev, { isAi: true, text: t('ai_chat_error') || '抱歉,请稍后再试。' }]; + const aiIdx = next.length - 1; + setScrollToId(`msg-${aiIdx}`); + return next; + }); + }) + .finally(() => setIsTyping(false)); }; const handleSend = () => { const text = inputText.trim(); - if (!text) return; + if (!text || isTyping) return; addMessages(text); }; const handleChipTap = (chip: string) => { + if (isTyping) return; addMessages(chip); }; @@ -96,6 +107,18 @@ const AiChatPage: React.FC = () => { ))} + {/* Typing indicator */} + {isTyping && ( + + + + + + ... + + + )} + {/* Suggestion Chips */} {messages.length <= 2 && ( diff --git a/frontend/miniapp/src/pages/detail/index.tsx b/frontend/miniapp/src/pages/detail/index.tsx index 6063498..ae04447 100644 --- a/frontend/miniapp/src/pages/detail/index.tsx +++ b/frontend/miniapp/src/pages/detail/index.tsx @@ -1,6 +1,8 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import Taro from '@tarojs/taro'; import { t } from '@/i18n'; +import { getCouponDetail, getNearbyStores, getSimilarCoupons } from '../../services/coupon'; +import type { CouponItem } from '../../services/coupon'; // Taro mini-program - Coupon Detail + Purchase /** @@ -10,6 +12,52 @@ import { t } from '@/i18n'; */ const DetailPage: React.FC = () => { + const [coupon, setCoupon] = useState(null); + const [stores, setStores] = useState>([]); + const [similarCoupons, setSimilarCoupons] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const params = Taro.getCurrentInstance().router?.params; + const id = params?.id; + if (!id) { + setLoading(false); + return; + } + + getCouponDetail(id) + .then((data) => setCoupon(data)) + .catch(() => {}) + .finally(() => setLoading(false)); + + getNearbyStores(id) + .then((data) => setStores(data)) + .catch(() => {}); + + getSimilarCoupons(id) + .then((data) => setSimilarCoupons(data)) + .catch(() => {}); + }, []); + + if (loading) { + return ( + + {t('loading') || '加载中...'} + + ); + } + + if (!coupon) { + return ( + + {t('empty') || '未找到券信息'} + + ); + } + + const discount = ((coupon.currentPrice / coupon.faceValue) * 10).toFixed(1) + '折'; + const savings = (coupon.faceValue - coupon.currentPrice).toFixed(2); + return ( {/* Coupon Image */} @@ -22,34 +70,36 @@ const DetailPage: React.FC = () => { {/* Main Info */} - S + {(coupon.brandName || coupon.name)[0]} - Starbucks - - AAA - + {coupon.brandName || coupon.category} + {coupon.creditRating && ( + + {coupon.creditRating} + + )} - 星巴克 ¥25 礼品卡 + {coupon.name} {/* Price */} ¥ - 21.25 - ¥25 - 8.5折 + {coupon.currentPrice.toFixed(2)} + ¥{coupon.faceValue} + {discount} - {t('coupon_price_save')} ¥3.75 + {t('coupon_price_save')} ¥{savings} {/* Info List */} {[ - { label: t('coupon_face_value'), value: '¥25.00' }, - { label: t('coupon_valid_until'), value: '2026/12/31' }, - { label: t('coupon_type_label'), value: t('coupon_type_consumer') }, - { label: t('coupon_store'), value: t('coupon_stores_count') }, + { label: t('coupon_face_value'), value: `¥${coupon.faceValue.toFixed(2)}` }, + { label: t('coupon_valid_until'), value: coupon.expiryDate ? coupon.expiryDate.split('T')[0].replace(/-/g, '/') : '--' }, + { label: t('coupon_type_label'), value: coupon.couponType === 'consumer' ? t('coupon_type_consumer') : coupon.couponType }, + { label: t('coupon_store'), value: stores.length > 0 ? `${stores.length} ${t('coupon_stores_count')}` : t('coupon_stores_count') }, ].map((item, i) => ( {item.label} @@ -61,17 +111,23 @@ const DetailPage: React.FC = () => { {/* Rules */} {t('coupon_description')} - {[ - t('coupon_rule_universal'), - t('coupon_rule_giftable'), - t('coupon_rule_anytime'), - t('coupon_rule_no_stack'), - ].map((rule, i) => ( - - - {rule} + {coupon.description ? ( + + {coupon.description} - ))} + ) : ( + [ + t('coupon_rule_universal'), + t('coupon_rule_giftable'), + t('coupon_rule_anytime'), + t('coupon_rule_no_stack'), + ].map((rule, i) => ( + + + {rule} + + )) + )} {/* Utility Track Notice */} @@ -82,59 +138,60 @@ const DetailPage: React.FC = () => { {/* Nearby Stores */} - - - {t('detail_nearby_stores')} - {t('detail_view_all')} > - - {[ - { name: '星巴克 国贸店', distance: '0.8km' }, - { name: '星巴克 三里屯店', distance: '1.2km' }, - { name: '星巴克 望京店', distance: '2.5km' }, - ].map((store, i) => ( - - {store.name} - - {store.distance} - + {stores.length > 0 && ( + + + {t('detail_nearby_stores')} + {t('detail_view_all')} > - ))} - - - {/* Similar Coupons */} - - - {t('detail_similar_coupons')} - {t('more')} > - - - {[ - { name: 'Costa ¥20 咖啡券', price: '¥16.00', discount: '8折' }, - { name: 'Luckin ¥15 咖啡券', price: '¥12.75', discount: '8.5折' }, - { name: 'Tim Hortons ¥18 饮品券', price: '¥14.40', discount: '8折' }, - { name: 'Pacific Coffee ¥22', price: '¥17.60', discount: '8折' }, - ].map((item, i) => ( - - - 🎫 - - {item.name} - - {item.price} - {item.discount} + {stores.slice(0, 3).map((store) => ( + + {store.name} + + {store.distance ? `${store.distance}km` : store.address} ))} - - + + )} + + {/* Similar Coupons */} + {similarCoupons.length > 0 && ( + + + {t('detail_similar_coupons')} + {t('more')} > + + + {similarCoupons.map((item) => ( + Taro.navigateTo({ url: `/pages/detail/index?id=${item.id}` })} + > + + 🎫 + + {item.name} + + ¥{item.currentPrice.toFixed(2)} + + {((item.currentPrice / item.faceValue) * 10).toFixed(1)}折 + + + + ))} + + + )} {/* Bottom Buy Bar */} {t('order_total')} - ¥21.25 + ¥{coupon.currentPrice.toFixed(2)} - Taro.navigateTo({ url: '/pages/purchase/index' })}> + Taro.navigateTo({ url: `/pages/purchase/index?id=${coupon.id}` })}> {t('coupon_buy_now')} diff --git a/frontend/miniapp/src/pages/h5-activity/index.tsx b/frontend/miniapp/src/pages/h5-activity/index.tsx index 1e83b29..ad0d051 100644 --- a/frontend/miniapp/src/pages/h5-activity/index.tsx +++ b/frontend/miniapp/src/pages/h5-activity/index.tsx @@ -1,5 +1,7 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { t } from '@/i18n'; +import { getFeaturedCoupons } from '../../services/coupon'; +import type { CouponItem } from '../../services/coupon'; // Taro mini-program component /** @@ -10,6 +12,14 @@ import { t } from '@/i18n'; */ const H5ActivityPage: React.FC = () => { + const [coupons, setCoupons] = useState([]); + + useEffect(() => { + getFeaturedCoupons(1, 3) + .then((data) => setCoupons(data.items)) + .catch(() => {}); + }, []); + return ( {/* Hero Banner */} @@ -51,73 +61,106 @@ const H5ActivityPage: React.FC = () => { {t('activity_limited')} - {[ - { - brand: 'Starbucks', - brandInitial: 'S', - name: '星巴克 ¥50 礼品卡', - originalPrice: '¥50.00', - discountPrice: '¥35.00', - discount: '7折', - tag: t('activity_tag_hot'), - }, - { - brand: 'Amazon', - brandInitial: 'A', - name: 'Amazon ¥200 购物券', - originalPrice: '¥200.00', - discountPrice: '¥156.00', - discount: '7.8折', - tag: t('activity_tag_selling'), - }, - { - brand: 'Nike', - brandInitial: 'N', - name: 'Nike ¥100 运动券', - originalPrice: '¥100.00', - discountPrice: '¥72.00', - discount: '7.2折', - tag: t('activity_tag_new'), - }, - ].map((coupon, i) => ( - - {/* Discount Badge */} - - {coupon.tag} - - - {/* Card Top: Brand + Image */} - - - 🎫 - - - {coupon.discount} - - - - {/* Card Body */} - - - - {coupon.brandInitial} + {coupons.length > 0 ? coupons.map((coupon) => { + const discount = ((coupon.currentPrice / coupon.faceValue) * 10).toFixed(1) + '折'; + const brandInitial = (coupon.brandName || coupon.name)[0]; + return ( + + {/* Card Top: Brand + Image */} + + + 🎫 + + + {discount} - {coupon.brand} - {coupon.name} - - ¥ - {coupon.discountPrice.replace('¥', '')} - {coupon.originalPrice} - - - {/* Buy Button */} - - {t('activity_buy_now')} + {/* Card Body */} + + + + {brandInitial} + + {coupon.brandName || coupon.category} + + {coupon.name} + + ¥ + {coupon.currentPrice.toFixed(2)} + ¥{coupon.faceValue} + + + + {/* Buy Button */} + + {t('activity_buy_now')} + - - ))} + ); + }) : ( + // Fallback placeholder cards + [ + { + brand: 'Starbucks', + brandInitial: 'S', + name: '星巴克 ¥50 礼品卡', + originalPrice: '¥50.00', + discountPrice: '¥35.00', + discount: '7折', + tag: t('activity_tag_hot'), + }, + { + brand: 'Amazon', + brandInitial: 'A', + name: 'Amazon ¥200 购物券', + originalPrice: '¥200.00', + discountPrice: '¥156.00', + discount: '7.8折', + tag: t('activity_tag_selling'), + }, + { + brand: 'Nike', + brandInitial: 'N', + name: 'Nike ¥100 运动券', + originalPrice: '¥100.00', + discountPrice: '¥72.00', + discount: '7.2折', + tag: t('activity_tag_new'), + }, + ].map((coupon, i) => ( + + + {coupon.tag} + + + + 🎫 + + + {coupon.discount} + + + + + + {coupon.brandInitial} + + {coupon.brand} + + {coupon.name} + + ¥ + {coupon.discountPrice.replace('¥', '')} + {coupon.originalPrice} + + + + {t('activity_buy_now')} + + + )) + )} {/* Activity Rules */} diff --git a/frontend/miniapp/src/pages/h5-register/index.tsx b/frontend/miniapp/src/pages/h5-register/index.tsx index 7e3a567..cd2780c 100644 --- a/frontend/miniapp/src/pages/h5-register/index.tsx +++ b/frontend/miniapp/src/pages/h5-register/index.tsx @@ -1,5 +1,8 @@ -import React from 'react'; +import React, { useState } from 'react'; import { t } from '@/i18n'; +import Taro from '@tarojs/taro'; +import { register, sendSmsCode } from '../../services/auth'; +import { authStore } from '../../store/auth'; // Taro mini-program component /** @@ -10,6 +13,58 @@ import { t } from '@/i18n'; */ const H5RegisterPage: React.FC = () => { + const [phone, setPhone] = useState(''); + const [smsCode, setSmsCode] = useState(''); + const [agreed, setAgreed] = useState(false); + const [codeSending, setCodeSending] = useState(false); + const [codeCountdown, setCodeCountdown] = useState(0); + const [submitting, setSubmitting] = useState(false); + + const handleSendCode = () => { + if (!phone || phone.length < 11 || codeSending || codeCountdown > 0) return; + setCodeSending(true); + sendSmsCode(phone) + .then(() => { + Taro.showToast({ title: t('login_code_sent') || '验证码已发送', icon: 'success' }); + setCodeCountdown(60); + const timer = setInterval(() => { + setCodeCountdown((prev) => { + if (prev <= 1) { + clearInterval(timer); + return 0; + } + return prev - 1; + }); + }, 1000); + }) + .catch(() => {}) + .finally(() => setCodeSending(false)); + }; + + const handleRegister = () => { + if (!phone || !smsCode || !agreed || submitting) return; + setSubmitting(true); + register(phone, smsCode) + .then((result) => { + // Store tokens via auth store if needed + Taro.setStorageSync('token', result.accessToken); + Taro.showToast({ title: t('register_success') || '注册成功', icon: 'success' }); + setTimeout(() => { + Taro.reLaunch({ url: '/pages/home/index' }); + }, 1000); + }) + .catch(() => {}) + .finally(() => setSubmitting(false)); + }; + + const handleWechatLogin = () => { + authStore.loginByWechat() + .then(() => { + Taro.reLaunch({ url: '/pages/home/index' }); + }) + .catch(() => {}); + }; + return ( {/* Top Branding Section */} @@ -72,6 +127,8 @@ const H5RegisterPage: React.FC = () => { placeholder={t('login_phone_placeholder')} type="number" maxlength={11} + value={phone} + onInput={(e: any) => setPhone(e.detail.value)} /> @@ -86,17 +143,21 @@ const H5RegisterPage: React.FC = () => { placeholder={t('login_code_placeholder')} type="number" maxlength={6} + value={smsCode} + onInput={(e: any) => setSmsCode(e.detail.value)} /> - - {t('login_send_code')} + + + {codeCountdown > 0 ? `${codeCountdown}s` : (codeSending ? '...' : t('login_send_code'))} + {/* Terms Checkbox */} - - + setAgreed(!agreed)}> + @@ -108,8 +169,8 @@ const H5RegisterPage: React.FC = () => { {/* Primary CTA Button */} - - {t('register_now')} + + {submitting ? (t('loading') || '注册中...') : t('register_now')} {/* Social Login Divider */} @@ -120,7 +181,7 @@ const H5RegisterPage: React.FC = () => { {/* WeChat Login */} - + 💬 {t('login_wechat')} @@ -128,7 +189,7 @@ const H5RegisterPage: React.FC = () => { {/* Already Have Account */} {t('register_has_account')} - {t('register_login_now')} + Taro.navigateTo({ url: '/pages/login/index' })}>{t('register_login_now')} @@ -289,10 +350,6 @@ CSS (H5注册引导页样式 - 对应 index.scss): padding: 0 28rpx; border: 2rpx solid #F1F3F8; } -.form-input-wrap:focus-within { - border-color: #6C5CE7; - box-shadow: 0 0 0 4rpx rgba(108,92,231,0.1); -} .form-input-icon { font-size: 32rpx; margin-right: 16rpx; @@ -392,10 +449,6 @@ CSS (H5注册引导页样式 - 对应 index.scss): justify-content: center; box-shadow: 0 8rpx 32rpx rgba(108,92,231,0.35); } -.register-btn:active { - opacity: 0.9; - transform: scale(0.98); -} .register-btn-text { font-size: 32rpx; font-weight: 600; @@ -430,9 +483,6 @@ CSS (H5注册引导页样式 - 对应 index.scss): align-items: center; justify-content: center; } -.wechat-login-btn:active { - opacity: 0.9; -} .wechat-login-icon { font-size: 36rpx; margin-right: 12rpx; diff --git a/frontend/miniapp/src/pages/h5-share/index.tsx b/frontend/miniapp/src/pages/h5-share/index.tsx index 1c418f7..99a99cc 100644 --- a/frontend/miniapp/src/pages/h5-share/index.tsx +++ b/frontend/miniapp/src/pages/h5-share/index.tsx @@ -1,5 +1,8 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { t } from '@/i18n'; +import Taro from '@tarojs/taro'; +import { getCouponDetail } from '../../services/coupon'; +import type { CouponItem } from '../../services/coupon'; /** * E2. H5页面 - 券分享页 + 活动落地页 + 注册引导页 @@ -9,6 +12,27 @@ import { t } from '@/i18n'; // === 券分享页 === export const SharePage: React.FC = () => { + const [coupon, setCoupon] = useState(null); + + useEffect(() => { + const params = Taro.getCurrentInstance().router?.params; + const id = params?.id; + if (id) { + getCouponDetail(id) + .then((data) => setCoupon(data)) + .catch(() => {}); + } + }, []); + + const brandName = coupon?.brandName || 'Brand'; + const brandInitial = brandName[0] || 'G'; + const name = coupon?.name || '---'; + const price = coupon ? coupon.currentPrice.toFixed(2) : '0.00'; + const faceValue = coupon ? coupon.faceValue : 0; + const savings = coupon ? (coupon.faceValue - coupon.currentPrice).toFixed(2) : '0.00'; + const discount = coupon ? ((coupon.currentPrice / coupon.faceValue) * 10).toFixed(1) + '折' : '--'; + const creditRating = coupon?.creditRating || ''; + return (
{ background: '#F1F3F8', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 14, fontWeight: 700, color: '#6C5CE7', - }}>S
- Starbucks - AAA + }}>{brandInitial}
+ {brandName} + {creditRating && ( + {creditRating} + )}

- 星巴克 $25 礼品卡 + {name}

{ }}>
$ - 21.25 - $25 + {price} + ${faceValue} 8.5折 + }}>{discount}
- {t('coupon_price_save')} $3.75 + {t('coupon_price_save')} ${savings}
{/* Info rows */} {[ - { label: t('coupon_valid_until'), value: '2026/12/31' }, + { label: t('coupon_valid_until'), value: coupon?.expiryDate ? coupon.expiryDate.split('T')[0].replace(/-/g, '/') : '--' }, { label: t('coupon_store'), value: t('coupon_stores_count') }, ].map((item, i) => (
{ + const [hotCoupons, setHotCoupons] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + getFeaturedCoupons(1, 4) + .then((data) => { + setHotCoupons(data.items); + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + const formatDiscount = (coupon: CouponItem) => { + const ratio = coupon.currentPrice / coupon.faceValue; + return `${(ratio * 10).toFixed(1)}折`; + }; + return ( {/* Search Bar */} @@ -76,27 +95,36 @@ const HomePage: React.FC = () => { - {[ - { brand: 'Starbucks', name: '星巴克 ¥25 礼品卡', price: '¥21.25', face: '¥25', discount: '8.5折' }, - { brand: 'Amazon', name: 'Amazon ¥100 购物券', price: '¥85.00', face: '¥100', discount: '8.5折' }, - { brand: 'Nike', name: 'Nike ¥80 运动券', price: '¥68.00', face: '¥80', discount: '8.5折' }, - { brand: 'Target', name: 'Target ¥30 折扣券', price: '¥24.00', face: '¥30', discount: '8折' }, - ].map((coupon, i) => ( - Taro.navigateTo({ url: '/pages/detail/index' })}> - - 🎫 - - - {coupon.brand} - {coupon.name} - - {coupon.price} - {coupon.face} - {coupon.discount} + {loading ? ( + + {t('loading') || '加载中...'} + + ) : hotCoupons.length === 0 ? ( + + {t('empty') || '暂无数据'} + + ) : ( + hotCoupons.map((coupon) => ( + Taro.navigateTo({ url: `/pages/detail/index?id=${coupon.id}` })} + > + + 🎫 + + + {coupon.brandName || coupon.category} + {coupon.name} + + ¥{coupon.currentPrice.toFixed(2)} + ¥{coupon.faceValue} + {formatDiscount(coupon)} + - - ))} + )) + )} ); diff --git a/frontend/miniapp/src/pages/kyc/index.tsx b/frontend/miniapp/src/pages/kyc/index.tsx index 32b0565..72c8ce8 100644 --- a/frontend/miniapp/src/pages/kyc/index.tsx +++ b/frontend/miniapp/src/pages/kyc/index.tsx @@ -1,6 +1,8 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import Taro from '@tarojs/taro'; import { t } from '@/i18n'; +import { getKycStatus, submitKyc } from '../../services/user'; +import type { KycStatus } from '../../services/user'; // Taro mini-program component /** @@ -11,53 +13,87 @@ import { t } from '@/i18n'; interface KycTier { level: string; + levelNum: number; titleKey: string; descKey: string; featureKey: string; limitKey: string; - status: 'completed' | 'locked'; icon: string; } const tiers: KycTier[] = [ { level: 'L1', + levelNum: 1, titleKey: 'kyc_l1_title', descKey: 'kyc_l1_desc', featureKey: 'kyc_l1_feature', limitKey: 'kyc_l1_limit', - status: 'completed', icon: '\u2705', }, { level: 'L2', + levelNum: 2, titleKey: 'kyc_l2_title', descKey: 'kyc_l2_desc', featureKey: 'kyc_l2_feature', limitKey: 'kyc_l2_limit', - status: 'locked', icon: '\uD83D\uDD12', }, { level: 'L3', + levelNum: 3, titleKey: 'kyc_l3_title', descKey: 'kyc_l3_desc', featureKey: 'kyc_l3_feature', limitKey: 'kyc_l3_limit', - status: 'locked', icon: '\uD83D\uDD12', }, ]; const KycPage: React.FC = () => { - const handleVerify = () => { + const [kycStatus, setKycStatus] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + getKycStatus() + .then((data) => setKycStatus(data)) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + const currentLevel = kycStatus?.level ?? 1; + + const getTierStatus = (tier: KycTier): 'completed' | 'pending' | 'locked' => { + if (tier.levelNum <= currentLevel) return 'completed'; + if (kycStatus?.status === 'pending' && tier.levelNum === currentLevel + 1) return 'pending'; + return 'locked'; + }; + + const handleVerify = (tier: KycTier) => { + if (tier.levelNum <= currentLevel) return; + Taro.showModal({ - title: '', + title: t('kyc_go_verify'), content: t('kyc_verify_in_app'), - showCancel: false, + showCancel: true, + confirmText: t('confirm'), + cancelText: t('cancel'), + success: (res) => { + if (res.confirm) { + submitKyc({ level: tier.levelNum, documents: {} }) + .then(() => { + Taro.showToast({ title: t('kyc_submitted') || '已提交', icon: 'success' }); + }) + .catch(() => {}); + } + }, }); }; + const currentTierTitleKey = tiers.find((t) => t.levelNum === currentLevel)?.titleKey || 'kyc_l1_title'; + const currentTierLimitKey = tiers.find((t) => t.levelNum === currentLevel)?.limitKey || 'kyc_l1_limit'; + return ( {/* Current Level Card */} @@ -66,52 +102,60 @@ const KycPage: React.FC = () => { {'\uD83D\uDEE1\uFE0F'} {t('kyc_current_level')} - {t('kyc_l1_title')} + {loading ? '...' : t(currentTierTitleKey)} {t('kyc_daily_limit')} - {t('kyc_l1_limit')} + {loading ? '...' : (kycStatus?.dailyLimit ? `$${kycStatus.dailyLimit}` : t(currentTierLimitKey))} {/* Tier Cards */} - {tiers.map((tier) => ( - - - - {tier.icon} - {t(tier.titleKey)} + {tiers.map((tier) => { + const status = getTierStatus(tier); + return ( + + + + {status === 'completed' ? '\u2705' : tier.icon} + {t(tier.titleKey)} + + {status === 'completed' && ( + + {t('kyc_completed')} + + )} + {status === 'pending' && ( + + {t('kyc_pending') || '审核中'} + + )} - {tier.status === 'completed' && ( - - {t('kyc_completed')} + + {t(tier.descKey)} + + + + + {t(tier.featureKey)} + + + + {t('kyc_daily_limit')}: {t(tier.limitKey)} + + + + {status === 'locked' && ( + handleVerify(tier)}> + {t('kyc_go_verify')} )} - - {t(tier.descKey)} - - - - - {t(tier.featureKey)} - - - - {t('kyc_daily_limit')}: {t(tier.limitKey)} - - - - {tier.status === 'locked' && ( - - {t('kyc_go_verify')} - - )} - - ))} + ); + })} ); diff --git a/frontend/miniapp/src/pages/login/index.tsx b/frontend/miniapp/src/pages/login/index.tsx index b8f7882..c30ad4e 100644 --- a/frontend/miniapp/src/pages/login/index.tsx +++ b/frontend/miniapp/src/pages/login/index.tsx @@ -1,5 +1,8 @@ -import React from 'react'; +import React, { useState } from 'react'; import { t } from '@/i18n'; +import Taro from '@tarojs/taro'; +import { authStore } from '../../store/auth'; +import { sendSmsCode } from '../../services/auth'; // Taro mini-program component /** @@ -10,6 +13,54 @@ import { t } from '@/i18n'; */ const LoginPage: React.FC = () => { + const [phone, setPhone] = useState(''); + const [code, setCode] = useState(''); + const [codeSending, setCodeSending] = useState(false); + const [codeCountdown, setCodeCountdown] = useState(0); + const [logging, setLogging] = useState(false); + + const handleSendCode = () => { + if (!phone || phone.length < 11 || codeSending || codeCountdown > 0) return; + setCodeSending(true); + sendSmsCode(phone) + .then(() => { + Taro.showToast({ title: t('login_code_sent') || '验证码已发送', icon: 'success' }); + setCodeCountdown(60); + const timer = setInterval(() => { + setCodeCountdown((prev) => { + if (prev <= 1) { + clearInterval(timer); + return 0; + } + return prev - 1; + }); + }, 1000); + }) + .catch(() => {}) + .finally(() => setCodeSending(false)); + }; + + const handlePhoneLogin = () => { + if (!phone || !code || logging) return; + setLogging(true); + authStore.loginByPhone(phone, code) + .then(() => { + Taro.reLaunch({ url: '/pages/home/index' }); + }) + .catch(() => {}) + .finally(() => setLogging(false)); + }; + + const handleWechatLogin = () => { + setLogging(true); + authStore.loginByWechat() + .then(() => { + Taro.reLaunch({ url: '/pages/home/index' }); + }) + .catch(() => {}) + .finally(() => setLogging(false)); + }; + return ( {/* Logo */} @@ -24,9 +75,9 @@ const LoginPage: React.FC = () => { {/* WeChat Login (小程序) */} - + 💬 - {t('login_wechat')} + {logging ? (t('loading') || '登录中...') : t('login_wechat')} @@ -39,19 +90,35 @@ const LoginPage: React.FC = () => { 📱 - + setPhone(e.detail.value)} + maxlength={11} + /> 🔒 - + setCode(e.detail.value)} + maxlength={6} + /> - - {t('login_send_code')} + + + {codeCountdown > 0 ? `${codeCountdown}s` : (codeSending ? '...' : t('login_send_code'))} + - - {t('login_btn')} + + {logging ? (t('loading') || '登录中...') : t('login_btn')} diff --git a/frontend/miniapp/src/pages/messages/index.tsx b/frontend/miniapp/src/pages/messages/index.tsx index 1b856d8..a42d4e6 100644 --- a/frontend/miniapp/src/pages/messages/index.tsx +++ b/frontend/miniapp/src/pages/messages/index.tsx @@ -1,5 +1,7 @@ -import React from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { t } from '@/i18n'; +import { getMessages, markAllRead, markAsRead } from '../../services/notification'; +import type { MessageItem } from '../../services/notification'; // Taro mini-program component /** @@ -8,23 +10,6 @@ import { t } from '@/i18n'; * 交易通知、到期提醒、价格提醒、公告 */ -interface Message { - id: number; - title: string; - body: string; - time: string; - type: 'transaction' | 'expiry' | 'price' | 'announcement'; - isRead: boolean; -} - -const mockMessages: Message[] = [ - { id: 1, title: '购买成功', body: '您已成功购买 星巴克 ¥25 礼品卡,花费 ¥21.25', time: '14:32', type: 'transaction', isRead: false }, - { id: 2, title: '券即将到期', body: 'Target ¥30 折扣券 将于3天后到期', time: '10:15', type: 'expiry', isRead: false }, - { id: 3, title: '价格提醒', body: 'Amazon ¥100 购物券 价格降至 ¥82', time: '昨天', type: 'price', isRead: true }, - { id: 4, title: '出售成交', body: 'Nike ¥80 运动券 已售出,收入 ¥68', time: '02/07', type: 'transaction', isRead: true }, - { id: 5, title: '核销成功', body: 'Walmart ¥50 生活券 已核销', time: '02/06', type: 'transaction', isRead: true }, -]; - const typeIconMap: Record = { transaction: '\uD83D\uDD04', expiry: '\u23F0', @@ -39,8 +24,12 @@ const typeColorMap: Record = { announcement: '#3498DB', }; +const TAB_TYPE_MAP: (string | null)[] = [null, 'transaction', 'expiry', 'announcement']; + const MessagesPage: React.FC = () => { - const [activeTab, setActiveTab] = React.useState(0); + const [activeTab, setActiveTab] = useState(0); + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(true); const tabs = [ { label: t('all'), filter: null }, @@ -49,16 +38,57 @@ const MessagesPage: React.FC = () => { { label: t('messages_tab_announcement'), filter: 'announcement' }, ]; - const filteredMessages = activeTab === 0 - ? mockMessages - : mockMessages.filter((m) => m.type === tabs[activeTab].filter); + const loadMessages = useCallback((tabIndex: number) => { + setLoading(true); + const typeFilter = TAB_TYPE_MAP[tabIndex]; + getMessages({ type: typeFilter || undefined }) + .then((data) => setMessages(data.items)) + .catch(() => setMessages([])) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + loadMessages(activeTab); + }, [activeTab, loadMessages]); + + const handleMarkAllRead = () => { + markAllRead() + .then(() => { + setMessages((prev) => prev.map((m) => ({ ...m, isRead: true }))); + }) + .catch(() => {}); + }; + + const handleMessageTap = (msg: MessageItem) => { + if (!msg.isRead) { + markAsRead(msg.id) + .then(() => { + setMessages((prev) => prev.map((m) => m.id === msg.id ? { ...m, isRead: true } : m)); + }) + .catch(() => {}); + } + }; + + const formatTime = (dateStr: string) => { + if (!dateStr) return ''; + try { + const date = new Date(dateStr); + const now = new Date(); + if (date.toDateString() === now.toDateString()) { + return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); + } + return `${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}`; + } catch { + return dateStr; + } + }; return ( {/* Header */} {t('messages_title')} - {t('messages_mark_all_read')} + {t('messages_mark_all_read')} {/* Tab Row */} @@ -78,25 +108,29 @@ const MessagesPage: React.FC = () => { {/* Message List */} - {filteredMessages.length === 0 ? ( + {loading ? ( + + {t('loading') || '加载中...'} + + ) : messages.length === 0 ? ( {typeIconMap.announcement} {t('messages_empty')} ) : ( - filteredMessages.map((msg) => ( - - - {typeIconMap[msg.type]} + messages.map((msg) => ( + handleMessageTap(msg)}> + + {typeIconMap[msg.type] || typeIconMap.announcement} {msg.title} - {msg.time} + {formatTime(msg.createdAt)} - {msg.body} + {msg.content} {!msg.isRead && } diff --git a/frontend/miniapp/src/pages/my-coupon-detail/index.tsx b/frontend/miniapp/src/pages/my-coupon-detail/index.tsx index 9acf4fd..1f48886 100644 --- a/frontend/miniapp/src/pages/my-coupon-detail/index.tsx +++ b/frontend/miniapp/src/pages/my-coupon-detail/index.tsx @@ -1,6 +1,8 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { t } from '@/i18n'; import Taro from '@tarojs/taro'; +import { getCouponDetail } from '../../services/coupon'; +import type { CouponItem } from '../../services/coupon'; // Taro mini-program component (WeChat / Alipay) /** @@ -10,14 +12,6 @@ import Taro from '@tarojs/taro'; * 支持转赠 / 出售操作 */ -const infoRows = [ - { label: t('coupon_face_value'), value: '¥25.00' }, - { label: t('my_coupon_purchase_price'), value: '¥21.25' }, - { label: t('coupon_valid_until'), value: '2026/12/31' }, - { label: t('my_coupon_order_no'), value: 'GNX-20260209-001234' }, - { label: t('my_coupon_resell_count'), value: '3次' }, -]; - const usageRules = [ t('my_coupon_use_in_store'), t('my_coupon_use_in_time'), @@ -26,8 +20,25 @@ const usageRules = [ ]; const MyCouponDetailPage: React.FC = () => { + const [coupon, setCoupon] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const params = Taro.getCurrentInstance().router?.params; + const id = params?.id; + if (!id) { + setLoading(false); + return; + } + getCouponDetail(id) + .then((data) => setCoupon(data)) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + const handleTransfer = () => { - Taro.navigateTo({ url: '/pages/transfer/index' }); + if (!coupon) return; + Taro.navigateTo({ url: `/pages/transfer/index?couponId=${coupon.id}&couponName=${encodeURIComponent(coupon.name)}` }); }; const handleSell = () => { @@ -39,18 +50,42 @@ const MyCouponDetailPage: React.FC = () => { }); }; + if (loading) { + return ( + + {t('loading') || '加载中...'} + + ); + } + + if (!coupon) { + return ( + + {t('empty') || '未找到券信息'} + + ); + } + + const infoRows = [ + { label: t('coupon_face_value'), value: `¥${coupon.faceValue.toFixed(2)}` }, + { label: t('my_coupon_purchase_price'), value: `¥${coupon.currentPrice.toFixed(2)}` }, + { label: t('coupon_valid_until'), value: coupon.expiryDate ? coupon.expiryDate.split('T')[0].replace(/-/g, '/') : '--' }, + { label: t('my_coupon_order_no'), value: coupon.id }, + { label: t('my_coupon_resell_count'), value: coupon.isTransferable ? t('my_coupon_transfer') : '--' }, + ]; + return ( {/* QR Code Card */} - Starbucks + {coupon.brandName || coupon.category} {t('my_coupon_active')} - 星巴克 ¥25 礼品卡 + {coupon.name} @@ -59,7 +94,7 @@ const MyCouponDetailPage: React.FC = () => { - GNX-STB-A1B2C3D4 + GNX-{coupon.id.slice(0, 8).toUpperCase()} {t('my_coupon_show_qr_hint')} diff --git a/frontend/miniapp/src/pages/my-coupons/index.tsx b/frontend/miniapp/src/pages/my-coupons/index.tsx index 0cc3179..72b9576 100644 --- a/frontend/miniapp/src/pages/my-coupons/index.tsx +++ b/frontend/miniapp/src/pages/my-coupons/index.tsx @@ -1,6 +1,8 @@ -import React from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import Taro from '@tarojs/taro'; import { t } from '@/i18n'; +import { getMyHoldings } from '../../services/my-coupon'; +import type { CouponItem } from '../../services/coupon'; // Taro mini-program component /** @@ -10,16 +12,36 @@ import { t } from '@/i18n'; * 每张券可操作:使用(核销)、出售、转赠 */ +const TAB_STATUS_MAP = ['active', 'used', 'expired']; + const MyCouponsPage: React.FC = () => { const tabs = [t('my_coupons_available'), t('my_coupons_used'), t('my_coupons_expired')]; - const [activeTab] = React.useState(0); + const [activeTab, setActiveTab] = useState(0); + const [coupons, setCoupons] = useState([]); + const [loading, setLoading] = useState(true); + + const loadCoupons = useCallback((tabIndex: number) => { + setLoading(true); + getMyHoldings({ status: TAB_STATUS_MAP[tabIndex] }) + .then((data) => setCoupons(data.items)) + .catch(() => setCoupons([])) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + loadCoupons(activeTab); + }, [activeTab, loadCoupons]); + + const handleTabChange = (index: number) => { + setActiveTab(index); + }; return ( {/* Tabs */} {tabs.map((tab, i) => ( - + handleTabChange(i)}> {tab} {i === activeTab && } @@ -27,41 +49,49 @@ const MyCouponsPage: React.FC = () => { {/* Coupon List */} - - {[ - { brand: 'Starbucks', name: '星巴克 ¥25 礼品卡', expiry: '2026-04-15', status: 'active' }, - { brand: 'Amazon', name: 'Amazon ¥100 购物券', expiry: '2026-03-20', status: 'active' }, - { brand: 'Nike', name: 'Nike ¥80 运动券', expiry: '2026-05-01', status: 'active' }, - ].map((coupon, i) => ( - Taro.navigateTo({ url: '/pages/my-coupon-detail/index' })}> - - - 🎫 - - - - {coupon.brand} - {coupon.name} - {t('coupon_valid_until_prefix')} {coupon.expiry} - - - - {t('coupon_use')} - - - {/* Ticket notch decoration */} - - - - ))} - - - {/* Empty State for other tabs */} - {activeTab > 0 && ( + {loading ? ( + + {t('loading') || '加载中...'} + + ) : coupons.length === 0 ? ( 📭 {t('my_coupons_empty')} + ) : ( + + {coupons.map((coupon) => ( + Taro.navigateTo({ url: `/pages/my-coupon-detail/index?id=${coupon.id}` })} + > + + + 🎫 + + + + {coupon.brandName || coupon.category} + {coupon.name} + {t('coupon_valid_until_prefix')} {coupon.expiryDate ? coupon.expiryDate.split('T')[0] : '--'} + + + {activeTab === 0 && ( + { + e.stopPropagation(); + Taro.navigateTo({ url: `/pages/redeem/index?id=${coupon.id}&name=${encodeURIComponent(coupon.name)}&faceValue=${coupon.faceValue}` }); + }}> + {t('coupon_use')} + + )} + + {/* Ticket notch decoration */} + + + + ))} + )} ); diff --git a/frontend/miniapp/src/pages/orders/index.tsx b/frontend/miniapp/src/pages/orders/index.tsx index 89d7020..f7d4947 100644 --- a/frontend/miniapp/src/pages/orders/index.tsx +++ b/frontend/miniapp/src/pages/orders/index.tsx @@ -1,26 +1,17 @@ -import React from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { t } from '@/i18n'; +import { getMyOrders } from '../../services/trading'; +import type { OrderItem } from '../../services/trading'; // Taro mini-program - Order List /** * P1. Order List - Tab-filtered order management * * Tabs: all / pending payment / pending delivery / completed / cancelled - * Mock order data, status badges, filter logic */ type OrderStatus = 'pending_payment' | 'pending_delivery' | 'completed' | 'cancelled'; -interface Order { - id: string; - brand: string; - name: string; - price: string; - status: OrderStatus; - time: string; - orderNo: string; -} - const STATUS_COLORS: Record = { pending_payment: '#FF9500', pending_delivery: '#6C5CE7', @@ -35,15 +26,7 @@ const STATUS_LABELS: Record = { cancelled: 'order_cancelled', }; -const MOCK_ORDERS: Order[] = [ - { id: '1', brand: 'Starbucks', name: '星巴克 ¥25 礼品卡', price: '¥21.25', status: 'completed', time: '2026-02-09 14:32', orderNo: 'GNX-20260209-001' }, - { id: '2', brand: 'Amazon', name: 'Amazon ¥100 购物券', price: '¥85.00', status: 'pending_payment', time: '2026-02-10 10:15', orderNo: 'GNX-20260210-002' }, - { id: '3', brand: 'Target', name: 'Target ¥30 折扣券', price: '¥24.00', status: 'completed', time: '2026-02-08 16:20', orderNo: 'GNX-20260208-003' }, - { id: '4', brand: 'Nike', name: 'Nike ¥80 运动券', price: '¥68.00', status: 'pending_delivery', time: '2026-02-07 09:30', orderNo: 'GNX-20260207-004' }, - { id: '5', brand: 'Walmart', name: 'Walmart ¥50 生活券', price: '¥42.50', status: 'cancelled', time: '2026-02-06 11:45', orderNo: 'GNX-20260206-005' }, -]; - -const TAB_STATUS_MAP: (OrderStatus | null)[] = [ +const TAB_STATUS_MAP: (string | null)[] = [ null, 'pending_payment', 'pending_delivery', @@ -52,7 +35,9 @@ const TAB_STATUS_MAP: (OrderStatus | null)[] = [ ]; const OrdersPage: React.FC = () => { - const [activeTab, setActiveTab] = React.useState(0); + const [activeTab, setActiveTab] = useState(0); + const [orders, setOrders] = useState([]); + const [loading, setLoading] = useState(true); const tabs = [ t('order_all'), @@ -62,11 +47,18 @@ const OrdersPage: React.FC = () => { t('order_cancelled'), ]; - const filteredOrders = React.useMemo(() => { - const statusFilter = TAB_STATUS_MAP[activeTab]; - if (statusFilter === null) return MOCK_ORDERS; - return MOCK_ORDERS.filter((o) => o.status === statusFilter); - }, [activeTab]); + const loadOrders = useCallback((tabIndex: number) => { + setLoading(true); + const statusFilter = TAB_STATUS_MAP[tabIndex]; + getMyOrders({ status: statusFilter || undefined }) + .then((data) => setOrders(data.items)) + .catch(() => setOrders([])) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + loadOrders(activeTab); + }, [activeTab, loadOrders]); return ( @@ -95,41 +87,45 @@ const OrdersPage: React.FC = () => { {/* Order List */} - {filteredOrders.length === 0 ? ( + {loading ? ( + + {t('loading') || '加载中...'} + + ) : orders.length === 0 ? ( 📦 {t('order_empty')} ) : ( - filteredOrders.map((order) => ( + orders.map((order) => ( {/* Top row: icon + brand + name */} 🎫 - {order.brand} - {order.name} + {order.brandName || ''} + {order.couponName} {/* Middle row: price + status */} - {order.price} + ¥{order.totalAmount.toFixed(2)} - {t(STATUS_LABELS[order.status])} + {t(STATUS_LABELS[order.status as OrderStatus] || 'order_completed')} {/* Bottom row: order no + time */} - {order.orderNo} - {order.time} + {order.id} + {order.createdAt ? order.createdAt.replace('T', ' ').slice(0, 16) : ''} )) diff --git a/frontend/miniapp/src/pages/payment-success/index.tsx b/frontend/miniapp/src/pages/payment-success/index.tsx index b691b3b..054ee3d 100644 --- a/frontend/miniapp/src/pages/payment-success/index.tsx +++ b/frontend/miniapp/src/pages/payment-success/index.tsx @@ -9,14 +9,23 @@ import Taro from '@tarojs/taro'; * 支付成功庆祝动画 + 订单信息卡片 + 跳转按钮 */ -const orderInfo = [ - { label: t('payment_success_coupon_name'), value: '星巴克 ¥25 礼品卡' }, - { label: t('payment_success_pay_amount'), value: '¥21.25' }, - { label: t('payment_success_order_no'), value: 'GNX-20260209-001234' }, - { label: t('payment_success_pay_time'), value: '2026-02-09 14:32' }, -]; - const PaymentSuccessPage: React.FC = () => { + const params = Taro.getCurrentInstance().router?.params || {}; + const couponName = params.couponName ? decodeURIComponent(params.couponName) : '--'; + const amount = params.amount || '--'; + const orderId = params.orderId || '--'; + const payTime = new Date().toLocaleString('zh-CN', { + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', + }); + + const orderInfo = [ + { label: t('payment_success_coupon_name'), value: couponName }, + { label: t('payment_success_pay_amount'), value: amount.startsWith('¥') ? amount : `¥${amount}` }, + { label: t('payment_success_order_no'), value: orderId }, + { label: t('payment_success_pay_time'), value: payTime }, + ]; + const handleViewCoupon = () => { Taro.reLaunch({ url: '/pages/my-coupons/index' }); }; diff --git a/frontend/miniapp/src/pages/profile/index.tsx b/frontend/miniapp/src/pages/profile/index.tsx index 6500337..a109c61 100644 --- a/frontend/miniapp/src/pages/profile/index.tsx +++ b/frontend/miniapp/src/pages/profile/index.tsx @@ -1,6 +1,8 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import Taro from '@tarojs/taro'; import { t } from '@/i18n'; +import { getProfile, getUserStats } from '../../services/user'; +import type { UserProfile, UserStats } from '../../services/user'; // Taro mini-program component /** @@ -10,17 +12,32 @@ import { t } from '@/i18n'; */ const ProfilePage: React.FC = () => { + const [user, setUser] = useState(null); + const [stats, setStats] = useState(null); + + useEffect(() => { + getProfile() + .then((data) => setUser(data)) + .catch(() => {}); + getUserStats() + .then((data) => setStats(data)) + .catch(() => {}); + }, []); + + const displayName = user?.nickname || (user?.phone ? `User_${user.phone.slice(0, 3)}****${user.phone.slice(-2)}` : 'User'); + const kycLevel = user?.kycLevel ?? 0; + return ( {/* User Header */} - U + {(user?.nickname || 'U')[0].toUpperCase()} - User_138****88 + {displayName} - {`L1 ${t('profile_kyc_basic')}`} + {`L${kycLevel} ${kycLevel >= 1 ? t('profile_kyc_basic') : t('profile_kyc_basic')}`} @@ -28,9 +45,9 @@ const ProfilePage: React.FC = () => { {/* Stats */} {[ - { value: '5', label: t('profile_owned') }, - { value: '12', label: t('my_coupons_used') }, - { value: '3', label: t('my_coupons_expired') }, + { value: String(stats?.ownedCount ?? 0), label: t('profile_owned') }, + { value: String(stats?.usedCount ?? 0), label: t('my_coupons_used') }, + { value: String(stats?.expiredCount ?? 0), label: t('my_coupons_expired') }, ].map((s, i) => ( {s.value} @@ -59,8 +76,8 @@ const ProfilePage: React.FC = () => { {[ { icon: '🛡️', label: t('profile_kyc'), path: '/pages/kyc/index', value: '' }, - { icon: '🌐', label: t('profile_language'), path: '/pages/settings/index', value: '简体中文' }, - { icon: '💰', label: t('profile_currency'), path: '/pages/settings/index', value: 'USD' }, + { icon: '🌐', label: t('profile_language'), path: '/pages/settings/index', value: '' }, + { icon: '💰', label: t('profile_currency'), path: '/pages/settings/index', value: '' }, { icon: '❓', label: t('profile_help'), path: '', value: '' }, { icon: '⚙️', label: t('profile_settings'), path: '/pages/settings/index', value: '' }, ].map((item, i) => ( diff --git a/frontend/miniapp/src/pages/purchase/index.tsx b/frontend/miniapp/src/pages/purchase/index.tsx index 741ab66..0cb7a02 100644 --- a/frontend/miniapp/src/pages/purchase/index.tsx +++ b/frontend/miniapp/src/pages/purchase/index.tsx @@ -1,6 +1,8 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { t } from '@/i18n'; import Taro from '@tarojs/taro'; +import { getCouponDetail, purchaseCoupon } from '../../services/coupon'; +import type { CouponItem } from '../../services/coupon'; // Taro mini-program component (WeChat / Alipay) /** @@ -10,15 +12,47 @@ import Taro from '@tarojs/taro'; * price breakdown, fixed bottom bar with confirm button */ -const UNIT_PRICE = 21.25; -const FACE_VALUE = 25; - const PurchasePage: React.FC = () => { - const [quantity, setQuantity] = React.useState(1); + const [coupon, setCoupon] = useState(null); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [quantity, setQuantity] = useState(1); - const totalPrice = (UNIT_PRICE * quantity).toFixed(2); - const totalFace = (FACE_VALUE * quantity).toFixed(2); - const savings = ((FACE_VALUE - UNIT_PRICE) * quantity).toFixed(2); + useEffect(() => { + const params = Taro.getCurrentInstance().router?.params; + const id = params?.id; + if (!id) { + setLoading(false); + return; + } + getCouponDetail(id) + .then((data) => setCoupon(data)) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + if (loading) { + return ( + + {t('loading') || '加载中...'} + + ); + } + + if (!coupon) { + return ( + + {t('empty') || '未找到券信息'} + + ); + } + + const unitPrice = coupon.currentPrice; + const faceValue = coupon.faceValue; + const discount = ((unitPrice / faceValue) * 10).toFixed(1) + '折'; + const totalPrice = (unitPrice * quantity).toFixed(2); + const totalFace = (faceValue * quantity).toFixed(2); + const savings = ((faceValue - unitPrice) * quantity).toFixed(2); const handleMinus = () => { if (quantity > 1) setQuantity(quantity - 1); @@ -29,7 +63,16 @@ const PurchasePage: React.FC = () => { }; const handleConfirm = () => { - Taro.navigateTo({ url: '/pages/payment-success/index' }); + if (submitting) return; + setSubmitting(true); + purchaseCoupon(coupon.id, quantity) + .then((result) => { + Taro.navigateTo({ + url: `/pages/payment-success/index?orderId=${result.orderId}&couponName=${encodeURIComponent(coupon.name)}&amount=${totalPrice}`, + }); + }) + .catch(() => {}) + .finally(() => setSubmitting(false)); }; return ( @@ -40,13 +83,13 @@ const PurchasePage: React.FC = () => { 🎫 - Starbucks - 星巴克 ¥25 礼品卡 + {coupon.brandName || coupon.category} + {coupon.name} - ¥21.25 - ¥25 + ¥{unitPrice.toFixed(2)} + ¥{faceValue} - 8.5折 + {discount} @@ -89,7 +132,7 @@ const PurchasePage: React.FC = () => { {t('purchase_price_detail')} {t('purchase_unit_price')} - ¥{UNIT_PRICE.toFixed(2)} + ¥{unitPrice.toFixed(2)} {t('purchase_count')} @@ -119,7 +162,7 @@ const PurchasePage: React.FC = () => { ¥{totalPrice} - {t('purchase_confirm_pay')} + {submitting ? (t('loading') || '处理中...') : t('purchase_confirm_pay')} diff --git a/frontend/miniapp/src/pages/redeem/index.tsx b/frontend/miniapp/src/pages/redeem/index.tsx index 318e012..ede1730 100644 --- a/frontend/miniapp/src/pages/redeem/index.tsx +++ b/frontend/miniapp/src/pages/redeem/index.tsx @@ -1,5 +1,7 @@ -import React from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { t } from '@/i18n'; +import Taro from '@tarojs/taro'; +import { redeemCoupon } from '../../services/my-coupon'; // Taro mini-program component /** @@ -9,12 +11,65 @@ import { t } from '@/i18n'; */ const RedeemPage: React.FC = () => { + const params = Taro.getCurrentInstance().router?.params || {}; + const couponId = params.id || ''; + const couponName = params.name ? decodeURIComponent(params.name) : '---'; + const faceValue = params.faceValue || '0'; + + const [countdown, setCountdown] = useState(300); // 5 minutes + const [redemptionCode, setRedemptionCode] = useState(''); + const timerRef = useRef | null>(null); + + useEffect(() => { + // Generate redemption code on mount + if (couponId) { + redeemCoupon(couponId) + .then((data) => { + setRedemptionCode(data.redemptionId || ''); + }) + .catch(() => { + // fallback code + setRedemptionCode(`${Math.random().toString().slice(2, 6)} ${Math.random().toString().slice(2, 6)} ${Math.random().toString().slice(2, 6)}`); + }); + } + + // Countdown timer + timerRef.current = setInterval(() => { + setCountdown((prev) => { + if (prev <= 0) { + if (timerRef.current) clearInterval(timerRef.current); + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => { + if (timerRef.current) clearInterval(timerRef.current); + }; + }, [couponId]); + + const formatCountdown = (seconds: number) => { + const m = String(Math.floor(seconds / 60)).padStart(2, '0'); + const s = String(seconds % 60).padStart(2, '0'); + return `${m}:${s}`; + }; + + const handleRefresh = () => { + setCountdown(300); + if (couponId) { + redeemCoupon(couponId) + .then((data) => setRedemptionCode(data.redemptionId || '')) + .catch(() => {}); + } + }; + return ( {/* Coupon Info */} - 星巴克 ¥25 礼品卡 - {t('coupon_face_value')} ¥25.00 + {couponName} + {t('coupon_face_value')} ¥{Number(faceValue).toFixed(2)} {/* QR Code */} @@ -28,16 +83,16 @@ const RedeemPage: React.FC = () => { {/* Numeric Code */} - 8429 3751 0062 + {redemptionCode || '---- ---- ----'} {/* Countdown */} - {t('redeem_valid_time')} 04:58 + {t('redeem_valid_time')} {formatCountdown(countdown)} - + {t('redeem_refresh')} diff --git a/frontend/miniapp/src/pages/search/index.tsx b/frontend/miniapp/src/pages/search/index.tsx index dc4dce8..48685f7 100644 --- a/frontend/miniapp/src/pages/search/index.tsx +++ b/frontend/miniapp/src/pages/search/index.tsx @@ -1,7 +1,9 @@ -import React from 'react'; +import React, { useState, useCallback, useRef } from 'react'; import { t } from '@/i18n'; import Taro from '@tarojs/taro'; import CouponCard from '@/components/coupon-card'; +import { searchCoupons } from '../../services/coupon'; +import type { CouponItem } from '../../services/coupon'; // Taro mini-program component (WeChat / Alipay) /** @@ -11,26 +13,87 @@ import CouponCard from '@/components/coupon-card'; * 输入内容后展示券列表,点击跳转详情 */ -const mockResults = [ - { brand: 'Starbucks', name: '星巴克 ¥25 礼品卡', price: '¥21.25', faceValue: '¥25', discount: '8.5折' }, - { brand: 'Amazon', name: 'Amazon ¥100 购物券', price: '¥85.00', faceValue: '¥100', discount: '8.5折' }, - { brand: 'Walmart', name: 'Walmart ¥50 购物券', price: '¥42.50', faceValue: '¥50', discount: '8.5折' }, - { brand: 'Target', name: 'Target ¥30 折扣券', price: '¥24.00', faceValue: '¥30', discount: '8折' }, - { brand: 'Nike', name: 'Nike ¥80 运动券', price: '¥68.00', faceValue: '¥80', discount: '8.5折' }, -]; - const hotTags = ['Starbucks', 'Amazon', t('category_food'), t('coupon_sort_discount'), t('category_travel'), 'Nike']; -const historyItems = ['星巴克 礼品卡', 'Nike 运动券', '餐饮 折扣']; - const SearchPage: React.FC = () => { - const [searchText, setSearchText] = React.useState(''); - const showResults = searchText.length > 0; + const [searchText, setSearchText] = useState(''); + const [historyItems, setHistoryItems] = useState(() => { + try { + const saved = Taro.getStorageSync('search_history'); + return saved ? JSON.parse(saved) : []; + } catch { + return []; + } + }); + const [results, setResults] = useState([]); + const [resultTotal, setResultTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [hasSearched, setHasSearched] = useState(false); + const timerRef = useRef | null>(null); - const handleResultTap = () => { - Taro.navigateTo({ url: '/pages/detail/index' }); + const doSearch = useCallback((keyword: string) => { + if (!keyword.trim()) { + setResults([]); + setResultTotal(0); + setHasSearched(false); + return; + } + setLoading(true); + setHasSearched(true); + searchCoupons({ q: keyword.trim() }) + .then((data) => { + setResults(data.items); + setResultTotal(data.total); + }) + .catch(() => { + setResults([]); + setResultTotal(0); + }) + .finally(() => setLoading(false)); + }, []); + + const handleInput = (e: any) => { + const val = e.detail.value; + setSearchText(val); + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + doSearch(val); + }, 500); }; + const handleTagOrHistory = (text: string) => { + setSearchText(text); + addToHistory(text); + doSearch(text); + }; + + const addToHistory = (text: string) => { + const updated = [text, ...historyItems.filter((h) => h !== text)].slice(0, 10); + setHistoryItems(updated); + try { + Taro.setStorageSync('search_history', JSON.stringify(updated)); + } catch {} + }; + + const clearHistory = () => { + setHistoryItems([]); + try { + Taro.removeStorageSync('search_history'); + } catch {} + }; + + const handleResultTap = (coupon: CouponItem) => { + addToHistory(searchText); + Taro.navigateTo({ url: `/pages/detail/index?id=${coupon.id}` }); + }; + + const formatDiscount = (coupon: CouponItem) => { + const ratio = coupon.currentPrice / coupon.faceValue; + return `${(ratio * 10).toFixed(1)}折`; + }; + + const showResults = hasSearched || searchText.length > 0; + return ( {/* Header: Search Input + Cancel */} @@ -41,11 +104,12 @@ const SearchPage: React.FC = () => { className="search-input" placeholder={t('search_placeholder')} value={searchText} - onInput={(e: any) => setSearchText(e.detail.value)} + onInput={handleInput} + onConfirm={() => { addToHistory(searchText); doSearch(searchText); }} focus /> {searchText.length > 0 && ( - setSearchText('')}>✕ + { setSearchText(''); setResults([]); setResultTotal(0); setHasSearched(false); }}>✕ )} Taro.navigateBack()}>{t('cancel')} @@ -58,7 +122,7 @@ const SearchPage: React.FC = () => { {t('search_hot_keywords')} {hotTags.map((tag, i) => ( - setSearchText(tag)}> + handleTagOrHistory(tag)}> {tag} ))} @@ -66,37 +130,53 @@ const SearchPage: React.FC = () => { {/* Search History */} - - - {t('search_history')} - {t('search_clear_history')} - - {historyItems.map((item, i) => ( - setSearchText(item)}> - 🕐 - {item} + {historyItems.length > 0 && ( + + + {t('search_history')} + {t('search_clear_history')} - ))} - + {historyItems.map((item, i) => ( + handleTagOrHistory(item)}> + 🕐 + {item} + + ))} + + )} ) : ( - - {t('search_result_count').replace('{count}', String(mockResults.length))} - - - {mockResults.map((coupon, i) => ( - - ))} - + {loading ? ( + + {t('loading') || '搜索中...'} + + ) : ( + <> + + {t('search_result_count').replace('{count}', String(resultTotal))} + + + {results.length === 0 ? ( + + {t('search_no_results') || '暂无搜索结果'} + + ) : ( + results.map((coupon) => ( + handleResultTap(coupon)} + /> + )) + )} + + + )} )} diff --git a/frontend/miniapp/src/pages/settings/index.tsx b/frontend/miniapp/src/pages/settings/index.tsx index 922bfbc..8b341e9 100644 --- a/frontend/miniapp/src/pages/settings/index.tsx +++ b/frontend/miniapp/src/pages/settings/index.tsx @@ -1,6 +1,8 @@ -import React from 'react'; +import React, { useState } from 'react'; import Taro from '@tarojs/taro'; import { t } from '@/i18n'; +import { updateSettings } from '../../services/user'; +import { authStore } from '../../store/auth'; // Taro mini-program component /** @@ -25,13 +27,13 @@ const currencies = [ ]; const SettingsPage: React.FC = () => { - const [notifyTrade, setNotifyTrade] = React.useState(true); - const [notifyExpiry, setNotifyExpiry] = React.useState(true); - const [notifyMarketing, setNotifyMarketing] = React.useState(false); - const [showLangPicker, setShowLangPicker] = React.useState(false); - const [showCurrencyPicker, setShowCurrencyPicker] = React.useState(false); - const [selectedLang, setSelectedLang] = React.useState('zh-CN'); - const [selectedCurrency, setSelectedCurrency] = React.useState('USD'); + const [notifyTrade, setNotifyTrade] = useState(true); + const [notifyExpiry, setNotifyExpiry] = useState(true); + const [notifyMarketing, setNotifyMarketing] = useState(false); + const [showLangPicker, setShowLangPicker] = useState(false); + const [showCurrencyPicker, setShowCurrencyPicker] = useState(false); + const [selectedLang, setSelectedLang] = useState('zh-CN'); + const [selectedCurrency, setSelectedCurrency] = useState('USD'); const currentLangLabel = languages.find((l) => l.value === selectedLang)?.label || '简体中文'; const currentCurrencyLabel = currencies.find((c) => c.code === selectedCurrency)?.label || 'USD ($)'; @@ -40,10 +42,51 @@ const SettingsPage: React.FC = () => { Taro.showToast({ title: t('settings_cache_cleared'), icon: 'success' }); }; + const handleToggleNotification = (key: 'trade' | 'expiry' | 'marketing', value: boolean) => { + const setters = { trade: setNotifyTrade, expiry: setNotifyExpiry, marketing: setNotifyMarketing }; + setters[key](value); + updateSettings({ + notificationPrefs: { + trade: key === 'trade' ? value : notifyTrade, + expiry: key === 'expiry' ? value : notifyExpiry, + marketing: key === 'marketing' ? value : notifyMarketing, + }, + }).catch(() => { + // Revert on failure + setters[key](!value); + }); + }; + + const handleLanguageChange = (langValue: string) => { + setSelectedLang(langValue); + setShowLangPicker(false); + updateSettings({ language: langValue }).catch(() => {}); + }; + + const handleCurrencyChange = (currencyCode: string) => { + setSelectedCurrency(currencyCode); + setShowCurrencyPicker(false); + updateSettings({ currency: currencyCode }).catch(() => {}); + }; + + const handleLogout = () => { + Taro.showModal({ + title: t('settings_logout'), + content: t('settings_logout') + '?', + confirmText: t('confirm'), + cancelText: t('cancel'), + success: (res) => { + if (res.confirm) { + authStore.logout(); + } + }, + }); + }; + const toggleItems = [ - { label: t('settings_trade_notify'), value: notifyTrade, setter: setNotifyTrade }, - { label: t('settings_expiry_remind'), value: notifyExpiry, setter: setNotifyExpiry }, - { label: t('settings_marketing_push'), value: notifyMarketing, setter: setNotifyMarketing }, + { label: t('settings_trade_notify'), value: notifyTrade, key: 'trade' as const }, + { label: t('settings_expiry_remind'), value: notifyExpiry, key: 'expiry' as const }, + { label: t('settings_marketing_push'), value: notifyMarketing, key: 'marketing' as const }, ]; return ( @@ -56,7 +99,7 @@ const SettingsPage: React.FC = () => { {item.label} item.setter(!item.value)} + onClick={() => handleToggleNotification(item.key, !item.value)} > @@ -116,7 +159,7 @@ const SettingsPage: React.FC = () => { {/* Logout Button */} - + {t('settings_logout')} @@ -132,7 +175,7 @@ const SettingsPage: React.FC = () => { { setSelectedLang(lang.value); setShowLangPicker(false); }} + onClick={() => handleLanguageChange(lang.value)} > {lang.label} {selectedLang === lang.value && } @@ -155,7 +198,7 @@ const SettingsPage: React.FC = () => { { setSelectedCurrency(cur.code); setShowCurrencyPicker(false); }} + onClick={() => handleCurrencyChange(cur.code)} > {cur.label} {selectedCurrency === cur.code && } diff --git a/frontend/miniapp/src/pages/transfer/index.tsx b/frontend/miniapp/src/pages/transfer/index.tsx index 3c5b892..d769b63 100644 --- a/frontend/miniapp/src/pages/transfer/index.tsx +++ b/frontend/miniapp/src/pages/transfer/index.tsx @@ -1,6 +1,9 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { t } from '@/i18n'; import Taro from '@tarojs/taro'; +import { transferCoupon } from '../../services/trading'; +import { getMyHoldings } from '../../services/my-coupon'; +import type { CouponItem } from '../../services/coupon'; // Taro mini-program component (WeChat / Alipay) /** @@ -22,18 +25,26 @@ const transferHistory = [ { name: 'Walmart ¥50', to: 'Diana', direction: 'out', time: '15天前' }, ]; -const selectableCoupons = [ - { brand: 'Starbucks', name: '星巴克 ¥25 礼品卡', price: '¥21.25' }, - { brand: 'Amazon', name: 'Amazon ¥100 购物券', price: '¥85.00' }, - { brand: 'Target', name: 'Target ¥30 折扣券', price: '¥24.00' }, -]; - const TransferPage: React.FC = () => { - const [showInputModal, setShowInputModal] = React.useState(false); - const [showSelectModal, setShowSelectModal] = React.useState(false); - const [showConfirmModal, setShowConfirmModal] = React.useState(false); - const [recipientInput, setRecipientInput] = React.useState(''); - const [selectedCouponIndex, setSelectedCouponIndex] = React.useState(0); + const [showInputModal, setShowInputModal] = useState(false); + const [showSelectModal, setShowSelectModal] = useState(false); + const [showConfirmModal, setShowConfirmModal] = useState(false); + const [recipientInput, setRecipientInput] = useState(''); + const [selectedCouponIndex, setSelectedCouponIndex] = useState(0); + const [selectableCoupons, setSelectableCoupons] = useState([]); + const [submitting, setSubmitting] = useState(false); + + // Pre-select coupon from route params if available + const routeParams = Taro.getCurrentInstance().router?.params || {}; + const preselectedCouponId = routeParams.couponId; + const preselectedCouponName = routeParams.couponName ? decodeURIComponent(routeParams.couponName) : ''; + + useEffect(() => { + // Load user's transferable coupons + getMyHoldings({ status: 'active' }) + .then((data) => setSelectableCoupons(data.items.filter((c) => c.isTransferable))) + .catch(() => {}); + }, []); const handleShareCard = () => { // WeChat native share via Taro @@ -55,7 +66,13 @@ const TransferPage: React.FC = () => { const handleInputConfirm = () => { if (!recipientInput.trim()) return; setShowInputModal(false); - setShowSelectModal(true); + + // If we have a preselected coupon, skip coupon selection + if (preselectedCouponId) { + setShowConfirmModal(true); + } else { + setShowSelectModal(true); + } }; const handleCouponSelect = (index: number) => { @@ -65,14 +82,32 @@ const TransferPage: React.FC = () => { }; const handleConfirmTransfer = () => { - setShowConfirmModal(false); - Taro.showToast({ title: t('transfer_success'), icon: 'success' }); + const couponId = preselectedCouponId || (selectableCoupons[selectedCouponIndex]?.id); + if (!couponId || submitting) return; + + setSubmitting(true); + transferCoupon(couponId, { phone: recipientInput.trim() }) + .then(() => { + setShowConfirmModal(false); + Taro.showToast({ title: t('transfer_success'), icon: 'success' }); + }) + .catch(() => {}) + .finally(() => setSubmitting(false)); }; const handleRecipientTap = (recipient: typeof recentRecipients[0]) => { if (recipient.expired) return; setRecipientInput(recipient.contact); - setShowSelectModal(true); + if (preselectedCouponId) { + setShowConfirmModal(true); + } else { + setShowSelectModal(true); + } + }; + + const getSelectedCouponName = () => { + if (preselectedCouponId && preselectedCouponName) return preselectedCouponName; + return selectableCoupons[selectedCouponIndex]?.name || '--'; }; return ( @@ -184,18 +219,24 @@ const TransferPage: React.FC = () => { {t('transfer_select_coupon')} setShowSelectModal(false)}>✕ - {selectableCoupons.map((coupon, i) => ( - handleCouponSelect(i)}> - - 🎫 - - - {coupon.brand} - {coupon.name} - - {coupon.price} + {selectableCoupons.length === 0 ? ( + + {t('my_coupons_empty') || '暂无可转赠的券'} - ))} + ) : ( + selectableCoupons.map((coupon, i) => ( + handleCouponSelect(i)}> + + 🎫 + + + {coupon.brandName || coupon.category} + {coupon.name} + + ¥{coupon.currentPrice.toFixed(2)} + + )) + )} )} @@ -212,7 +253,7 @@ const TransferPage: React.FC = () => { {t('transfer_select_coupon')} - {selectableCoupons[selectedCouponIndex].name} + {getSelectedCouponName()} {t('transfer_to')} @@ -230,7 +271,7 @@ const TransferPage: React.FC = () => { {t('cancel')} - {t('transfer_confirm_btn')} + {submitting ? (t('loading') || '处理中...') : t('transfer_confirm_btn')} diff --git a/frontend/miniapp/src/pages/wallet/index.tsx b/frontend/miniapp/src/pages/wallet/index.tsx index aa39af4..49e7f4e 100644 --- a/frontend/miniapp/src/pages/wallet/index.tsx +++ b/frontend/miniapp/src/pages/wallet/index.tsx @@ -1,5 +1,7 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { t } from '@/i18n'; +import { getBalance, getTransactions } from '../../services/wallet'; +import type { WalletBalance, TransactionRecord } from '../../services/wallet'; // Taro mini-program component /** @@ -8,41 +10,74 @@ import { t } from '@/i18n'; * 余额展示 + 交易记录,无充值/提现操作 */ -interface Transaction { - id: number; - icon: string; - title: string; - amount: string; - time: string; - isPositive: boolean; - color: string; -} +const txIconMap: Record = { + buy: '\uD83D\uDED2', + sell: '\uD83D\uDCB0', + deposit: '\u2795', + withdraw: '\uD83C\uDFE6', + transfer: '\uD83C\uDF81', + redeem: '\u2705', +}; -const mockTransactions: Transaction[] = [ - { id: 1, icon: '\uD83D\uDED2', title: '买入 Starbucks ¥25', amount: '-¥21.25', time: '14:32 today', isPositive: false, color: '#6C5CE7' }, - { id: 2, icon: '\uD83D\uDCB0', title: '卖出 Amazon ¥50', amount: '+¥42.50', time: '10:15 today', isPositive: true, color: '#00C48C' }, - { id: 3, icon: '\u2795', title: '充值', amount: '+¥500.00', time: '09:20 today', isPositive: true, color: '#3498DB' }, - { id: 4, icon: '\uD83C\uDF81', title: '转赠 Target', amount: '-¥30.00', time: '02/07', isPositive: false, color: '#FF9500' }, - { id: 5, icon: '\u2705', title: '核销 Nike', amount: '¥0', time: '02/06', isPositive: false, color: '#A0A8BE' }, - { id: 6, icon: '\uD83C\uDFE6', title: '提现', amount: '-¥200.00', time: '02/05', isPositive: false, color: '#E74C3C' }, -]; +const txColorMap: Record = { + buy: '#6C5CE7', + sell: '#00C48C', + deposit: '#3498DB', + withdraw: '#E74C3C', + transfer: '#FF9500', + redeem: '#A0A8BE', +}; const WalletPage: React.FC = () => { + const [balance, setBalance] = useState(null); + const [transactions, setTransactions] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + Promise.all([ + getBalance().catch(() => null), + getTransactions().catch(() => ({ items: [], total: 0 })), + ]).then(([balanceData, txData]) => { + if (balanceData) setBalance(balanceData); + setTransactions(txData?.items || []); + }).finally(() => setLoading(false)); + }, []); + + const formatTime = (dateStr: string) => { + if (!dateStr) return ''; + try { + const date = new Date(dateStr); + const now = new Date(); + if (date.toDateString() === now.toDateString()) { + return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) + ' today'; + } + return `${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}`; + } catch { + return dateStr; + } + }; + return ( {/* Balance Card */} {t('wallet_total_balance')} - $1,234.56 + + {loading ? '...' : `$${(balance?.total ?? 0).toLocaleString('en-US', { minimumFractionDigits: 2 })}`} + {t('wallet_available')} - $1,034.56 + + {loading ? '...' : `$${(balance?.available ?? 0).toLocaleString('en-US', { minimumFractionDigits: 2 })}`} + {t('wallet_frozen')} - $200.00 + + {loading ? '...' : `$${(balance?.frozen ?? 0).toLocaleString('en-US', { minimumFractionDigits: 2 })}`} + @@ -62,26 +97,42 @@ const WalletPage: React.FC = () => { - {mockTransactions.map((tx, i) => ( - - - - {tx.icon} - - - {tx.title} - {tx.time} - - - {tx.amount} - - - {i < mockTransactions.length - 1 && } + {loading ? ( + + {t('loading') || '加载中...'} - ))} + ) : transactions.length === 0 ? ( + + {t('empty') || '暂无记录'} + + ) : ( + transactions.map((tx, i) => { + const isPositive = tx.amount > 0; + const color = txColorMap[tx.type] || '#A0A8BE'; + const icon = txIconMap[tx.type] || '\uD83D\uDD04'; + + return ( + + + + {icon} + + + {tx.description} + {formatTime(tx.createdAt)} + + + {isPositive ? '+' : ''}¥{Math.abs(tx.amount).toFixed(2)} + + + {i < transactions.length - 1 && } + + ); + }) + )} diff --git a/frontend/miniapp/src/services/ai.ts b/frontend/miniapp/src/services/ai.ts new file mode 100644 index 0000000..a80ea33 --- /dev/null +++ b/frontend/miniapp/src/services/ai.ts @@ -0,0 +1,19 @@ +import { post } from '../utils/request'; + +export interface AiChatResponse { + message: string; + intent?: string; + recommendations?: Array<{ couponId: string; name: string; reason: string }>; +} + +/** 发送消息到AI助手 */ +export function chat(message: string, context?: string) { + return post('/api/v1/ai/chat', { message, context }); +} + +/** 获取AI推荐券 */ +export function getRecommendations() { + return post>( + '/api/v1/ai/recommendations', + ); +} diff --git a/frontend/miniapp/src/services/auth.ts b/frontend/miniapp/src/services/auth.ts new file mode 100644 index 0000000..b1cd1af --- /dev/null +++ b/frontend/miniapp/src/services/auth.ts @@ -0,0 +1,32 @@ +import { post } from '../utils/request'; + +interface LoginResult { + accessToken: string; + refreshToken: string; + user: { id: string; phone?: string; nickname?: string; kycLevel: number }; +} + +/** 手机号+验证码登录 */ +export function loginByPhone(phone: string, smsCode: string) { + return post('/api/v1/auth/login-phone', { phone, smsCode }, false); +} + +/** 发送短信验证码 */ +export function sendSmsCode(phone: string) { + return post<{ success: boolean }>('/api/v1/auth/send-sms-code', { phone }, false); +} + +/** 微信登录 */ +export function loginByWechat(code: string) { + return post('/api/v1/auth/login-wechat', { code }, false); +} + +/** 注册 */ +export function register(phone: string, smsCode: string) { + return post('/api/v1/auth/register', { phone, smsCode }, false); +} + +/** 登出 */ +export function logout() { + return post('/api/v1/auth/logout'); +} diff --git a/frontend/miniapp/src/services/coupon.ts b/frontend/miniapp/src/services/coupon.ts new file mode 100644 index 0000000..4da750e --- /dev/null +++ b/frontend/miniapp/src/services/coupon.ts @@ -0,0 +1,78 @@ +import { get, post } from '../utils/request'; + +export interface CouponItem { + id: string; + name: string; + description?: string; + imageUrl?: string; + faceValue: number; + currentPrice: number; + category: string; + status: string; + couponType: string; + expiryDate: string; + totalSupply: number; + remainingSupply: number; + isTransferable: boolean; + brandName?: string; + brandLogoUrl?: string; + creditRating?: string; +} + +export interface PaginatedResult { + items: T[]; + total: number; +} + +/** 获取精选券列表(首页用) */ +export function getFeaturedCoupons(page = 1, limit = 10) { + return get>('/api/v1/coupons', { + page, + limit, + status: 'listed', + }); +} + +/** 搜索券 */ +export function searchCoupons(params: { + q?: string; + category?: string; + sort?: string; + page?: number; + limit?: number; +}) { + return get>('/api/v1/coupons', { + search: params.q, + category: params.category, + sort: params.sort, + page: params.page ?? 1, + limit: params.limit ?? 20, + }); +} + +/** 获取券详情 */ +export function getCouponDetail(id: string) { + return get(`/api/v1/coupons/${id}`); +} + +/** 获取附近门店 */ +export function getNearbyStores(couponId: string) { + return get>( + `/api/v1/coupons/${couponId}/nearby-stores`, + ); +} + +/** 获取相似券 */ +export function getSimilarCoupons(couponId: string) { + return get(`/api/v1/coupons/${couponId}/similar`); +} + +/** 获取分类列表 */ +export function getCategories() { + return get>('/api/v1/coupons/categories', undefined, false); +} + +/** 购买券 */ +export function purchaseCoupon(couponId: string, quantity = 1) { + return post<{ orderId: string }>(`/api/v1/coupons/${couponId}/purchase`, { quantity }); +} diff --git a/frontend/miniapp/src/services/my-coupon.ts b/frontend/miniapp/src/services/my-coupon.ts new file mode 100644 index 0000000..e08c4d0 --- /dev/null +++ b/frontend/miniapp/src/services/my-coupon.ts @@ -0,0 +1,27 @@ +import { get, post } from '../utils/request'; +import type { CouponItem, PaginatedResult } from './coupon'; + +export interface HoldingsSummary { + count: number; + totalFaceValue: number; + totalSaved: number; +} + +/** 获取我的持仓 */ +export function getMyHoldings(params?: { status?: string; page?: number; limit?: number }) { + return get>('/api/v1/coupons/my', { + status: params?.status, + page: params?.page ?? 1, + limit: params?.limit ?? 20, + }); +} + +/** 获取持仓汇总 */ +export function getHoldingsSummary() { + return get('/api/v1/coupons/my/summary'); +} + +/** 核销券(出示二维码后由商户扫码,此处为用户侧调用) */ +export function redeemCoupon(couponId: string) { + return post<{ redemptionId: string }>('/api/v1/redemptions/scan', { couponId }); +} diff --git a/frontend/miniapp/src/services/notification.ts b/frontend/miniapp/src/services/notification.ts new file mode 100644 index 0000000..0a050dc --- /dev/null +++ b/frontend/miniapp/src/services/notification.ts @@ -0,0 +1,39 @@ +import { get, put } from '../utils/request'; + +export interface MessageItem { + id: string; + title: string; + content: string; + type: string; // 'transaction' | 'expiry' | 'price' | 'announcement' + isRead: boolean; + createdAt: string; +} + +export interface PaginatedMessages { + items: MessageItem[]; + total: number; +} + +/** 获取消息列表 */ +export function getMessages(params?: { type?: string; page?: number; limit?: number }) { + return get('/api/v1/notifications', { + type: params?.type, + page: params?.page ?? 1, + limit: params?.limit ?? 50, + }); +} + +/** 获取未读数 */ +export function getUnreadCount() { + return get<{ count: number }>('/api/v1/notifications/unread-count'); +} + +/** 标记已读 */ +export function markAsRead(notificationId: string) { + return put(`/api/v1/notifications/${notificationId}/read`); +} + +/** 全部标记已读 */ +export function markAllRead() { + return put('/api/v1/notifications/read-all'); +} diff --git a/frontend/miniapp/src/services/trading.ts b/frontend/miniapp/src/services/trading.ts new file mode 100644 index 0000000..3f65407 --- /dev/null +++ b/frontend/miniapp/src/services/trading.ts @@ -0,0 +1,48 @@ +import { get, post } from '../utils/request'; + +export interface OrderItem { + id: string; + couponId: string; + couponName: string; + brandName?: string; + type: string; // 'buy' | 'sell' + quantity: number; + price: number; + totalAmount: number; + status: string; // 'pending_payment' | 'pending_delivery' | 'completed' | 'cancelled' + createdAt: string; +} + +export interface PaginatedOrders { + items: OrderItem[]; + total: number; +} + +/** 获取我的订单 */ +export function getMyOrders(params?: { status?: string; page?: number; limit?: number }) { + return get('/api/v1/trades/my/orders', { + status: params?.status, + page: params?.page ?? 1, + limit: params?.limit ?? 20, + }); +} + +/** 获取订单详情 */ +export function getOrderDetail(orderId: string) { + return get(`/api/v1/trades/my/orders/${orderId}`); +} + +/** 转赠券 */ +export function transferCoupon(couponId: string, recipient: { phone?: string; userId?: string }) { + return post<{ transferId: string }>(`/api/v1/trades/coupons/${couponId}/transfer`, recipient); +} + +/** 挂牌出售 */ +export function listForSale(couponId: string, price: number) { + return post<{ listingId: string }>(`/api/v1/trades/coupons/${couponId}/list-for-sale`, { price }); +} + +/** 取消挂牌 */ +export function cancelSale(couponId: string) { + return post(`/api/v1/trades/coupons/${couponId}/cancel-sale`); +} diff --git a/frontend/miniapp/src/services/user.ts b/frontend/miniapp/src/services/user.ts new file mode 100644 index 0000000..85bec64 --- /dev/null +++ b/frontend/miniapp/src/services/user.ts @@ -0,0 +1,51 @@ +import { get, put, post } from '../utils/request'; + +export interface UserProfile { + id: string; + phone?: string; + email?: string; + nickname?: string; + kycLevel: number; + avatarUrl?: string; +} + +export interface UserStats { + ownedCount: number; + usedCount: number; + expiredCount: number; +} + +export interface KycStatus { + level: number; + status: string; // 'pending' | 'approved' | 'rejected' + dailyLimit: number; +} + +/** 获取用户资料 */ +export function getProfile() { + return get('/api/v1/users/me'); +} + +/** 获取用户统计 */ +export function getUserStats() { + return get('/api/v1/users/me/stats'); +} + +/** 获取KYC状态 */ +export function getKycStatus() { + return get('/api/v1/users/kyc'); +} + +/** 提交KYC验证 */ +export function submitKyc(data: { level: number; documents: Record }) { + return post<{ submissionId: string }>('/api/v1/users/kyc', data); +} + +/** 更新用户设置 */ +export function updateSettings(settings: { + language?: string; + currency?: string; + notificationPrefs?: { trade: boolean; expiry: boolean; marketing: boolean }; +}) { + return put('/api/v1/users/me/settings', settings); +} diff --git a/frontend/miniapp/src/services/wallet.ts b/frontend/miniapp/src/services/wallet.ts new file mode 100644 index 0000000..b6e83ad --- /dev/null +++ b/frontend/miniapp/src/services/wallet.ts @@ -0,0 +1,35 @@ +import { get } from '../utils/request'; + +export interface WalletBalance { + total: number; + available: number; + frozen: number; +} + +export interface TransactionRecord { + id: string; + type: string; // 'buy' | 'sell' | 'deposit' | 'withdraw' | 'transfer' | 'redeem' + amount: number; + description: string; + status: string; + createdAt: string; +} + +export interface PaginatedTransactions { + items: TransactionRecord[]; + total: number; +} + +/** 获取钱包余额 */ +export function getBalance() { + return get('/api/v1/wallet'); +} + +/** 获取交易记录 */ +export function getTransactions(params?: { type?: string; page?: number; limit?: number }) { + return get('/api/v1/wallet/transactions', { + type: params?.type, + page: params?.page ?? 1, + limit: params?.limit ?? 20, + }); +} diff --git a/frontend/miniapp/src/store/auth.ts b/frontend/miniapp/src/store/auth.ts new file mode 100644 index 0000000..3303f9e --- /dev/null +++ b/frontend/miniapp/src/store/auth.ts @@ -0,0 +1,97 @@ +import Taro from '@tarojs/taro'; +import { config } from '../config'; +import { post, get } from '../utils/request'; + +export interface UserProfile { + id: string; + phone?: string; + email?: string; + nickname?: string; + kycLevel: number; + avatarUrl?: string; +} + +interface LoginResult { + accessToken: string; + refreshToken: string; + user: UserProfile; +} + +/** + * Auth 状态管理(简单模块模式,不依赖额外状态库) + */ +let _user: UserProfile | null = null; +let _listeners: Array<() => void> = []; + +function notify() { + _listeners.forEach((fn) => fn()); +} + +export const authStore = { + /** 获取当前用户 */ + getUser(): UserProfile | null { + if (!_user) { + try { + const saved = Taro.getStorageSync(config.USER_KEY); + if (saved) _user = JSON.parse(saved); + } catch {} + } + return _user; + }, + + /** 是否已登录 */ + isLoggedIn(): boolean { + return !!Taro.getStorageSync(config.TOKEN_KEY); + }, + + /** 手机号+验证码登录 */ + async loginByPhone(phone: string, smsCode: string): Promise { + const result = await post('/api/v1/auth/login-phone', { phone, smsCode }, false); + Taro.setStorageSync(config.TOKEN_KEY, result.accessToken); + Taro.setStorageSync(config.USER_KEY, JSON.stringify(result.user)); + _user = result.user; + notify(); + }, + + /** 发送短信验证码 */ + async sendSmsCode(phone: string): Promise { + await post('/api/v1/auth/send-sms-code', { phone }, false); + }, + + /** 微信一键登录 */ + async loginByWechat(): Promise { + const { code } = await Taro.login(); + const result = await post('/api/v1/auth/login-wechat', { code }, false); + Taro.setStorageSync(config.TOKEN_KEY, result.accessToken); + Taro.setStorageSync(config.USER_KEY, JSON.stringify(result.user)); + _user = result.user; + notify(); + }, + + /** 获取用户资料 */ + async fetchProfile(): Promise { + const profile = await get('/api/v1/users/me'); + _user = profile; + Taro.setStorageSync(config.USER_KEY, JSON.stringify(profile)); + notify(); + return profile; + }, + + /** 登出 */ + logout(): void { + post('/api/v1/auth/logout').catch(() => {}); + Taro.removeStorageSync(config.TOKEN_KEY); + Taro.removeStorageSync(config.USER_KEY); + _user = null; + notify(); + Taro.reLaunch({ url: '/pages/login/index' }); + }, + + /** 订阅状态变化 */ + subscribe(listener: () => void): () => void { + _listeners.push(listener); + return () => { + _listeners = _listeners.filter((fn) => fn !== listener); + }; + }, +}; diff --git a/frontend/miniapp/src/utils/request.ts b/frontend/miniapp/src/utils/request.ts new file mode 100644 index 0000000..57d86ec --- /dev/null +++ b/frontend/miniapp/src/utils/request.ts @@ -0,0 +1,107 @@ +import Taro from '@tarojs/taro'; +import { config } from '../config'; + +interface RequestOptions { + /** 请求路径(不含 baseURL) */ + url: string; + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + data?: Record; + params?: Record; + /** 是否需要登录 */ + auth?: boolean; +} + +interface ApiResponse { + code?: number; + data: T; + message?: string; +} + +/** + * 构建查询字符串 + */ +function buildUrl(url: string, params?: Record): string { + if (!params) return url; + const query = Object.entries(params) + .filter(([, v]) => v !== undefined && v !== null && v !== '') + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`) + .join('&'); + return query ? `${url}?${query}` : url; +} + +/** + * 通用请求封装 — 基于 Taro.request + */ +async function request(options: RequestOptions): Promise { + const { url, method = 'GET', data, params, auth = true } = options; + + const header: Record = { + 'Content-Type': 'application/json', + }; + + if (auth) { + const token = Taro.getStorageSync(config.TOKEN_KEY); + if (token) { + header['Authorization'] = `Bearer ${token}`; + } + } + + const fullUrl = buildUrl(`${config.API_BASE_URL}${url}`, params); + + try { + const response = await Taro.request({ + url: fullUrl, + method, + data, + header, + timeout: config.REQUEST_TIMEOUT, + }); + + const statusCode = response.statusCode; + + if (statusCode === 401) { + Taro.removeStorageSync(config.TOKEN_KEY); + Taro.removeStorageSync(config.USER_KEY); + Taro.reLaunch({ url: '/pages/login/index' }); + throw new Error('未登录或登录已过期'); + } + + if (statusCode < 200 || statusCode >= 300) { + const msg = (response.data as ApiResponse)?.message || `请求失败 (${statusCode})`; + Taro.showToast({ title: msg, icon: 'none', duration: 2000 }); + throw new Error(msg); + } + + const body = response.data as ApiResponse; + return (body.data !== undefined ? body.data : body) as T; + } catch (error: unknown) { + if (error instanceof Error && error.message === '未登录或登录已过期') { + throw error; + } + const msg = error instanceof Error ? error.message : '网络异常,请稍后重试'; + Taro.showToast({ title: msg, icon: 'none', duration: 2000 }); + throw error; + } +} + +/** GET 请求 */ +export function get(url: string, params?: Record, auth = true) { + return request({ url, method: 'GET', params, auth }); +} + +/** POST 请求 */ +export function post(url: string, data?: Record, auth = true) { + return request({ url, method: 'POST', data, auth }); +} + +/** PUT 请求 */ +export function put(url: string, data?: Record, auth = true) { + return request({ url, method: 'PUT', data, auth }); +} + +/** DELETE 请求 */ +export function del(url: string, data?: Record, auth = true) { + return request({ url, method: 'DELETE', data, auth }); +} + +export default request;