feat(pre-planting): 重命名预种持仓→预种明细 + 购买协议弹窗
- mobile-app: "预种持仓"按钮和页面标题改为"预种明细" - admin-service: 新增预种协议文本 API (GET/PUT agreement),存储于 system_configs 表 - admin-service: 公开 config API 响应增加 agreementText 字段 - planting-service: 新建 PrePlantingPublicController (无需 JWT),暴露 GET /pre-planting/config - admin-web: 预种管理页面新增协议文本编辑器(textarea + 保存按钮) - mobile-app: 购买流程增加协议弹窗,用户需勾选同意后才能继续 - mobile-app: 协议文本优先使用后台配置,未配置时使用默认文本 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
92054e776e
commit
c0ac63d40a
|
|
@ -27,6 +27,11 @@ class TogglePrePlantingConfigDto {
|
|||
isActive: boolean;
|
||||
}
|
||||
|
||||
class UpdatePrePlantingAgreementDto {
|
||||
@IsString()
|
||||
text: string;
|
||||
}
|
||||
|
||||
@ApiTags('预种计划配置')
|
||||
@Controller('admin/pre-planting')
|
||||
export class PrePlantingConfigController {
|
||||
|
|
@ -124,6 +129,26 @@ export class PrePlantingConfigController {
|
|||
async getStats() {
|
||||
return this.proxyService.getStats();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// [2026-02-28] 新增:预种协议管理
|
||||
// ============================================
|
||||
|
||||
@Get('agreement')
|
||||
@ApiOperation({ summary: '获取预种协议文本' })
|
||||
@ApiResponse({ status: HttpStatus.OK, description: '协议文本' })
|
||||
async getAgreement() {
|
||||
const text = await this.configService.getAgreement();
|
||||
return { text };
|
||||
}
|
||||
|
||||
@Put('agreement')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '更新预种协议文本' })
|
||||
@ApiResponse({ status: HttpStatus.OK, description: '更新成功' })
|
||||
async updateAgreement(@Body() dto: UpdatePrePlantingAgreementDto) {
|
||||
return this.configService.updateAgreement(dto.text);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -137,8 +162,10 @@ export class PublicPrePlantingConfigController {
|
|||
) {}
|
||||
|
||||
@Get('config')
|
||||
@ApiOperation({ summary: '获取预种计划开关状态(内部API)' })
|
||||
@ApiOperation({ summary: '获取预种计划开关状态(内部API,含协议文本)' })
|
||||
async getConfig() {
|
||||
return this.configService.getConfig();
|
||||
const config = await this.configService.getConfig();
|
||||
const agreementText = await this.configService.getAgreement();
|
||||
return { ...config, agreementText };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,38 @@ export class PrePlantingConfigService {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预种协议文本(从 system_configs 表读取)
|
||||
*/
|
||||
async getAgreement(): Promise<string | null> {
|
||||
const config = await this.prisma.systemConfig.findUnique({
|
||||
where: { key: 'pre_planting_agreement' },
|
||||
});
|
||||
return config?.value ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新预种协议文本(upsert 到 system_configs 表)
|
||||
*/
|
||||
async updateAgreement(text: string, updatedBy?: string): Promise<{ text: string }> {
|
||||
await this.prisma.systemConfig.upsert({
|
||||
where: { key: 'pre_planting_agreement' },
|
||||
create: {
|
||||
key: 'pre_planting_agreement',
|
||||
value: text,
|
||||
description: '预种计划购买协议文本',
|
||||
updatedBy: updatedBy || null,
|
||||
},
|
||||
update: {
|
||||
value: text,
|
||||
updatedBy: updatedBy || null,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`[PRE-PLANTING] Agreement text updated by ${updatedBy || 'unknown'}`);
|
||||
return { text };
|
||||
}
|
||||
|
||||
async updateConfig(
|
||||
isActive: boolean,
|
||||
updatedBy?: string,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
import { Controller, Get, HttpStatus } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { PrePlantingAdminClient } from '../../infrastructure/external/pre-planting-admin.client';
|
||||
|
||||
/**
|
||||
* [2026-02-28] 预种计划公开 API(无需 JWT)
|
||||
*
|
||||
* 供 mobile-app 在未登录或登录前获取预种计划配置信息(开关状态 + 协议文本)。
|
||||
* 与 PrePlantingController(需 JWT)分离,避免在类级 @UseGuards 中打洞。
|
||||
*/
|
||||
@ApiTags('预种计划-公开')
|
||||
@Controller('pre-planting')
|
||||
export class PrePlantingPublicController {
|
||||
constructor(
|
||||
private readonly adminClient: PrePlantingAdminClient,
|
||||
) {}
|
||||
|
||||
@Get('config')
|
||||
@ApiOperation({ summary: '获取预种计划配置(公开,含协议文本)' })
|
||||
@ApiResponse({ status: HttpStatus.OK, description: '配置信息' })
|
||||
async getConfig() {
|
||||
return this.adminClient.getPrePlantingConfig();
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { firstValueFrom } from 'rxjs';
|
|||
export interface PrePlantingConfig {
|
||||
isActive: boolean;
|
||||
activatedAt: Date | null;
|
||||
agreementText?: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { HttpModule } from '@nestjs/axios';
|
|||
// Controllers
|
||||
import { PrePlantingController } from './api/controllers/pre-planting.controller';
|
||||
import { InternalPrePlantingController } from './api/controllers/internal-pre-planting.controller';
|
||||
import { PrePlantingPublicController } from './api/controllers/pre-planting-public.controller';
|
||||
|
||||
// Application Services
|
||||
import { PrePlantingApplicationService } from './application/services/pre-planting-application.service';
|
||||
|
|
@ -55,6 +56,7 @@ import { PrePlantingAuthorizationClient } from './infrastructure/external/pre-pl
|
|||
],
|
||||
controllers: [
|
||||
PrePlantingController,
|
||||
PrePlantingPublicController,
|
||||
InternalPrePlantingController,
|
||||
],
|
||||
providers: [
|
||||
|
|
|
|||
|
|
@ -20,11 +20,12 @@
|
|||
* 侧边栏入口在"数据统计"和"系统维护"之间。
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Button } from '@/components/common';
|
||||
import { PageContainer } from '@/components/layout';
|
||||
import { cn } from '@/utils/helpers';
|
||||
import { formatDateTime } from '@/utils/formatters';
|
||||
import { prePlantingService } from '@/services/prePlantingService';
|
||||
import {
|
||||
usePrePlantingConfig,
|
||||
usePrePlantingStats,
|
||||
|
|
@ -88,6 +89,36 @@ export default function PrePlantingPage() {
|
|||
keyword: activeTab === 'merges' ? keyword : undefined,
|
||||
});
|
||||
|
||||
// === 协议文本管理 ===
|
||||
const [agreementText, setAgreementText] = useState('');
|
||||
const [agreementSaving, setAgreementSaving] = useState(false);
|
||||
const [agreementMsg, setAgreementMsg] = useState('');
|
||||
|
||||
const loadAgreement = useCallback(async () => {
|
||||
try {
|
||||
const res = await prePlantingService.getAgreement();
|
||||
setAgreementText(res.text ?? '');
|
||||
} catch {
|
||||
// 未配置时使用空文本
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadAgreement(); }, [loadAgreement]);
|
||||
|
||||
const handleSaveAgreement = async () => {
|
||||
setAgreementSaving(true);
|
||||
setAgreementMsg('');
|
||||
try {
|
||||
await prePlantingService.updateAgreement(agreementText);
|
||||
setAgreementMsg('协议已保存');
|
||||
setTimeout(() => setAgreementMsg(''), 3000);
|
||||
} catch {
|
||||
setAgreementMsg('保存失败,请重试');
|
||||
} finally {
|
||||
setAgreementSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// === 开关切换 ===
|
||||
const handleToggle = () => {
|
||||
if (!config || toggleConfig.isPending) return;
|
||||
|
|
@ -163,6 +194,49 @@ export default function PrePlantingPage() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* 预种协议管理 */}
|
||||
<div className={styles.prePlanting__card} style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600, color: '#5D4037' }}>预种协议管理</h3>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{agreementMsg && (
|
||||
<span style={{ fontSize: 13, color: agreementMsg.includes('失败') ? '#d32f2f' : '#2e7d32' }}>
|
||||
{agreementMsg}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleSaveAgreement}
|
||||
disabled={agreementSaving}
|
||||
>
|
||||
{agreementSaving ? '保存中...' : '保存协议'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={agreementText}
|
||||
onChange={(e) => setAgreementText(e.target.value)}
|
||||
placeholder={'预种协议\n\n1. 用户成功购买预种计划后,自动获得分享权。\n2. 每一份预种计划,对应享有1棵猫山王榴莲树40%产果分红权的1/5份额,即单份预种计划可享受该果树8%的产果分红权。'}
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: 120,
|
||||
padding: 12,
|
||||
border: '1px solid #d7ccc8',
|
||||
borderRadius: 8,
|
||||
fontSize: 14,
|
||||
lineHeight: 1.6,
|
||||
fontFamily: 'inherit',
|
||||
resize: 'vertical',
|
||||
backgroundColor: '#fffaf5',
|
||||
color: '#5D4037',
|
||||
}}
|
||||
/>
|
||||
<p style={{ margin: '8px 0 0', fontSize: 12, color: '#8d6e63' }}>
|
||||
留空时,App 将显示默认协议内容。修改后需保存才能生效。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className={styles.prePlanting__statsGrid}>
|
||||
<div className={styles.prePlanting__statCard}>
|
||||
|
|
|
|||
|
|
@ -303,6 +303,8 @@ export const API_ENDPOINTS = {
|
|||
MERGES: '/v1/admin/pre-planting/merges',
|
||||
// 预种统计汇总(总份数、总金额、合并树数等)
|
||||
STATS: '/v1/admin/pre-planting/stats',
|
||||
// [2026-02-28] 预种协议文本(管理员可编辑)
|
||||
AGREEMENT: '/v1/admin/pre-planting/agreement',
|
||||
},
|
||||
// [2026-02-19] 纯新增:树转让管理 (transfer-service)
|
||||
// Saga 编排的树转让订单管理:列表/详情/统计/强制取消
|
||||
|
|
|
|||
|
|
@ -158,6 +158,20 @@ export const prePlantingService = {
|
|||
async getMerges(params: PrePlantingListParams = {}): Promise<PaginatedResponse<PrePlantingAdminMerge>> {
|
||||
return apiClient.get(API_ENDPOINTS.PRE_PLANTING.MERGES, { params });
|
||||
},
|
||||
|
||||
/**
|
||||
* [2026-02-28] 获取预种协议文本
|
||||
*/
|
||||
async getAgreement(): Promise<{ text: string | null }> {
|
||||
return apiClient.get(API_ENDPOINTS.PRE_PLANTING.AGREEMENT);
|
||||
},
|
||||
|
||||
/**
|
||||
* [2026-02-28] 更新预种协议文本
|
||||
*/
|
||||
async updateAgreement(text: string): Promise<{ text: string }> {
|
||||
return apiClient.put(API_ENDPOINTS.PRE_PLANTING.AGREEMENT, { text });
|
||||
},
|
||||
};
|
||||
|
||||
export default prePlantingService;
|
||||
|
|
|
|||
|
|
@ -42,10 +42,12 @@ enum PrePlantingContractStatus {
|
|||
class PrePlantingConfig {
|
||||
final bool isActive; // 预种功能是否开启
|
||||
final DateTime? activatedAt; // 开启时间
|
||||
final String? agreementText; // 预种协议文本(null 时使用默认)
|
||||
|
||||
PrePlantingConfig({
|
||||
required this.isActive,
|
||||
this.activatedAt,
|
||||
this.agreementText,
|
||||
});
|
||||
|
||||
factory PrePlantingConfig.fromJson(Map<String, dynamic> json) {
|
||||
|
|
@ -54,6 +56,7 @@ class PrePlantingConfig {
|
|||
activatedAt: json['activatedAt'] != null
|
||||
? DateTime.parse(json['activatedAt'])
|
||||
: null,
|
||||
agreementText: json['agreementText'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import '../../../../core/services/pre_planting_service.dart';
|
|||
import '../../../../routes/route_paths.dart';
|
||||
|
||||
// ============================================
|
||||
// [2026-02-17] 预种持仓页面
|
||||
// [2026-02-17] 预种明细页面
|
||||
// ============================================
|
||||
//
|
||||
// 显示用户的预种计划持仓信息,包括:
|
||||
|
|
@ -25,9 +25,9 @@ import '../../../../routes/route_paths.dart';
|
|||
// 本页面纯展示,不含购买操作。
|
||||
// 购买入口在 Profile 页面或本页顶部按钮跳转到 PrePlantingPurchasePage。
|
||||
|
||||
/// 预种持仓页面
|
||||
/// 预种明细页面
|
||||
///
|
||||
/// 展示用户的预种持仓概览、合并进度、订单列表和合并记录。
|
||||
/// 展示用户的预种明细概览、合并进度、订单列表和合并记录。
|
||||
class PrePlantingPositionPage extends ConsumerStatefulWidget {
|
||||
const PrePlantingPositionPage({super.key});
|
||||
|
||||
|
|
@ -203,7 +203,7 @@ class _PrePlantingPositionPageState
|
|||
const SizedBox(width: 42),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'预种持仓',
|
||||
'预种明细',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontFamily: 'Inter',
|
||||
|
|
|
|||
|
|
@ -323,11 +323,22 @@ class _PrePlantingPurchasePageState
|
|||
return true;
|
||||
}
|
||||
|
||||
/// 默认预种协议文本
|
||||
static const String _defaultAgreementText =
|
||||
'预种协议\n\n'
|
||||
'1. 用户成功购买预种计划后,自动获得分享权。\n'
|
||||
'2. 每一份预种计划,对应享有1棵猫山王榴莲树40%产果分红权的1/5份额,'
|
||||
'即单份预种计划可享受该果树8%的产果分红权。';
|
||||
|
||||
/// 确认购买
|
||||
Future<void> _confirmPurchase() async {
|
||||
if (!_canPurchase) return;
|
||||
|
||||
// 显示确认弹窗
|
||||
// 第一步:显示协议弹窗
|
||||
final agreed = await _showAgreementDialog();
|
||||
if (agreed != true || !mounted) return;
|
||||
|
||||
// 第二步:显示确认弹窗
|
||||
final confirmed = await _showConfirmDialog();
|
||||
if (confirmed != true || !mounted) return;
|
||||
|
||||
|
|
@ -382,6 +393,19 @@ class _PrePlantingPurchasePageState
|
|||
}
|
||||
}
|
||||
|
||||
/// 显示预种协议弹窗(需勾选同意才能继续)
|
||||
Future<bool?> _showAgreementDialog() {
|
||||
final agreementText = (_config?.agreementText != null && _config!.agreementText!.isNotEmpty)
|
||||
? _config!.agreementText!
|
||||
: _defaultAgreementText;
|
||||
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => _AgreementDialog(agreementText: agreementText),
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示购买确认弹窗
|
||||
Future<bool?> _showConfirmDialog() {
|
||||
final totalAmount = _quantity * _pricePerPortion;
|
||||
|
|
@ -1420,3 +1444,150 @@ class _PrePlantingPurchasePageState
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// [2026-02-28] 预种协议弹窗组件
|
||||
// ============================================
|
||||
|
||||
/// 预种购买协议弹窗
|
||||
///
|
||||
/// 展示协议文本,用户需勾选"我已经知晓并完全同意协议中的内容"后
|
||||
/// 才能点击"下一步"继续购买流程。
|
||||
class _AgreementDialog extends StatefulWidget {
|
||||
final String agreementText;
|
||||
|
||||
const _AgreementDialog({required this.agreementText});
|
||||
|
||||
@override
|
||||
State<_AgreementDialog> createState() => _AgreementDialogState();
|
||||
}
|
||||
|
||||
class _AgreementDialogState extends State<_AgreementDialog> {
|
||||
bool _isAgreed = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
backgroundColor: const Color(0xFFFFF7E6),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
title: const Text(
|
||||
'预种协议',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 协议文本(可滚动)
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxHeight: 300),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF5E6),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: const Color(0xFFD7CCC8),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Scrollbar(
|
||||
child: SingleChildScrollView(
|
||||
child: Text(
|
||||
widget.agreementText,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.8,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 勾选区域
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_isAgreed = !_isAgreed;
|
||||
});
|
||||
},
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
margin: const EdgeInsets.only(top: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: _isAgreed
|
||||
? const Color(0xFFD4A84B)
|
||||
: const Color(0xFFFFF5E6),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: const Color(0xFFD4A84B),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: _isAgreed
|
||||
? const Icon(
|
||||
Icons.check,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'我已经知晓并完全同意协议中的内容',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text(
|
||||
'取消',
|
||||
style: TextStyle(color: Color(0xFF745D43), fontSize: 16),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _isAgreed ? () => Navigator.of(context).pop(true) : null,
|
||||
child: Text(
|
||||
'下一步',
|
||||
style: TextStyle(
|
||||
color: _isAgreed
|
||||
? const Color(0xFFD4AF37)
|
||||
: const Color(0xFFD4AF37).withValues(alpha: 0.4),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1998,7 +1998,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||
),
|
||||
SizedBox(width: 6),
|
||||
Text(
|
||||
'预种持仓',
|
||||
'预种明细',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
|
|
|
|||
Loading…
Reference in New Issue