feat: create/update/del follow tag

opt: owner follow page

Signed-off-by: bggRGjQaUbCoE <githubaccount56556@proton.me>
This commit is contained in:
bggRGjQaUbCoE
2025-04-23 11:12:17 +08:00
parent e212144250
commit 0d27d88719
9 changed files with 405 additions and 87 deletions

View File

@@ -47,6 +47,8 @@
## feat ## feat
- [x] 创建/修改/删除关注分组
- [x] 移除粉丝
- [x] 直播弹幕发送表情 - [x] 直播弹幕发送表情
- [x] 收藏夹排序 - [x] 收藏夹排序
- [x] 稍后再看`未看`/`未看完`/`已看完`分类 - [x] 稍后再看`未看`/`未看完`/`已看完`分类

View File

@@ -4,7 +4,7 @@ import 'package:get/get.dart';
void showConfirmDialog({ void showConfirmDialog({
required BuildContext context, required BuildContext context,
required String title, required String title,
String? content, dynamic content,
required VoidCallback onConfirm, required VoidCallback onConfirm,
}) { }) {
showDialog( showDialog(
@@ -12,7 +12,11 @@ void showConfirmDialog({
builder: (context) { builder: (context) {
return AlertDialog( return AlertDialog(
title: Text(title), title: Text(title),
content: content == null ? null : Text(content), content: content is String
? Text(content)
: content is Widget
? content
: null,
actions: [ actions: [
TextButton( TextButton(
onPressed: Get.back, onPressed: Get.back,

View File

@@ -286,10 +286,6 @@ class Api {
// order_type 排序规则 最近访问传空,最常访问传 attention // order_type 排序规则 最近访问传空,最常访问传 attention
static const String followings = '/x/relation/followings'; static const String followings = '/x/relation/followings';
// 指定分类的关注
// https://api.bilibili.com/x/relation/tag?mid=17340771&tagid=-10&pn=1&ps=20
static const String tagFollowings = '/x/relation/tag';
// 搜索follow // 搜索follow
static const followSearch = '/x/relation/followings/search'; static const followSearch = '/x/relation/followings/search';
@@ -469,6 +465,12 @@ class Api {
// 获取指定分组下的up // 获取指定分组下的up
static const String followUpGroup = '/x/relation/tag'; static const String followUpGroup = '/x/relation/tag';
static const String createFollowTag = '/x/relation/tag/create';
static const String updateFollowTag = '/x/relation/tag/update';
static const String delFollowTag = '/x/relation/tag/del';
// 获取未读私信数 // 获取未读私信数
// https://api.vc.bilibili.com/session_svr/v1/session_svr/single_unread // https://api.vc.bilibili.com/session_svr/v1/session_svr/single_unread
static const String msgUnread = static const String msgUnread =

View File

@@ -4,8 +4,12 @@ import '../models/follow/result.dart';
import 'index.dart'; import 'index.dart';
class FollowHttp { class FollowHttp {
static Future followings( static Future followings({
{int? vmid, int? pn, int? ps, String? orderType}) async { int? vmid,
int? pn,
int? ps,
String orderType = '',
}) async {
var res = await Request().get(Api.followings, queryParameters: { var res = await Request().get(Api.followings, queryParameters: {
'vmid': vmid, 'vmid': vmid,
'pn': pn, 'pn': pn,
@@ -23,8 +27,12 @@ class FollowHttp {
} }
} }
static Future<LoadingState<List<FollowItemModel>?>> followingsNew( static Future<LoadingState<FollowDataModel>> followingsNew({
{int? vmid, int? pn, int? ps, String? orderType}) async { int? vmid,
int? pn,
int? ps,
String orderType = '', // ''=>最近关注,'attention'=>最常访问
}) async {
var res = await Request().get(Api.followings, queryParameters: { var res = await Request().get(Api.followings, queryParameters: {
'vmid': vmid, 'vmid': vmid,
'pn': pn, 'pn': pn,
@@ -35,7 +43,8 @@ class FollowHttp {
if (res.data['code'] == 0) { if (res.data['code'] == 0) {
return LoadingState.success( return LoadingState.success(
FollowDataModel.fromJson(res.data['data']).list); FollowDataModel.fromJson(res.data['data']),
);
} else { } else {
return LoadingState.error(res.data['message']); return LoadingState.error(res.data['message']);
} }

View File

@@ -470,8 +470,8 @@ class MemberHttp {
if (res.data['code'] == 0) { if (res.data['code'] == 0) {
return { return {
'status': true, 'status': true,
'data': (res.data['data'] as List?) 'data': res.data['data']
?.map<MemberTagItemModel>((e) => MemberTagItemModel.fromJson(e)) .map<MemberTagItemModel>((e) => MemberTagItemModel.fromJson(e))
.toList() .toList()
}; };
} else { } else {
@@ -525,7 +525,7 @@ class MemberHttp {
} }
// 获取某分组下的up // 获取某分组下的up
static Future<LoadingState<List<FollowItemModel>?>> followUpGroup( static Future<LoadingState<FollowDataModel>> followUpGroup(
int? mid, int? mid,
int? tagid, int? tagid,
int? pn, int? pn,
@@ -541,14 +541,82 @@ class MemberHttp {
}, },
); );
if (res.data['code'] == 0) { if (res.data['code'] == 0) {
return LoadingState.success((res.data['data'] as List?) return LoadingState.success(FollowDataModel(
?.map<FollowItemModel>((e) => FollowItemModel.fromJson(e)) list: (res.data['data'] as List?)
.toList()); ?.map<FollowItemModel>((e) => FollowItemModel.fromJson(e))
.toList()));
} else { } else {
return LoadingState.error(res.data['message']); return LoadingState.error(res.data['message']);
} }
} }
static Future createFollowTag(tagName) async {
var res = await Request().post(
Api.createFollowTag,
queryParameters: {
'x-bili-device-req-json':
'{"platform":"web","device":"pc","spmid":"333.1387"}',
},
data: {
'tag': tagName,
'csrf': Accounts.main.csrf,
},
options: Options(
contentType: Headers.formUrlEncodedContentType,
),
);
if (res.data['code'] == 0) {
return {'status': true};
} else {
return {'status': false, 'msg': res.data['message']};
}
}
static Future updateFollowTag(tagid, name) async {
var res = await Request().post(
Api.updateFollowTag,
queryParameters: {
'x-bili-device-req-json':
'{"platform":"web","device":"pc","spmid":"333.1387"}',
},
data: {
'tagid': tagid,
'name': name,
'csrf': Accounts.main.csrf,
},
options: Options(
contentType: Headers.formUrlEncodedContentType,
),
);
if (res.data['code'] == 0) {
return {'status': true};
} else {
return {'status': false, 'msg': res.data['message']};
}
}
static Future delFollowTag(tagid) async {
var res = await Request().post(
Api.delFollowTag,
queryParameters: {
'x-bili-device-req-json':
'{"platform":"web","device":"pc","spmid":"333.1387"}',
},
data: {
'tagid': tagid,
'csrf': Accounts.main.csrf,
},
options: Options(
contentType: Headers.formUrlEncodedContentType,
),
);
if (res.data['code'] == 0) {
return {'status': true};
} else {
return {'status': false, 'msg': res.data['message']};
}
}
// 获取up置顶 // 获取up置顶
static Future getTopVideo(String? vmid) async { static Future getTopVideo(String? vmid) async {
var res = await Request().get(Api.getTopVideoApi); var res = await Request().get(Api.getTopVideoApi);

View File

@@ -3,13 +3,25 @@ import 'package:PiliPlus/http/loading_state.dart';
import 'package:PiliPlus/http/member.dart'; import 'package:PiliPlus/http/member.dart';
import 'package:PiliPlus/models/follow/result.dart'; import 'package:PiliPlus/models/follow/result.dart';
import 'package:PiliPlus/pages/common/common_list_controller.dart'; import 'package:PiliPlus/pages/common/common_list_controller.dart';
import 'package:PiliPlus/pages/follow/controller.dart';
import 'package:get/get.dart';
enum OrderType { def, attention }
extension OrderTypeExt on OrderType {
String get type => const ['', 'attention'][index];
String get title => const ['最近关注', '最常访问'][index];
}
class FollowChildController class FollowChildController
extends CommonListController<List<FollowItemModel>?, FollowItemModel> { extends CommonListController<FollowDataModel, FollowItemModel> {
FollowChildController(this.mid, this.tagid); FollowChildController(this.controller, this.mid, this.tagid);
final FollowController controller;
final int? tagid; final int? tagid;
final int mid; final int mid;
late final Rx<OrderType> orderType = OrderType.def.obs;
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
@@ -17,12 +29,35 @@ class FollowChildController
} }
@override @override
Future<LoadingState<List<FollowItemModel>?>> customGetData() { List<FollowItemModel>? getDataList(FollowDataModel response) {
return response.list;
}
@override
bool customHandleResponse(bool isRefresh, Success<FollowDataModel> response) {
try {
if (controller.isOwner &&
tagid == null &&
isRefresh &&
controller.followState.value is Success) {
controller.tabs[0].count = response.response.total;
controller.tabs.refresh();
}
} catch (_) {}
return false;
}
@override
Future<LoadingState<FollowDataModel>> customGetData() {
if (tagid != null) { if (tagid != null) {
return MemberHttp.followUpGroup(mid, tagid, currentPage, 20); return MemberHttp.followUpGroup(mid, tagid, currentPage, 20);
} }
return FollowHttp.followingsNew( return FollowHttp.followingsNew(
vmid: mid, pn: currentPage, ps: 20, orderType: 'attention'); vmid: mid,
pn: currentPage,
ps: 20,
orderType: orderType.value.type,
);
} }
} }

View File

@@ -4,14 +4,21 @@ import 'package:PiliPlus/common/widgets/refresh_indicator.dart';
import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/http/loading_state.dart';
import 'package:PiliPlus/models/follow/result.dart'; import 'package:PiliPlus/models/follow/result.dart';
import 'package:PiliPlus/pages/follow/child_controller.dart'; import 'package:PiliPlus/pages/follow/child_controller.dart';
import 'package:PiliPlus/pages/follow/controller.dart';
import 'package:PiliPlus/pages/follow/widgets/follow_item.dart'; import 'package:PiliPlus/pages/follow/widgets/follow_item.dart';
import 'package:PiliPlus/utils/utils.dart'; import 'package:PiliPlus/utils/utils.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
class FollowChildPage extends StatefulWidget { class FollowChildPage extends StatefulWidget {
const FollowChildPage({super.key, required this.mid, this.tagid}); const FollowChildPage({
super.key,
required this.controller,
required this.mid,
this.tagid,
});
final FollowController controller;
final int mid; final int mid;
final int? tagid; final int? tagid;
@@ -22,30 +29,50 @@ class FollowChildPage extends StatefulWidget {
class _FollowChildPageState extends State<FollowChildPage> class _FollowChildPageState extends State<FollowChildPage>
with AutomaticKeepAliveClientMixin { with AutomaticKeepAliveClientMixin {
late final _followController = Get.put( late final _followController = Get.put(
FollowChildController(widget.mid, widget.tagid), FollowChildController(widget.controller, widget.mid, widget.tagid),
tag: Utils.generateRandomString(8)); tag: Utils.generateRandomString(8));
late final _isOwner = widget.tagid != null;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); super.build(context);
return refreshIndicator( if (widget.controller.isOwner && widget.tagid == null) {
onRefresh: () async { return Scaffold(
await _followController.onRefresh(); backgroundColor: Colors.transparent,
}, body: _child,
child: CustomScrollView( floatingActionButton: FloatingActionButton.extended(
physics: const AlwaysScrollableScrollPhysics(), onPressed: () {
slivers: [ _followController
SliverPadding( ..orderType.value =
padding: EdgeInsets.only( _followController.orderType.value == OrderType.def
bottom: MediaQuery.paddingOf(context).bottom + 80), ? OrderType.attention
sliver: Obx(() => _buildBody(_followController.loadingState.value)), : OrderType.def
), ..onReload();
], },
), icon: const Icon(Icons.format_list_bulleted, size: 20),
); label: Obx(() => Text(_followController.orderType.value.title)),
),
);
}
return _child;
} }
Widget get _child => refreshIndicator(
onRefresh: () async {
await _followController.onRefresh();
},
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverPadding(
padding: EdgeInsets.only(
bottom: MediaQuery.paddingOf(context).bottom + 80),
sliver:
Obx(() => _buildBody(_followController.loadingState.value)),
),
],
),
);
Widget _buildBody(LoadingState<List<FollowItemModel>?> loadingState) { Widget _buildBody(LoadingState<List<FollowItemModel>?> loadingState) {
return switch (loadingState) { return switch (loadingState) {
Loading() => SliverList.builder( Loading() => SliverList.builder(
@@ -63,7 +90,7 @@ class _FollowChildPageState extends State<FollowChildPage>
} }
return FollowItem( return FollowItem(
item: loadingState.response![index], item: loadingState.response![index],
isOwner: _isOwner, isOwner: widget.controller.isOwner,
callback: (attr) { callback: (attr) {
List<FollowItemModel> list = List<FollowItemModel> list =
(_followController.loadingState.value as Success) (_followController.loadingState.value as Success)
@@ -86,5 +113,5 @@ class _FollowChildPageState extends State<FollowChildPage>
} }
@override @override
bool get wantKeepAlive => widget.tagid != null; bool get wantKeepAlive => widget.controller.tabController != null;
} }

View File

@@ -2,17 +2,17 @@ import 'package:PiliPlus/http/loading_state.dart';
import 'package:PiliPlus/http/member.dart'; import 'package:PiliPlus/http/member.dart';
import 'package:PiliPlus/models/member/tags.dart'; import 'package:PiliPlus/models/member/tags.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:PiliPlus/utils/storage.dart'; import 'package:PiliPlus/utils/storage.dart';
class FollowController extends GetxController class FollowController extends GetxController with GetTickerProviderStateMixin {
with GetSingleTickerProviderStateMixin {
late int mid; late int mid;
String? name; String? name;
late bool isOwner; late bool isOwner;
late final Rx<LoadingState<List<MemberTagItemModel>?>> followState = late final Rx<LoadingState> followState = LoadingState.loading().obs;
LoadingState<List<MemberTagItemModel>?>.loading().obs; late final RxList<MemberTagItemModel> tabs = <MemberTagItemModel>[].obs;
TabController? tabController; TabController? tabController;
@override @override
@@ -33,12 +33,20 @@ class FollowController extends GetxController
Future queryFollowUpTags() async { Future queryFollowUpTags() async {
var res = await MemberHttp.followUpTags(); var res = await MemberHttp.followUpTags();
if (res['status']) { if (res['status']) {
tabs.clear();
tabs.addAll(res['data']);
tabs.insert(0, MemberTagItemModel(name: '全部关注'));
int initialIndex = 0;
if (tabController != null) {
initialIndex = tabController!.index.clamp(0, tabs.length - 1);
tabController!.dispose();
}
tabController = TabController( tabController = TabController(
initialIndex: 0, initialIndex: initialIndex,
length: res['data'].length, length: tabs.length,
vsync: this, vsync: this,
); );
followState.value = LoadingState.success(res['data']); followState.value = LoadingState.success(tabs.hashCode);
} else { } else {
followState.value = LoadingState.error(res['msg']); followState.value = LoadingState.error(res['msg']);
} }
@@ -49,4 +57,37 @@ class FollowController extends GetxController
tabController?.dispose(); tabController?.dispose();
super.onClose(); super.onClose();
} }
Future onCreateTag(String tagName) async {
final res = await MemberHttp.createFollowTag(tagName);
if (res['status']) {
followState.value = LoadingState.loading();
queryFollowUpTags();
SmartDialog.showToast('创建成功');
} else {
SmartDialog.showToast(res['msg']);
}
}
Future onUpdateTag(int index, tagid, String tagName) async {
final res = await MemberHttp.updateFollowTag(tagid, tagName);
if (res['status']) {
tabs[index].name = tagName;
tabs.refresh();
SmartDialog.showToast('修改成功');
} else {
SmartDialog.showToast(res['msg']);
}
}
Future onDelTag(tagid) async {
final res = await MemberHttp.delFollowTag(tagid);
if (res['status']) {
followState.value = LoadingState.loading();
queryFollowUpTags();
SmartDialog.showToast('删除成功');
} else {
SmartDialog.showToast(res['msg']);
}
}
} }

View File

@@ -1,3 +1,4 @@
import 'package:PiliPlus/common/widgets/dialog.dart';
import 'package:PiliPlus/common/widgets/loading_widget.dart'; import 'package:PiliPlus/common/widgets/loading_widget.dart';
import 'package:PiliPlus/common/widgets/scroll_physics.dart'; import 'package:PiliPlus/common/widgets/scroll_physics.dart';
import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/http/loading_state.dart';
@@ -5,6 +6,7 @@ import 'package:PiliPlus/models/member/tags.dart';
import 'package:PiliPlus/pages/follow/child_view.dart'; import 'package:PiliPlus/pages/follow/child_view.dart';
import 'package:PiliPlus/utils/utils.dart'; import 'package:PiliPlus/utils/utils.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'controller.dart'; import 'controller.dart';
@@ -16,8 +18,10 @@ class FollowPage extends StatefulWidget {
} }
class _FollowPageState extends State<FollowPage> { class _FollowPageState extends State<FollowPage> {
final FollowController _followController = final FollowController _followController = Get.put(
Get.put(FollowController(), tag: Utils.generateRandomString(8)); FollowController(),
tag: Utils.generateRandomString(8),
);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -28,6 +32,11 @@ class _FollowPageState extends State<FollowPage> {
), ),
actions: _followController.isOwner actions: _followController.isOwner
? [ ? [
IconButton(
onPressed: _onCreateTag,
icon: const Icon(Icons.add),
tooltip: '新建分组',
),
IconButton( IconButton(
onPressed: () => Get.toNamed( onPressed: () => Get.toNamed(
'/followSearch', '/followSearch',
@@ -60,50 +69,171 @@ class _FollowPageState extends State<FollowPage> {
), ),
body: _followController.isOwner body: _followController.isOwner
? Obx(() => _buildBody(_followController.followState.value)) ? Obx(() => _buildBody(_followController.followState.value))
: FollowChildPage(mid: _followController.mid), : FollowChildPage(
controller: _followController, mid: _followController.mid),
); );
} }
Widget _buildBody(LoadingState<List<MemberTagItemModel>?> loadingState) { bool _isCustomTag(tagid) {
return tagid != null && tagid != 0 && tagid != -10 && tagid != -2;
}
Widget _buildBody(LoadingState loadingState) {
return switch (loadingState) { return switch (loadingState) {
Loading() => loadingWidget, Loading() => loadingWidget,
Success() => loadingState.response?.isNotEmpty == true Success() => Column(
? Column( crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, children: [
children: [ SafeArea(
SafeArea( top: false,
top: false, bottom: false,
bottom: false, child: TabBar(
child: TabBar( isScrollable: true,
isScrollable: true, tabAlignment: TabAlignment.start,
tabAlignment: TabAlignment.start, controller: _followController.tabController,
controller: _followController.tabController, tabs: List.generate(_followController.tabs.length, (index) {
tabs: loadingState.response! return Obx(() {
.map((item) => Tab(text: item.name)) final item = _followController.tabs[index];
.toList(), int? count = item.count;
), if (_isCustomTag(item.tagid)) {
return GestureDetector(
onLongPress: () {
_onHandleTag(index, item);
},
child: Tab(
child: Row(
children: [
Text(
'${item.name}${count != null ? '($count)' : ''} ',
),
Icon(Icons.menu, size: 18),
],
),
),
);
}
return Tab(
text: '${item.name}${count != null ? '($count)' : ''}');
});
}).toList(),
onTap: (value) {
if (!_followController.tabController!.indexIsChanging) {
final item = _followController.tabs[value];
if (_isCustomTag(item.tagid)) {
_onHandleTag(value, item);
}
}
},
),
),
Expanded(
child: Material(
color: Colors.transparent,
child: tabBarView(
controller: _followController.tabController,
children: _followController.tabs
.map(
(item) => FollowChildPage(
controller: _followController,
mid: _followController.mid,
tagid: item.tagid,
),
)
.toList(),
), ),
Expanded( ),
child: Material( ),
color: Colors.transparent, ],
child: tabBarView( ),
controller: _followController.tabController, Error() => FollowChildPage(
children: loadingState.response! controller: _followController, mid: _followController.mid),
.map(
(item) => FollowChildPage(
mid: _followController.mid,
tagid: item.tagid,
),
)
.toList(),
),
),
),
],
)
: FollowChildPage(mid: _followController.mid),
Error() => FollowChildPage(mid: _followController.mid),
_ => throw UnimplementedError(), _ => throw UnimplementedError(),
}; };
} }
void _onHandleTag(int index, MemberTagItemModel item) {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
clipBehavior: Clip.hardEdge,
contentPadding: const EdgeInsets.symmetric(vertical: 12),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
onTap: () {
Get.back();
String tagName = item.name!;
showConfirmDialog(
context: context,
title: '编辑分组名称',
content: TextFormField(
autofocus: true,
initialValue: tagName,
onChanged: (value) => tagName = value,
inputFormatters: [
LengthLimitingTextInputFormatter(16),
],
decoration:
const InputDecoration(border: OutlineInputBorder()),
),
onConfirm: () {
if (tagName.isNotEmpty) {
_followController.onUpdateTag(
index, item.tagid, tagName);
}
},
);
},
dense: true,
title: const Text(
'修改名称',
style: TextStyle(fontSize: 14),
),
),
ListTile(
onTap: () {
Get.back();
showConfirmDialog(
context: context,
title: '删除分组',
content: '删除后,该分组下的用户依旧保留?',
onConfirm: () {
_followController.onDelTag(item.tagid);
},
);
},
dense: true,
title: const Text(
'删除分组',
style: TextStyle(fontSize: 14),
),
),
],
),
);
},
);
}
void _onCreateTag() {
String tagName = '';
showConfirmDialog(
context: context,
title: '新建分组',
content: TextFormField(
autofocus: true,
initialValue: tagName,
onChanged: (value) => tagName = value,
inputFormatters: [
LengthLimitingTextInputFormatter(16),
],
decoration: const InputDecoration(border: OutlineInputBorder()),
),
onConfirm: () {
_followController.onCreateTag(tagName);
},
);
}
} }