Files
daiyongkang 76f266645d Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。
排除 node_modules、构建产物、安装包与 .env 密钥。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 10:04:03 +00:00

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('&nbsp;', ' ')
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'")
.replaceAllMapped(RegExp(r'&#(\d+);'), (m) {
final n = int.tryParse(m.group(1)!);
return n == null ? m.group(0)! : String.fromCharCode(n);
});
}