557 lines
16 KiB
Markdown
557 lines
16 KiB
Markdown
# Calendar Share Redesign - Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** 重写日历分享功能,支持选择多个好友/用户并单独设置权限,同时后端新增批量分享接口。
|
|
|
|
**Architecture:**
|
|
- **后端**: 新增 `POST /api/v1/schedule-items/{id}/share/batch` 批量分享接口,原有单用户接口保留。
|
|
- **前端**: 重构 `CalendarEventShareScreen` 和 `CalendarShareDialog`,移除硬编码手机号输入,改为用户搜索 + 多选 + 独立权限设置。
|
|
|
|
**Tech Stack:** Flutter (Riverpod/BLoC), FastAPI (Pydantic), PostgreSQL, 现有 API 契约
|
|
|
|
---
|
|
|
|
## 背景与现状
|
|
|
|
### 现有流程
|
|
```
|
|
用户 → CalendarEventShareScreen → CalendarShareDialog
|
|
├── 手机号输入 + 前缀选择
|
|
├── 权限开关 (View/Edit/Invite)
|
|
└── POST /api/v1/schedule-items/{id}/share
|
|
```
|
|
|
|
### 问题
|
|
1. 仅支持单用户分享
|
|
2. 需要手动输入手机号,不支持好友选择
|
|
3. 权限是全局的,无法为不同用户设置不同权限
|
|
|
|
### 目标流程
|
|
```
|
|
用户 → CalendarEventShareScreen → CalendarShareDialog
|
|
├── 搜索框 (好友/用户名/手机号搜索)
|
|
├── 用户列表 (可多选)
|
|
│ └── 每行: 头像 + 名字 + 权限开关 (选中后显示)
|
|
├── 已选用户 Chips
|
|
└── POST /api/v1/schedule-items/{id}/share/batch
|
|
```
|
|
|
|
---
|
|
|
|
## Task 1: 后端 - 新增批量分享接口
|
|
|
|
**Files:**
|
|
- Create: `backend/src/v1/schedule_items/schemas.py` (新增 `ScheduleItemShareBatchRequest`)
|
|
- Modify: `backend/src/v1/schedule_items/router.py` (新增 `/share/batch` 端点)
|
|
- Modify: `backend/src/v1/schedule_items/service.py` (新增 `share_batch` 方法)
|
|
- Verify: `backend/tests/**`
|
|
|
|
- [ ] **Step 1: 创建批量分享请求 Schema**
|
|
|
|
```python
|
|
# backend/src/v1/schedule_items/schemas.py
|
|
|
|
class ScheduleItemShareBatchRequest(BaseModel):
|
|
shares: List[ShareTarget] = Field(..., description="List of users to share with")
|
|
permission_view: bool = Field(True, description="Default view permission")
|
|
permission_edit: bool = Field(False, description="Default edit permission")
|
|
permission_invite: bool = Field(False, description="Default invite permission")
|
|
|
|
class ShareTarget(BaseModel):
|
|
phone: str = Field(..., pattern=r"^\+861[3-9]\d{9}$")
|
|
permission_view: Optional[bool] = None
|
|
permission_edit: Optional[bool] = None
|
|
permission_invite: Optional[bool] = None
|
|
|
|
class ScheduleItemShareBatchResponse(BaseModel):
|
|
message: str
|
|
success_count: int
|
|
failure_count: int
|
|
failures: List[ShareFailure] = []
|
|
|
|
class ShareFailure(BaseModel):
|
|
phone: str
|
|
reason: str
|
|
```
|
|
|
|
- [ ] **Step 2: 实现批量分享服务方法**
|
|
|
|
```python
|
|
# backend/src/v1/schedule_items/service.py
|
|
|
|
async def share_batch(
|
|
db: AsyncSession,
|
|
current_user: User,
|
|
schedule_item_id: UUID,
|
|
shares: List[ShareTarget],
|
|
default_permission_view: bool = True,
|
|
default_permission_edit: bool = False,
|
|
default_permission_invite: bool = False,
|
|
) -> Tuple[int, int, List[Dict]]:
|
|
"""批量分享日程给多个用户"""
|
|
# 1. 验证当前用户是否有邀请权限
|
|
# 2. 对每个 share 调用现有的 share_single 逻辑
|
|
# 3. 返回 (成功数, 失败数, 失败详情列表)
|
|
```
|
|
|
|
- [ ] **Step 3: 新增批量分享路由**
|
|
|
|
```python
|
|
# backend/src/v1/schedule_items/router.py
|
|
|
|
@router.post("/{item_id}/share/batch", response_model=ScheduleItemShareBatchResponse)
|
|
async def share_batch(
|
|
item_id: UUID,
|
|
request: ScheduleItemShareBatchRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
service: ScheduleItemService = Depends(),
|
|
):
|
|
"""批量分享日程给多个用户"""
|
|
success_count, failure_count, failures = await service.share_batch(
|
|
db=db,
|
|
current_user=current_user,
|
|
schedule_item_id=item_id,
|
|
shares=request.shares,
|
|
default_permission_view=request.permission_view,
|
|
default_permission_edit=request.permission_edit,
|
|
default_permission_invite=request.permission_invite,
|
|
)
|
|
return ScheduleItemShareBatchResponse(
|
|
message="Batch share completed",
|
|
success_count=success_count,
|
|
failure_count=failure_count,
|
|
failures=[ShareFailure(**f) for f in failures],
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 4: 编写测试**
|
|
|
|
```python
|
|
# backend/tests/v1/test_schedule_items.py
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_share_batch_success():
|
|
"""测试批量分享成功"""
|
|
# Arrange
|
|
# Act
|
|
# Assert
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_share_batch_partial_failure():
|
|
"""测试批量分享部分失败(非好友用户应被拒绝)"""
|
|
# Arrange
|
|
# Act
|
|
# Assert
|
|
```
|
|
|
|
- [ ] **Step 5: 运行测试验证**
|
|
|
|
```bash
|
|
cd backend && uv run pytest tests/v1/test_schedule_items.py -v -k "share_batch"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: 前端 - 新增批量分享 API 方法
|
|
|
|
**Files:**
|
|
- Modify: `apps/lib/features/calendar/data/apis/calendar_api.dart`
|
|
|
|
- [ ] **Step 1: 新增批量分享 API 方法**
|
|
|
|
```dart
|
|
// apps/lib/features/calendar/data/apis/calendar_api.dart
|
|
|
|
class CalendarApi {
|
|
// 现有单用户分享保留
|
|
Future<void> share(...) { ... }
|
|
|
|
// 新增批量分享
|
|
Future<ShareBatchResponse> shareBatch({
|
|
required String eventId,
|
|
required List<ShareTarget> shares,
|
|
bool permissionView = true,
|
|
bool permissionEdit = false,
|
|
bool permissionInvite = false,
|
|
}) async {
|
|
final response = await _dio.post(
|
|
'/api/v1/schedule-items/$eventId/share/batch',
|
|
data: {
|
|
'shares': shares.map((s) => s.toJson()).toList(),
|
|
'permission_view': permissionView,
|
|
'permission_edit': permissionEdit,
|
|
'permission_invite': permissionInvite,
|
|
},
|
|
);
|
|
return ShareBatchResponse.fromJson(response.data);
|
|
}
|
|
}
|
|
|
|
class ShareTarget {
|
|
final String phone;
|
|
final bool? permissionView;
|
|
final bool? permissionEdit;
|
|
final bool? permissionInvite;
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'phone': phone,
|
|
if (permissionView != null) 'permission_view': permissionView,
|
|
if (permissionEdit != null) 'permission_edit': permissionEdit,
|
|
if (permissionInvite != null) 'permission_invite': permissionInvite,
|
|
};
|
|
}
|
|
|
|
class ShareBatchResponse {
|
|
final String message;
|
|
final int successCount;
|
|
final int failureCount;
|
|
final List<ShareFailure> failures;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: 验证 API 编译**
|
|
|
|
```bash
|
|
cd apps && flutter analyze lib/features/calendar/data/apis/calendar_api.dart
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: 前端 - 创建分享页面状态管理
|
|
|
|
**Files:**
|
|
- Create: `apps/lib/features/calendar/presentation/cubits/calendar_share_cubit.dart`
|
|
- Create: `apps/lib/features/calendar/presentation/cubits/calendar_share_state.dart`
|
|
|
|
- [ ] **Step 1: 创建 State**
|
|
|
|
```dart
|
|
// apps/lib/features/calendar/presentation/cubits/calendar_share_state.dart
|
|
|
|
enum CalendarShareStatus { initial, loading, success, failure }
|
|
|
|
class SelectedUser {
|
|
final String id;
|
|
final String username;
|
|
final String? avatarUrl;
|
|
final String phone;
|
|
bool permissionView;
|
|
bool permissionEdit;
|
|
bool permissionInvite;
|
|
}
|
|
|
|
class CalendarShareState {
|
|
final CalendarShareStatus status;
|
|
final List<FriendResponse> friends;
|
|
final List<UserProfile> searchResults;
|
|
final List<SelectedUser> selectedUsers;
|
|
final String searchQuery;
|
|
final String? errorMessage;
|
|
|
|
// 权限默认值(应用于新选中的用户)
|
|
bool defaultPermissionView;
|
|
bool defaultPermissionEdit;
|
|
bool defaultPermissionInvite;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: 创建 Cubit**
|
|
|
|
```dart
|
|
// apps/lib/features/calendar/presentation/cubits/calendar_share_cubit.dart
|
|
|
|
class CalendarShareCubit extends Cubit<CalendarShareState> {
|
|
final FriendsApi _friendsApi;
|
|
final UsersApi _usersApi;
|
|
final CalendarApi _calendarApi;
|
|
|
|
CalendarShareCubit({
|
|
required FriendsApi friendsApi,
|
|
required UsersApi usersApi,
|
|
required CalendarApi calendarApi,
|
|
}) : super(CalendarShareState.initial());
|
|
|
|
// 加载好友列表
|
|
Future<void> loadFriends() async { ... }
|
|
|
|
// 搜索用户
|
|
Future<void> searchUsers(String query) async { ... }
|
|
|
|
// 切换用户选中状态
|
|
void toggleUser(String userId) { ... }
|
|
|
|
// 更新单个用户的权限
|
|
void updateUserPermission(String userId, {bool? view, bool? edit, bool? invite}) { ... }
|
|
|
|
// 移除已选用户
|
|
void removeUser(String userId) { ... }
|
|
|
|
// 设置默认权限(对新选用户生效)
|
|
void setDefaultPermissions({bool? view, bool? edit, bool? invite}) { ... }
|
|
|
|
// 发送批量分享
|
|
Future<void> share(String eventId) async { ... }
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: 验证编译**
|
|
|
|
```bash
|
|
cd apps && flutter analyze lib/features/calendar/presentation/cubits/calendar_share_cubit.dart
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: 前端 - 重构分享对话框 UI
|
|
|
|
**Files:**
|
|
- Create: `apps/lib/features/calendar/presentation/widgets/user_select_list.dart`
|
|
- Create: `apps/lib/features/calendar/presentation/widgets/user_select_item.dart`
|
|
- Modify: `apps/lib/features/calendar/presentation/widgets/calendar_share_dialog.dart`
|
|
- Modify: `apps/lib/features/calendar/presentation/screens/calendar_event_share_screen.dart`
|
|
|
|
### 4.1 用户选择列表组件
|
|
|
|
- [ ] **Step 1: 创建 UserSelectItem**
|
|
|
|
```dart
|
|
// apps/lib/features/calendar/presentation/widgets/user_select_item.dart
|
|
|
|
class UserSelectItem extends StatelessWidget {
|
|
final UserBasicInfo user;
|
|
final bool isSelected;
|
|
final bool showPermissions; // 选中后才显示权限开关
|
|
final SelectedUser? selectedUser; // 如果选中,包含权限状态
|
|
final ValueChanged<bool> onToggle;
|
|
final Function(PermissionType, bool) onPermissionChanged;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListTile(
|
|
leading: Avatar(url: user.avatarUrl, name: user.username),
|
|
title: Text(user.username),
|
|
subtitle: showPermissions && isSelected
|
|
? Row(
|
|
children: [
|
|
_PermissionChip('V', selectedUser?.permissionView ?? true, (v) => onPermissionChanged(PermissionType.view, v)),
|
|
_PermissionChip('E', selectedUser?.permissionEdit ?? false, (v) => onPermissionChanged(PermissionType.edit, v)),
|
|
_PermissionChip('I', selectedUser?.permissionInvite ?? false, (v) => onPermissionChanged(PermissionType.invite, v)),
|
|
],
|
|
)
|
|
: null,
|
|
trailing: Checkbox(value: isSelected, onChanged: (v) => onToggle(v ?? false)),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 4.2 重构 CalendarShareDialog
|
|
|
|
- [ ] **Step 2: 重构对话框**
|
|
|
|
```dart
|
|
// apps/lib/features/calendar/presentation/widgets/calendar_share_dialog.dart
|
|
|
|
class CalendarShareDialog extends StatefulWidget {
|
|
// ... existing props
|
|
|
|
@override
|
|
State<CalendarShareDialog> createState() => _CalendarShareDialogState();
|
|
}
|
|
|
|
class _CalendarShareDialogState extends State<CalendarShareDialog> {
|
|
late final CalendarShareCubit _cubit;
|
|
final _searchController = TextEditingController();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_cubit = context.read<CalendarShareCubit>();
|
|
_cubit.loadFriends();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return BlocProvider.value(
|
|
value: _cubit,
|
|
child: BlocBuilder<CalendarShareCubit, CalendarShareState>(
|
|
builder: (context, state) {
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// Header
|
|
_buildHeader(context, state),
|
|
|
|
// 搜索框
|
|
TextField(
|
|
controller: _searchController,
|
|
decoration: InputDecoration(
|
|
hintText: '搜索好友或输入手机号...',
|
|
prefixIcon: Icon(Icons.search),
|
|
),
|
|
onChanged: (value) => _cubit.searchUsers(value),
|
|
),
|
|
|
|
// 已选用户 Chips
|
|
if (state.selectedUsers.isNotEmpty) ...[
|
|
Wrap(
|
|
spacing: 8,
|
|
children: state.selectedUsers.map((user) {
|
|
return Chip(
|
|
label: Text('${user.username} (${_getPermissionSummary(user)})'),
|
|
onDeleted: () => _cubit.removeUser(user.id),
|
|
);
|
|
}).toList(),
|
|
),
|
|
],
|
|
|
|
// 用户列表
|
|
Flexible(
|
|
child: _buildUserList(state),
|
|
),
|
|
|
|
// 发送按钮
|
|
AppButton(
|
|
text: '发送邀请 (${state.selectedUsers.length}人)',
|
|
onPressed: state.selectedUsers.isEmpty
|
|
? null
|
|
: () => _handleShare(context, state),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildUserList(CalendarShareState state) {
|
|
// 如果有搜索结果,显示搜索结果;否则显示好友列表
|
|
final users = state.searchQuery.isNotEmpty
|
|
? state.searchResults
|
|
: state.friends.map((f) => f.friend).toList();
|
|
|
|
return ListView.builder(
|
|
shrinkWrap: true,
|
|
itemCount: users.length,
|
|
itemBuilder: (context, index) {
|
|
final user = users[index];
|
|
final selectedUser = state.selectedUsers.cast<SelectedUser?>().firstWhere(
|
|
(s) => s?.id == user.id,
|
|
orElse: () => null,
|
|
);
|
|
final isSelected = selectedUser != null;
|
|
|
|
return UserSelectItem(
|
|
user: user,
|
|
isSelected: isSelected,
|
|
showPermissions: isSelected,
|
|
selectedUser: selectedUser,
|
|
onToggle: (selected) {
|
|
if (selected) {
|
|
_cubit.addUser(user, state.defaultPermissionView, state.defaultPermissionEdit, state.defaultPermissionInvite);
|
|
} else {
|
|
_cubit.removeUser(user.id);
|
|
}
|
|
},
|
|
onPermissionChanged: (type, value) {
|
|
_cubit.updateUserPermission(user.id, type: type, value: value);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: 移除顶部栏,简化 CalendarEventShareScreen**
|
|
|
|
```dart
|
|
// apps/lib/features/calendar/presentation/screens/calendar_event_share_screen.dart
|
|
|
|
class CalendarEventShareScreen extends StatelessWidget {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
body: SafeArea(
|
|
child: Column(
|
|
children: [
|
|
// 直接显示分享对话框,不需要额外顶部栏
|
|
Expanded(
|
|
child: CalendarShareDialog(
|
|
eventId: eventId,
|
|
eventTitle: event.title,
|
|
canInvite: event.canInvite,
|
|
canEdit: event.canEdit,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: 验证编译**
|
|
|
|
```bash
|
|
cd apps && flutter analyze lib/features/calendar/presentation/widgets/calendar_share_dialog.dart
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: 集成测试
|
|
|
|
**Files:**
|
|
- Create: `apps/test/features/calendar/presentation/widgets/calendar_share_dialog_test.dart`
|
|
|
|
- [ ] **Step 1: 编写 Widget 测试**
|
|
|
|
```dart
|
|
void main() {
|
|
testWidgets('选择用户后显示权限开关', (tester) async {
|
|
// Arrange
|
|
// Act
|
|
// Assert
|
|
});
|
|
|
|
testWidgets('已选用户显示在 Chips 中', (tester) async {
|
|
// Arrange
|
|
// Act
|
|
// Assert
|
|
});
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: 运行测试**
|
|
|
|
```bash
|
|
cd apps && flutter test test/features/calendar/presentation/widgets/calendar_share_dialog_test.dart
|
|
```
|
|
|
|
---
|
|
|
|
## 依赖关系
|
|
|
|
```
|
|
Task 1 (后端批量接口)
|
|
↓
|
|
Task 2 (前端批量分享 API)
|
|
↓
|
|
Task 3 (分享状态管理 Cubit)
|
|
↓
|
|
Task 4 (分享对话框 UI)
|
|
↓
|
|
Task 5 (集成测试)
|
|
```
|
|
|
|
---
|
|
|
|
## 风险与注意事项
|
|
|
|
1. **后端兼容性**: 批量接口上线前,前端使用原有单用户接口
|
|
2. **搜索防抖**: 搜索用户时需要 debounce 避免频繁请求
|
|
3. **好友列表分页**: 如果好友数量大,需要分页加载
|
|
4. **权限验证**: 前端权限不能超过当前用户的权限(由后端控制)
|