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:
hailin 2026-02-26 21:14:24 -08:00
parent 92054e776e
commit c0ac63d40a
12 changed files with 359 additions and 9 deletions

View File

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

View File

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

View File

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

View File

@ -6,6 +6,7 @@ import { firstValueFrom } from 'rxjs';
export interface PrePlantingConfig {
isActive: boolean;
activatedAt: Date | null;
agreementText?: string | null;
}
@Injectable()

View File

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

View File

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

View File

@ -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 编排的树转让订单管理:列表/详情/统计/强制取消

View File

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

View File

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

View File

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

View File

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

View File

@ -1998,7 +1998,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
),
SizedBox(width: 6),
Text(
'预种持仓',
'预种明细',
style: TextStyle(
fontSize: 14,
fontFamily: 'Inter',