您当前的位置: 首页 > 慢生活 > 程序人生 网站首页程序人生
Flutter for Web开发者(解决图片溢出表格的对应行的高度没有自动调整及屏蔽词管理)
发布时间:2026-07-11 22:37:19编辑:雪饮阅读()
-
对应官方文档的链接是
https://docs.flutter.dev/flutter-for/web-devs
其实就是对照html中css的各种布局实现,在flutter的dart中的实现而已。
很好理解的,都有对应示例的。
那么接下来再来完成上篇中没有完成的任务,之前还有残留如:
════════ Exception caught by rendering library ═════════════════════════════════
The following assertion was thrown during layout:
A RenderFlex overflowed by 74 pixels on the bottom.
这是因为之前search_result_table.dart我们的listView设置了固定高度,当窗口变小的时候,最外层都变小了,固定高度就会溢出其父容器。
之前我是这样写的:
Container(
height: 400, // ← 这是罪魁祸首!
child: ListView.builder(...)
)
那我现在修改为这样:
Flexible(
child: ListView.builder(
itemCount: _searchResults.length,
itemBuilder: (context, index) {
final item = _searchResults[index];
final title = item['title']?.toString() ?? '';
final imageBytes = _imageDataUris[index];
return Row(
children: [
Expanded(
flex: 2,
child: _buildGridCell(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(title),
),
rightBorder: true,
bottomBorder: true,
),
),
Expanded(
flex: 2,
child: _buildGridCell(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: imageBytes != null
? Image.memory(
imageBytes,
fit: BoxFit.contain,
width: double.infinity,
height: 120,
)
: const Center(child: Text('加载中...')),
),
rightBorder: true,
bottomBorder: true,
),
),
Expanded(
child: _buildGridCell(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ElevatedButton(
style: ElevatedButton.styleFrom(
minimumSize: Size.zero,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () {
debugPrint('详情按钮被点击');
},
child: const Text('详情'),
),
),
bottomBorder: true,
),
),
],
);
},
),
),
接下来需要处理的是,我们昨天渲染图片之后可能发现有图片之后表格的边框会消失,规律在左边框消失,实际情况应该是图片所在单元格的左侧标题单元格没有因为图片单元格的高度撑开,而自动撑开。
这个问题以及接下来的屏蔽词管理,以及表格优化后的完整search_result_table.dart:
import 'dart:convert' show jsonEncode, jsonDecode;
import 'dart:typed_data';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:http/io_client.dart';
import 'blocked_keywords_button.dart';
class SearchResultTable extends StatefulWidget {
const SearchResultTable({super.key});
@override
State<SearchResultTable> createState() => _SearchResultTableState();
}
class _SearchResultTableState extends State<SearchResultTable> {
final TextEditingController _apiController = TextEditingController(
text: "https://search-server.xoczl.net/Search/Social/Query",
);
final TextEditingController _keywordController = TextEditingController(
text: "搭讪",
);
final TextEditingController authorizationController = TextEditingController(
text:
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoi5ri45a6iLTEiLCJpZCI6IjI0MDAwMDAwMDAyIiwiaXAiOiI1Mi44OC4xNjkuMjUzIiwidHlwZSI6MywicGFja2FnZV9pZCI6MSwibGV2ZWwiOjEsImlzcyI6IkdVRVNUIiwic3ViIjoic29jaWFsLXNlcnZlciIsImV4cCI6MTc4NDIxMjU3MCwibmJmIjoxNzgzNjA3NzcwLCJpYXQiOjE3ODM2MDc3NzAsImp0aSI6ImUwMTdmOWI2MjlmNDRjMmU5YTMxMDlkOWZkNWYzMDEyIn0.J1cuFSnZ0Xi4eE0sA-aF_c3LcN_YttNqzipvETBKPl0",
);
List<dynamic> _searchResults = [];
int _resultCount = 0;
final Map<int, Uint8List> _imageDataUris = {};
List<String> _blockedKeywords = [];
void _onBlockedKeywordsChanged(List<String> keywords) {
_blockedKeywords = keywords;
}
@override
void dispose() {
_apiController.dispose();
_keywordController.dispose();
authorizationController.dispose();
super.dispose();
}
Widget _buildGridCell({
required Widget child,
bool rightBorder = false,
bool bottomBorder = false,
bool topBorder = false,
}) {
return Container(
decoration: BoxDecoration(
border: Border(
right: rightBorder ? const BorderSide() : BorderSide.none,
bottom: bottomBorder ? const BorderSide() : BorderSide.none,
top: topBorder ? const BorderSide() : BorderSide.none,
),
),
child: child,
);
}
Widget _buildHeaderCell(String text) {
return Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(text, style: const TextStyle(fontWeight: FontWeight.bold)),
),
);
}
Future<void> _fetchImagesForResults(
List<dynamic> list,
String apiUrl,
String auth,
) async {
for (int i = 0; i < list.length; i++) {
final item = list[i];
final covers = item['covers'];
if (covers != null && covers is List && covers.isNotEmpty) {
final resource = covers[0]['resource'];
final file = covers[0]['file'];
if (file == null) {
continue;
}
if (file != null) {
final hash = file['hash']?.toString() ?? '';
if (hash.isNotEmpty) {
if (hash != "2bf933b436b05c58") {
// continue;
}
}
}
if (resource != null) {
final path = resource['path']?.toString() ?? '';
final name = resource['name']?.toString() ?? '';
if (path.isNotEmpty && name.isNotEmpty) {
final imageUrl = 'https://media-server.ehgyz.com' + '$path/$name';
debugPrint('图片[$i] 请求URL: $imageUrl');
try {
final imageResponse = await http.get(
Uri.parse(imageUrl),
headers: {
'referer': 'https://xn--10jong-2o7io6t.30gnao.space/',
'origin': 'https://xn--10jong-2o7io6t.30gnao.space',
'x-bundle-info': '1, com.xingba.web.online',
'authorization': auth,
},
);
if (imageResponse.statusCode == 200) {
final contentType = imageResponse.headers['content-type'] ?? '';
final imageBytes = imageResponse.bodyBytes;
debugPrint(
'图片[$i] content-type: $contentType, 大小: ${imageBytes.length} bytes',
);
if (!contentType.startsWith('image/')) {
// 服务器返回的不是图片,打印前200字符看看实际返回了什么
final bodyText = String.fromCharCodes(
imageBytes.length > 200
? imageBytes.sublist(0, 200)
: imageBytes,
);
debugPrint('图片[$i] 非图片响应: $bodyText');
return;
}
// 验证图片数据是否有效(检查文件头)
if (imageBytes.length < 32) {
debugPrint('图片[$i] 数据太小: ${imageBytes.length} bytes');
return;
}
// 去除前16字节和后16字节(与JS端解密逻辑一致)
final trimmedBytes = imageBytes.sublist(
16,
imageBytes.length - 16,
);
debugPrint('图片[$i] 裁剪后大小: ${trimmedBytes.length} bytes');
if (mounted) {
setState(() {
_imageDataUris[i] = trimmedBytes;
});
}
} else {
debugPrint('图片[$i] 请求失败, 状态码: ${imageResponse.statusCode}');
}
} catch (e) {
debugPrint('图片[$i]请求失败: $e');
}
}
}
}
}
}
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(border: Border.all()),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
Expanded(
child: TextField(
controller: _apiController,
decoration: const InputDecoration(
hintText: '请输入api地址',
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _keywordController,
decoration: const InputDecoration(
hintText: '请输入关键词',
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: authorizationController,
decoration: const InputDecoration(
hintText: '请输入authorization',
border: OutlineInputBorder(),
),
),
),
],
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
//搜索
ElevatedButton(
onPressed: () async {
if (_apiController.text.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('请输入api地址')));
return;
}
if (_keywordController.text.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('请输入关键词')));
return;
}
debugPrint('屏蔽关键词列表: $_blockedKeywords');
/*
发起http的post请求
请求头需要包含authorization
请求头content-type的值为application/json
请求的json对象格式为:
var jsonObject=[{"name":"social.post","limit":100,"page":1,"or":[{"field":"title","op":"$like","value":"%kasumi"},{"field":"tags","op":"$in","value":["kasumi"]}],"and":[{"field":"categories_id","op":"$nin","value":["137","257","347","264","764","931","97","101","365","534","115","361","335","389","336","425","367","456","930","85","2","382","78","252","154","1108","917","416","344","1107","337","74","99","281","93","230","454","166","175","397","452","414","285","928","766","1009","288","113","322","49","1106","426","81","758","332","104","88","364","1011","333","57","240","695","86","396","677","41","265","959","102","56","358","221","301","929","242","187","224","789","340","349","55","231","402","105","169","461","346","50","182","210","676","48","292","95","413","131","334","35","348","310","455","43","246","77","401","330","293","355","84","161","23","312","1012","359","771","87","124","163","395","1214","123","927","926","388","39","934","202","274","453","214","286","932","375","405","212","404","38","7","46","40","363","268","1110","75","350","262","218","394","92","37","247","128","675","933"]},{"field":"status","op":"$in","value":[1,9]}],"sort":[{"field":"create_time","order":"DESC"}]}];
这个json对象中仅仅用输入的关键词替换这个对象中的kasumi
*/
final keyword = _keywordController.text;
final apiUrl = _apiController.text;
final auth = authorizationController.text;
final requestBody = [
{
"name": "social.post",
"limit": 10,
"page": 1,
"or": [
{
"field": "title",
"op": "\$like",
"value": "%$keyword",
},
{
"field": "tags",
"op": "\$in",
"value": [keyword],
},
],
"and": [
{
"field": "categories_id",
"op": "\$nin",
"value": [
"137",
"257",
"347",
"264",
"764",
"931",
"97",
"101",
"365",
"534",
"115",
"361",
"335",
"389",
"336",
"425",
"367",
"456",
"930",
"85",
"2",
"382",
"78",
"252",
"154",
"1108",
"917",
"416",
"344",
"1107",
"337",
"74",
"99",
"281",
"93",
"230",
"454",
"166",
"175",
"397",
"452",
"414",
"285",
"928",
"766",
"1009",
"288",
"113",
"322",
"49",
"1106",
"426",
"81",
"758",
"332",
"104",
"88",
"364",
"1011",
"333",
"57",
"240",
"695",
"86",
"396",
"677",
"41",
"265",
"959",
"102",
"56",
"358",
"221",
"301",
"929",
"242",
"187",
"224",
"789",
"340",
"349",
"55",
"231",
"402",
"105",
"169",
"461",
"346",
"50",
"182",
"210",
"676",
"48",
"292",
"95",
"413",
"131",
"334",
"35",
"348",
"310",
"455",
"43",
"246",
"77",
"401",
"330",
"293",
"355",
"84",
"161",
"23",
"312",
"1012",
"359",
"771",
"87",
"124",
"163",
"395",
"1214",
"123",
"927",
"926",
"388",
"39",
"934",
"202",
"274",
"453",
"214",
"286",
"932",
"375",
"405",
"212",
"404",
"38",
"7",
"46",
"40",
"363",
"268",
"1110",
"75",
"350",
"262",
"218",
"394",
"92",
"37",
"247",
"128",
"675",
"933",
],
},
{
"field": "status",
"op": "\$in",
"value": [1, 9],
},
],
"sort": [
{"field": "create_time", "order": "DESC"},
],
},
];
try {
final response = await http.post(
Uri.parse(apiUrl),
headers: {
'authorization': auth,
'Content-Type': 'application/json',
'origin': 'https://xn--10jong-2o7io6t.30gnao.space',
'X-Bundle-Info': '1, com.xingba.web.online',
},
body: jsonEncode(requestBody),
);
if (response.statusCode == 200) {
debugPrint('请求成功, 响应: ${response.body}');
/*
响应体中
msg[0].count 为搜索结果总数
msg[0].list[x].title 为搜索结果中每个条目的标题
关于搜索结果中每个条目的图片
图片请求api
var imageApiUrl=msg[0].list[x].covers[0].resource.path+"/"+ msg[0].list[x].covers[0].resource.name;
图片请求头 x-bundle-info 为 1, com.xingba.web.online
图片请求成功后的请求结果中将响应体用类似JavaScript那种URL.createObjectURL(s)转换为图片url
var imageUrl=URL.createObjectURL(s);
然后在控制台中打印请求成功后的经过类似URL.createObjectURL处理过后的URL地址
*/
final responseBody = jsonDecode(response.body);
final msg = responseBody['msg'];
final count = msg[0]['count'] as int;
final list = msg[0]['list'] as List<dynamic>;
setState(() {
_resultCount = count;
_searchResults = list;
_imageDataUris.clear();
});
_fetchImagesForResults(list, apiUrl, auth);
} else {
debugPrint(
'请求失败, 状态码: ${response.statusCode}, 响应: ${response.body}',
);
}
} catch (e) {
debugPrint('请求异常: $e');
}
debugPrint(
'搜索按钮被点击, api: ${_apiController.text}, 关键词: ${_keywordController.text}',
);
},
child: const Text('搜索'),
),
const SizedBox(width: 8),
BlockedKeywordsButton(onChanged: _onBlockedKeywordsChanged),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Row(
children: [
const Text(
'搜索结果',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
],
),
),
const Divider(),
Row(
children: [
Expanded(
flex: 2,
child: _buildGridCell(
child: _buildHeaderCell('标题'),
rightBorder: true,
bottomBorder: true,
topBorder: true,
),
),
Expanded(
flex: 2,
child: _buildGridCell(
child: _buildHeaderCell('图片'),
rightBorder: true,
bottomBorder: true,
topBorder: true,
),
),
Expanded(
child: _buildGridCell(
child: _buildHeaderCell('操作'),
bottomBorder: true,
topBorder: true,
),
),
],
),
Flexible(
child: ListView.builder(
itemCount: _searchResults.length,
itemBuilder: (context, index) {
final item = _searchResults[index];
final title = item['title']?.toString() ?? '';
final imageBytes = _imageDataUris[index];
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
flex: 2,
child: _buildGridCell(
child: Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(title),
),
),
rightBorder: true,
bottomBorder: true,
),
),
Expanded(
flex: 2,
child: _buildGridCell(
child: Center(
child: Padding(
padding: const EdgeInsets.all(4.0),
child: imageBytes != null
? Image.memory(
imageBytes,
fit: BoxFit.contain,
height: 80,
)
: const Text('加载中...'),
),
),
rightBorder: true,
bottomBorder: true,
),
),
Expanded(
child: _buildGridCell(
child: Center(
child: ElevatedButton(
style: ElevatedButton.styleFrom(
minimumSize: const Size(60, 32),
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () {
debugPrint('详情按钮被点击');
},
child: const Text('详情'),
),
),
bottomBorder: true,
),
),
],
),
);
},
),
),
Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'共$_resultCount条记录,共${(_resultCount / 100).ceil()}页',
),
const SizedBox(width: 16),
ElevatedButton(
style: ElevatedButton.styleFrom(
minimumSize: Size.zero,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () {
debugPrint('下一页按钮被点击');
},
child: const Text('下一页'),
),
const SizedBox(width: 8),
ElevatedButton(
style: ElevatedButton.styleFrom(
minimumSize: Size.zero,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () {
debugPrint('上一页按钮被点击');
},
child: const Text('上一页'),
),
],
),
),
),
],
),
],
),
);
}
}
这里我自然是有新增一个屏蔽词管理的独立dart,blocked_keywords_button.dart:
import 'package:flutter/material.dart';
class BlockedKeywordsButton extends StatefulWidget {
final void Function(List<String> blockedKeywords)? onChanged;
const BlockedKeywordsButton({super.key, this.onChanged});
@override
State<BlockedKeywordsButton> createState() => _BlockedKeywordsButtonState();
}
class _BlockedKeywordsButtonState extends State<BlockedKeywordsButton> {
final List<String> _blockedKeywords = [];
void _notifyParent() {
widget.onChanged?.call(List<String>.from(_blockedKeywords));
}
void _showBlockedKeywordsDialog() {
final TextEditingController addController = TextEditingController();
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: const Text('屏蔽关键词管理'),
content: SizedBox(
width: 400,
height: 400,
child: Column(
children: [
Row(
children: [
Expanded(
child: TextField(
controller: addController,
decoration: const InputDecoration(
hintText: '输入屏蔽关键词',
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () {
if (addController.text.isNotEmpty) {
setState(() {
_blockedKeywords.add(addController.text);
});
setDialogState(() {});
addController.clear();
_notifyParent();
}
},
child: const Text('新增'),
),
],
),
const SizedBox(height: 16),
Expanded(
child: _blockedKeywords.isEmpty
? const Center(child: Text('暂无屏蔽关键词'))
: ListView.builder(
itemCount: _blockedKeywords.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(_blockedKeywords[index]),
trailing: IconButton(
icon: const Icon(
Icons.delete,
color: Colors.red,
),
onPressed: () {
setState(() {
_blockedKeywords.removeAt(index);
});
setDialogState(() {});
_notifyParent();
},
),
);
},
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('关闭'),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: _showBlockedKeywordsDialog,
child: const Text('屏蔽关键词管理'),
);
}
}
现在就差疲敝关键词的后端php端没有完成了。
关键字词:flutter,web,图片,溢出,行,高,自动,调整,屏蔽词