Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)

含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。
排除 node_modules、构建产物、安装包与 .env 密钥。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-02 10:04:03 +00:00
commit 76f266645d
507 changed files with 99891 additions and 0 deletions
+122
View File
@@ -0,0 +1,122 @@
import 'package:flutter/material.dart';
import '../theme.dart';
/// 微信式语音消息:气泡 + 下方独立白底转写框。
class VoiceMessageBlock extends StatelessWidget {
const VoiceMessageBlock({
super.key,
required this.mine,
required this.seconds,
required this.playing,
this.transcript,
this.loading = false,
this.onTap,
this.onLongPress,
this.bubbleColor,
this.iconColor,
this.readLabel,
this.bottomMargin = 8,
});
final bool mine;
final int seconds;
final bool playing;
final String? transcript;
final bool loading;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final Color? bubbleColor;
final Color? iconColor;
final String? readLabel;
final double bottomMargin;
@override
Widget build(BuildContext context) {
final bubble = mine ? (bubbleColor ?? kBubbleMine) : Colors.white;
final ink = iconColor ?? kInk;
final width = (80 + seconds * 4).clamp(80, 180).toDouble();
return Column(
crossAxisAlignment: mine ? CrossAxisAlignment.end : CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: onTap,
onLongPress: onLongPress,
child: Container(
margin: EdgeInsets.only(bottom: bottomMargin),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
constraints: BoxConstraints(maxWidth: 280, minWidth: width),
decoration: BoxDecoration(
color: bubble,
borderRadius: BorderRadius.circular(6),
),
child: Column(
crossAxisAlignment:
mine ? CrossAxisAlignment.end : CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: width,
child: Row(
mainAxisAlignment:
mine ? MainAxisAlignment.end : MainAxisAlignment.start,
children: [
if (!mine) ...[
Icon(
playing
? Icons.stop_circle_outlined
: Icons.volume_up_outlined,
size: 20,
color: ink,
),
const SizedBox(width: 6),
Text('$seconds″',
style: TextStyle(fontSize: 16, color: ink)),
] else ...[
Text('$seconds″',
style: TextStyle(fontSize: 16, color: ink)),
const SizedBox(width: 6),
Icon(
playing
? Icons.stop_circle_outlined
: Icons.volume_up_outlined,
size: 20,
color: ink,
),
],
],
),
),
if (readLabel != null)
Text(readLabel!,
style: const TextStyle(fontSize: 10, color: kMute)),
],
),
),
),
if (loading) _transcriptBox(const Text('转文字中…', style: TextStyle(fontSize: 14, color: kMute)))
else if (transcript != null && transcript!.isNotEmpty)
_transcriptBox(Text(
transcript!,
style: const TextStyle(fontSize: 15, color: kInk, height: 1.35),
)),
],
);
}
Widget _transcriptBox(Widget child) {
return Container(
margin: EdgeInsets.only(bottom: bottomMargin),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
constraints: const BoxConstraints(maxWidth: 280),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: const Color(0xFFE5E5E5)),
),
child: child,
);
}
}