76f266645d
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
158 lines
5.3 KiB
Dart
158 lines
5.3 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../api/oa_client.dart';
|
|
import '../theme.dart';
|
|
import 'auth_image.dart';
|
|
|
|
/// 公告 HTML:按块渲染标题、段落、列表、加粗,不依赖 flutter_html。
|
|
class HtmlBody extends StatelessWidget {
|
|
const HtmlBody({super.key, required this.html, this.api});
|
|
final String html;
|
|
final OaClient? api;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final blocks = parseHtmlBlocks(html);
|
|
if (blocks.isEmpty) {
|
|
return const Text('暂无正文', style: TextStyle(color: kMute, fontSize: 15));
|
|
}
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
for (final b in blocks) ...[
|
|
if (b.kind == 'img' && b.src != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: _img(b.src!),
|
|
)
|
|
else
|
|
Padding(
|
|
padding: EdgeInsets.only(bottom: b.kind.startsWith('h') ? 10 : 12, left: b.kind == 'li' ? 12 : 0),
|
|
child: Text.rich(
|
|
TextSpan(children: b.spans),
|
|
style: _style(b.kind),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
TextStyle _style(String kind) {
|
|
switch (kind) {
|
|
case 'h1':
|
|
return const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: kInk, height: 1.4);
|
|
case 'h2':
|
|
return const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: kInk, height: 1.4);
|
|
case 'h3':
|
|
return const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: kInk, height: 1.45);
|
|
case 'li':
|
|
return const TextStyle(fontSize: 16, color: kInk, height: 1.7);
|
|
default:
|
|
return const TextStyle(fontSize: 16, color: kInk, height: 1.7);
|
|
}
|
|
}
|
|
|
|
Widget _img(String src) {
|
|
final m = RegExp(r'/files/([0-9a-f-]{36})', caseSensitive: false).firstMatch(src);
|
|
if (m != null && api != null) {
|
|
return ClipRRect(
|
|
borderRadius: BorderRadius.circular(6),
|
|
child: AuthImage(api: api!, fileId: m.group(1)!, fit: BoxFit.contain),
|
|
);
|
|
}
|
|
return const SizedBox.shrink();
|
|
}
|
|
}
|
|
|
|
class HtmlBlock {
|
|
HtmlBlock(this.kind, this.spans, {this.src});
|
|
final String kind;
|
|
final List<InlineSpan> spans;
|
|
final String? src;
|
|
}
|
|
|
|
List<HtmlBlock> parseHtmlBlocks(String raw) {
|
|
var s = raw.trim();
|
|
if (s.isEmpty) return [];
|
|
s = s.replaceAll(RegExp(r'<(script|style)[^>]*>[\s\S]*?</\1>', caseSensitive: false), '');
|
|
if (!RegExp(r'<[a-z][\s\S]*>', caseSensitive: false).hasMatch(s)) {
|
|
return s
|
|
.split(RegExp(r'\n+'))
|
|
.where((e) => e.trim().isNotEmpty)
|
|
.map((e) => HtmlBlock('p', _inline(e.trim())))
|
|
.toList();
|
|
}
|
|
s = s
|
|
.replaceAll(RegExp(r'<br\s*/?>', caseSensitive: false), '\n')
|
|
.replaceAll(RegExp(r'</(p|div|h1|h2|h3|li|tr|blockquote)>', caseSensitive: false), '\n')
|
|
.replaceAll(RegExp(r'<(p|div|blockquote)[^>]*>', caseSensitive: false), '\n')
|
|
.replaceAll(RegExp(r'<h1[^>]*>', caseSensitive: false), '\n#h1#')
|
|
.replaceAll(RegExp(r'<h2[^>]*>', caseSensitive: false), '\n#h2#')
|
|
.replaceAll(RegExp(r'<h3[^>]*>', caseSensitive: false), '\n#h3#')
|
|
.replaceAll(RegExp(r'<li[^>]*>', caseSensitive: false), '\n#li#')
|
|
.replaceAll(RegExp(r'</?(ul|ol)[^>]*>', caseSensitive: false), '\n')
|
|
.replaceAll(RegExp(r'<(strong|b)[^>]*>', caseSensitive: false), '«b»')
|
|
.replaceAll(RegExp(r'</(strong|b)>', caseSensitive: false), '«/b»');
|
|
final imgRe = RegExp(r'<img[^>]*src=["'']([^"'']+)["''][^>]*>', caseSensitive: false);
|
|
s = s.replaceAllMapped(imgRe, (m) => '\n#img#${m.group(1)}#\n');
|
|
s = s.replaceAll(RegExp(r'<[^>]+>'), '');
|
|
s = decodeEntities(s);
|
|
final out = <HtmlBlock>[];
|
|
for (final line in s.split('\n')) {
|
|
final t = line.trim();
|
|
if (t.isEmpty) continue;
|
|
if (t.startsWith('#img#') && t.endsWith('#')) {
|
|
out.add(HtmlBlock('img', const [], src: t.substring(5, t.length - 1)));
|
|
continue;
|
|
}
|
|
var kind = 'p';
|
|
var text = t;
|
|
if (t.startsWith('#h1#')) {
|
|
kind = 'h1';
|
|
text = t.substring(4);
|
|
} else if (t.startsWith('#h2#')) {
|
|
kind = 'h2';
|
|
text = t.substring(4);
|
|
} else if (t.startsWith('#h3#')) {
|
|
kind = 'h3';
|
|
text = t.substring(4);
|
|
} else if (t.startsWith('#li#')) {
|
|
kind = 'li';
|
|
text = '• ${t.substring(4)}';
|
|
}
|
|
text = text.trim();
|
|
if (text.isEmpty) continue;
|
|
out.add(HtmlBlock(kind, _inline(text)));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
List<InlineSpan> _inline(String text) {
|
|
final out = <InlineSpan>[];
|
|
final re = RegExp(r'«b»(.*?)«/b»');
|
|
var i = 0;
|
|
for (final m in re.allMatches(text)) {
|
|
if (m.start > i) out.add(TextSpan(text: text.substring(i, m.start)));
|
|
out.add(TextSpan(text: m.group(1), style: const TextStyle(fontWeight: FontWeight.w700)));
|
|
i = m.end;
|
|
}
|
|
if (i < text.length) out.add(TextSpan(text: text.substring(i)));
|
|
if (out.isEmpty) out.add(TextSpan(text: text));
|
|
return out;
|
|
}
|
|
|
|
String decodeEntities(String s) {
|
|
return s
|
|
.replaceAll(' ', ' ')
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll(''', "'")
|
|
.replaceAllMapped(RegExp(r'&#(\d+);'), (m) {
|
|
final n = int.tryParse(m.group(1)!);
|
|
return n == null ? m.group(0)! : String.fromCharCode(n);
|
|
});
|
|
}
|