727 lines
24 KiB
Dart
727 lines
24 KiB
Dart
import 'package:elysia/page/child/EditorRobotPage.dart';
|
||
import 'package:elysia/page/child/RobotProfilePage.dart';
|
||
import 'package:elysia/page/child/RobotSquarePage.dart';
|
||
import 'package:elysia/plugin/HiverCache.dart';
|
||
import 'package:elysia/plugin/RouteAnimation.dart';
|
||
import 'package:elysia/plugin/Toast.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:shimmer/shimmer.dart';
|
||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||
|
||
import '../bean/Mappable.dart';
|
||
import '../plugin/C.dart';
|
||
import '../plugin/CacheAavatar.dart';
|
||
import '../plugin/HTTP.dart';
|
||
import 'package:oktoast/oktoast.dart';
|
||
|
||
import '../plugin/LoadingOverlay.dart';
|
||
|
||
class FollowListPage extends StatefulWidget {
|
||
const FollowListPage({Key? key}) : super(key: key);
|
||
static Function(bool)? flushData;
|
||
static Function(int)? delete;
|
||
|
||
@override
|
||
State<FollowListPage> createState() => _FollowListPageState();
|
||
}
|
||
|
||
class _FollowListPageState extends State<FollowListPage>
|
||
with AutomaticKeepAliveClientMixin {
|
||
List<FollowItem> _list = [];
|
||
bool _loading = true;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
FollowListPage.flushData = (status) {
|
||
_list = [];
|
||
_loading = true;
|
||
fetchFollowList(status);
|
||
};
|
||
FollowListPage.delete = (id) {
|
||
_removeById(id);
|
||
};
|
||
fetchFollowList(false);
|
||
}
|
||
|
||
Future<void> fetchFollowList(bool status) async {
|
||
try {
|
||
_loading = false;
|
||
List<int> cacheId = [];
|
||
List<FollowItem> cacheData =
|
||
await HiverCache.getCache(
|
||
C.HIVE_CACHE_FETCH_FOLLOW,
|
||
FollowItem.fromJson,
|
||
) ??
|
||
[];
|
||
setState(() {
|
||
_list = cacheData;
|
||
});
|
||
if (_list.length == 0 || status) {
|
||
_loading = true;
|
||
} else {
|
||
if (!status) {
|
||
for (FollowItem followItem in cacheData) {
|
||
cacheId.add(followItem.robotId);
|
||
}
|
||
}
|
||
}
|
||
|
||
dynamic result = await HTTP
|
||
.create("${C.BASE_URL}/robot/follow-list")
|
||
.setHeader(C.TOKEN)
|
||
.setParam({"cacheId": cacheId})
|
||
.setRequestType(RequestType.GET)
|
||
.execute();
|
||
if (result['code'] == 200) {
|
||
setState(() {
|
||
if (status) {
|
||
_list = [];
|
||
}
|
||
//判定是否有要删除的ID
|
||
List<dynamic> excludeIdList = result['data']["excludeIdList"] ?? [];
|
||
if (excludeIdList != null || excludeIdList.length != 0) {
|
||
for (dynamic id in excludeIdList) {
|
||
int index = -1;
|
||
for (int i = 0; i < _list.length; i++) {
|
||
if (_list[i].robotId.toString() == id.toString()) {
|
||
index = i;
|
||
break;
|
||
}
|
||
}
|
||
if (index != -1) {
|
||
_list.removeAt(index);
|
||
}
|
||
}
|
||
}
|
||
List<Map<String, dynamic>> listOfMap =
|
||
(result['data']["data"] as List).cast<Map<String, dynamic>>();
|
||
if (listOfMap.length != 0) {
|
||
for (Map<String, dynamic> d in listOfMap) {
|
||
_list.add(FollowItem.fromJson(d));
|
||
}
|
||
}
|
||
_loading = false;
|
||
HiverCache.cache(C.HIVE_CACHE_FETCH_FOLLOW, _list);
|
||
});
|
||
} else {
|
||
setState(() => _loading = false);
|
||
}
|
||
} catch (e) {
|
||
setState(() => _loading = false);
|
||
}
|
||
}
|
||
|
||
void _removeAt(int index) {
|
||
setState(() {
|
||
_list.removeAt(index);
|
||
HiverCache.cache(C.HIVE_CACHE_FETCH_FOLLOW, _list);
|
||
});
|
||
}
|
||
|
||
void _removeById(int id) {
|
||
int index = -1;
|
||
for (int i = 0; i < _list.length; i++) {
|
||
if (_list[i].robotId == id) {
|
||
index = i;
|
||
break;
|
||
}
|
||
}
|
||
if (index != -1) {
|
||
_removeAt(index);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
body: Column(
|
||
children: [
|
||
SizedBox(
|
||
child: Column(
|
||
children: [
|
||
Padding(
|
||
padding: EdgeInsets.symmetric(horizontal: 5),
|
||
child: GestureDetector(
|
||
onTap: () async {
|
||
bool status = await Navigator.push(
|
||
context,
|
||
RouteAnimation(RobotSquarePage(), Offset(1, 0)),
|
||
);
|
||
if (status) {
|
||
setState(() {
|
||
_list = [];
|
||
_loading = true;
|
||
fetchFollowList(false);
|
||
});
|
||
}
|
||
},
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
color: Colors.grey[200],
|
||
borderRadius: BorderRadius.circular(5),
|
||
),
|
||
child: Padding(
|
||
padding: EdgeInsets.symmetric(
|
||
horizontal: 10,
|
||
vertical: 5,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
// Avatar(带圆角和 Shimmer)
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(8),
|
||
child: SizedBox(
|
||
width: 48,
|
||
height: 50,
|
||
child: Container(
|
||
decoration: BoxDecoration(color: Colors.blue),
|
||
child: Icon(Icons.group, color: Colors.white),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
// 名称与其它信息(这里仅显示 name)
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
"机器人广场",
|
||
style: const TextStyle(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Expanded(child: SizedBox()),
|
||
Icon(Icons.chevron_right, color: Colors.grey),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
SizedBox(height: 10),
|
||
Divider(height: 1, thickness: 1, color: Colors.grey.shade300),
|
||
],
|
||
),
|
||
),
|
||
SizedBox(height: 10),
|
||
Expanded(
|
||
child: _loading
|
||
? ListView.builder(
|
||
itemCount: 10,
|
||
itemBuilder: (context, index) => const ShimmerFollowTile(),
|
||
)
|
||
: ClipRect(
|
||
child: ListView.builder(
|
||
itemCount: _list.length,
|
||
itemBuilder: (context, index) {
|
||
final item = _list[index];
|
||
return FollowTile(
|
||
item: item,
|
||
onDelete: () => unfollowRobot(index),
|
||
onClick: () {
|
||
Navigator.push(
|
||
context,
|
||
RouteAnimation(
|
||
RobotProfilePage(
|
||
robotId: item.robotId,
|
||
name: item.name,
|
||
avatar: item.avatar,
|
||
systemPrompt: item.systemPrompt,
|
||
creatorUsername: "开发中...",
|
||
template: "开发中...",
|
||
description: item.describe,
|
||
followList: true,
|
||
),
|
||
Offset(1, 0),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> unfollowRobot(int index) async {
|
||
LoadingOverlay.show(context: context, barrierColor: Colors.black54);
|
||
try {
|
||
dynamic result = await HTTP
|
||
.create("${C.BASE_URL}/robot/unfollow/${_list[index].robotId}")
|
||
.setHeader(C.TOKEN)
|
||
.setRequestType(RequestType.GET)
|
||
.execute();
|
||
LoadingOverlay.hide();
|
||
if (result["code"] == 200) {
|
||
_removeAt(index);
|
||
}
|
||
} catch (e) {
|
||
LoadingOverlay.hide();
|
||
}
|
||
}
|
||
|
||
@override
|
||
bool get wantKeepAlive => true;
|
||
}
|
||
|
||
/// 数据模型
|
||
class FollowItem extends Mappable<FollowItem> {
|
||
final int robotId;
|
||
final String name;
|
||
final String avatar; // 头像 URL
|
||
final bool creator;
|
||
final String systemPrompt;
|
||
final String describe;
|
||
|
||
FollowItem({
|
||
required this.robotId,
|
||
required this.name,
|
||
required this.avatar,
|
||
required this.systemPrompt,
|
||
required this.creator,
|
||
required this.describe,
|
||
});
|
||
|
||
factory FollowItem.fromJson(Map<String, dynamic> json) {
|
||
return FollowItem(
|
||
robotId: json['robotId'] ?? -1,
|
||
name: json['name'] ?? '',
|
||
avatar: json['avatar'] ?? '',
|
||
describe: json['describe'] ?? '',
|
||
systemPrompt: json['systemPrompt'] ?? '',
|
||
creator: json['creator'] == 1 || json['creator'] == true,
|
||
);
|
||
}
|
||
|
||
@override
|
||
Map<String, dynamic> toMap() {
|
||
return {
|
||
"robotId": robotId,
|
||
"name": name,
|
||
"avatar": avatar,
|
||
"describe": describe,
|
||
"systemPrompt": systemPrompt,
|
||
"creator": creator,
|
||
};
|
||
}
|
||
}
|
||
|
||
/// 真正的数据行(支持 Slidable 删除)
|
||
class FollowTile extends StatelessWidget {
|
||
final FollowItem item;
|
||
final VoidCallback? onDelete;
|
||
final VoidCallback? onClick;
|
||
|
||
const FollowTile({Key? key, required this.item, this.onDelete, this.onClick})
|
||
: super(key: key);
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 2),
|
||
child: Slidable(
|
||
key: ValueKey(item.robotId),
|
||
endActionPane: ActionPane(
|
||
motion: const DrawerMotion(),
|
||
extentRatio: 0.25,
|
||
children: [
|
||
SlidableAction(
|
||
onPressed: (ctx) {
|
||
if (onDelete != null) onDelete!(); // ✅ 这里会正确调用 unfollowRobot
|
||
},
|
||
backgroundColor: Colors.amber,
|
||
foregroundColor: Colors.white,
|
||
icon: Icons.heart_broken,
|
||
label: '取消关注',
|
||
spacing: 4,
|
||
borderRadius: BorderRadius.circular(5),
|
||
autoClose: true,
|
||
)
|
||
],
|
||
),
|
||
child: GestureDetector(
|
||
onTap: () {
|
||
if (onClick != null) onClick!();
|
||
},
|
||
child: Stack(
|
||
children: [
|
||
Padding(
|
||
padding: EdgeInsets.symmetric(horizontal: 5),
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
color: Colors.grey[200],
|
||
borderRadius: BorderRadius.circular(5),
|
||
),
|
||
child: Padding(
|
||
padding: EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||
child: Row(
|
||
children: [
|
||
// Avatar(带圆角和 Shimmer)
|
||
CacheAvatar(url: item.avatar),
|
||
const SizedBox(width: 12),
|
||
// 名称与其它信息(这里仅显示 name)
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
item.name,
|
||
style: const TextStyle(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
SizedBox(height: 5),
|
||
Text(
|
||
item.describe,
|
||
style: const TextStyle(
|
||
fontSize: 12,
|
||
color: Colors.grey,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
if (item.creator)
|
||
Positioned(
|
||
right: 5,
|
||
top: 0,
|
||
child: Container(
|
||
padding: const EdgeInsets.all(4),
|
||
decoration: BoxDecoration(
|
||
color: Colors.orange,
|
||
borderRadius: BorderRadius.only(
|
||
topRight: Radius.circular(5),
|
||
bottomLeft: Radius.circular(5),
|
||
),
|
||
),
|
||
child: const Icon(
|
||
Icons.person, // 人头图标
|
||
size: 14,
|
||
color: Colors.white,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Shimmer 占位行(加载时显示)
|
||
class ShimmerFollowTile extends StatelessWidget {
|
||
const ShimmerFollowTile({Key? key}) : super(key: key);
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
// 高度与真实行相似,保持视觉一致
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||
child: Row(
|
||
children: [
|
||
Shimmer.fromColors(
|
||
baseColor: Colors.grey.shade300,
|
||
highlightColor: Colors.grey.shade100,
|
||
child: Container(
|
||
width: 48,
|
||
height: 48,
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Shimmer.fromColors(
|
||
baseColor: Colors.grey.shade300,
|
||
highlightColor: Colors.grey.shade100,
|
||
child: Container(
|
||
height: 16,
|
||
width: double.infinity,
|
||
color: Colors.white,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Shimmer.fromColors(
|
||
baseColor: Colors.grey.shade300,
|
||
highlightColor: Colors.grey.shade100,
|
||
child: Container(
|
||
height: 14,
|
||
width: MediaQuery.of(context).size.width * 0.5,
|
||
color: Colors.white,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class ChatRoomDialog extends StatefulWidget {
|
||
final List<Map<String, String>> options; // 下拉选择的数据
|
||
final Function(String roomName, String value) onConfirm;
|
||
|
||
const ChatRoomDialog({
|
||
Key? key,
|
||
required this.options,
|
||
required this.onConfirm,
|
||
}) : super(key: key);
|
||
|
||
@override
|
||
State<ChatRoomDialog> createState() => _ChatRoomDialogState();
|
||
}
|
||
|
||
class _ChatRoomDialogState extends State<ChatRoomDialog> {
|
||
final TextEditingController _roomController = TextEditingController();
|
||
String? _selectedValue;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
if (widget.options.isNotEmpty) {
|
||
_selectedValue = widget.options[0]['value'];
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final isDark = theme.brightness == Brightness.dark;
|
||
|
||
return Dialog(
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||
elevation: 0,
|
||
backgroundColor: Colors.transparent,
|
||
insetPadding: EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||
child: _dialogContent(context, theme, isDark),
|
||
);
|
||
}
|
||
|
||
Widget _dialogContent(BuildContext context, ThemeData theme, bool isDark) {
|
||
return Container(
|
||
padding: EdgeInsets.all(24),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.surface,
|
||
borderRadius: BorderRadius.circular(24),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withOpacity(0.2),
|
||
blurRadius: 20,
|
||
spreadRadius: 0,
|
||
offset: Offset(0, 8),
|
||
),
|
||
],
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// Title
|
||
Text(
|
||
"创建聊天室",
|
||
style: TextStyle(
|
||
fontSize: 20,
|
||
fontWeight: FontWeight.w700,
|
||
color: theme.colorScheme.onSurface,
|
||
),
|
||
),
|
||
SizedBox(height: 8),
|
||
Text(
|
||
"设置您的聊天室名称并选择AI引擎",
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||
),
|
||
),
|
||
SizedBox(height: 24),
|
||
|
||
// Chat room name field
|
||
Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
"聊天室名称",
|
||
style: TextStyle(
|
||
fontWeight: FontWeight.w500,
|
||
color: theme.colorScheme.onSurface.withOpacity(0.8),
|
||
fontSize: 14,
|
||
),
|
||
),
|
||
SizedBox(height: 8),
|
||
Container(
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(16),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: theme.colorScheme.primary.withOpacity(0.1),
|
||
blurRadius: 4,
|
||
offset: Offset(0, 2),
|
||
),
|
||
],
|
||
),
|
||
child: TextField(
|
||
controller: _roomController,
|
||
decoration: InputDecoration(
|
||
hintText: "请输入聊天室名称",
|
||
filled: true,
|
||
fillColor: isDark
|
||
? theme.colorScheme.surfaceVariant
|
||
: Colors.grey[50],
|
||
contentPadding: EdgeInsets.symmetric(
|
||
horizontal: 16,
|
||
vertical: 14,
|
||
),
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(16),
|
||
borderSide: BorderSide.none,
|
||
),
|
||
focusedBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(16),
|
||
borderSide: BorderSide(
|
||
color: theme.colorScheme.primary,
|
||
width: 1.5,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
SizedBox(height: 20),
|
||
|
||
// Engine selection
|
||
Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
"选择引擎",
|
||
style: TextStyle(
|
||
fontWeight: FontWeight.w500,
|
||
color: theme.colorScheme.onSurface.withOpacity(0.8),
|
||
fontSize: 14,
|
||
),
|
||
),
|
||
SizedBox(height: 8),
|
||
Container(
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(16),
|
||
color: isDark
|
||
? theme.colorScheme.surfaceVariant
|
||
: Colors.grey[50],
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: theme.colorScheme.primary.withOpacity(0.1),
|
||
blurRadius: 4,
|
||
offset: Offset(0, 2),
|
||
),
|
||
],
|
||
),
|
||
padding: EdgeInsets.symmetric(horizontal: 12),
|
||
child: DropdownButtonHideUnderline(
|
||
child: DropdownButton<String>(
|
||
value: _selectedValue,
|
||
isExpanded: true,
|
||
icon: Icon(
|
||
Icons.keyboard_arrow_down_rounded,
|
||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||
),
|
||
borderRadius: BorderRadius.circular(16),
|
||
dropdownColor: isDark
|
||
? theme.colorScheme.surfaceVariant
|
||
: Colors.white,
|
||
style: TextStyle(
|
||
color: theme.colorScheme.onSurface,
|
||
fontSize: 16,
|
||
),
|
||
items: widget.options.map((option) {
|
||
return DropdownMenuItem<String>(
|
||
value: option['value'],
|
||
child: Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 8),
|
||
child: Text(option['key'] ?? ''),
|
||
),
|
||
);
|
||
}).toList(),
|
||
onChanged: (val) {
|
||
setState(() {
|
||
_selectedValue = val;
|
||
});
|
||
},
|
||
selectedItemBuilder: (BuildContext context) {
|
||
return widget.options.map<Widget>((item) {
|
||
return Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: Text(
|
||
item['key'] ?? '',
|
||
style: TextStyle(
|
||
color: theme.colorScheme.onSurface,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
);
|
||
}).toList();
|
||
},
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
SizedBox(height: 32),
|
||
|
||
// Confirm button
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: ElevatedButton(
|
||
style: ElevatedButton.styleFrom(
|
||
padding: EdgeInsets.symmetric(vertical: 16),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(16),
|
||
),
|
||
backgroundColor: theme.colorScheme.primary,
|
||
foregroundColor: theme.colorScheme.onPrimary,
|
||
elevation: 0,
|
||
shadowColor: Colors.transparent,
|
||
),
|
||
onPressed: () {
|
||
if (_roomController.text.isEmpty || _selectedValue == null) {
|
||
showToast("请填写完整信息", position: ToastPosition.center);
|
||
return;
|
||
}
|
||
widget.onConfirm(_roomController.text, _selectedValue!);
|
||
Navigator.of(context).pop();
|
||
},
|
||
child: Text(
|
||
"创建聊天室",
|
||
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|