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
+85
View File
@@ -0,0 +1,85 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../theme.dart';
class AuthImage extends StatefulWidget {
const AuthImage({
super.key,
required this.api,
required this.fileId,
this.width,
this.height,
this.fit = BoxFit.cover,
this.placeholder,
});
final OaClient api;
final String fileId;
final double? width;
final double? height;
final BoxFit fit;
final Widget? placeholder;
@override
State<AuthImage> createState() => _AuthImageState();
}
class _AuthImageState extends State<AuthImage> {
Uint8List? _bytes;
bool _fail = false;
@override
void initState() {
super.initState();
_bytes = widget.api.peekFile(widget.fileId);
if (_bytes == null) _load();
}
@override
void didUpdateWidget(covariant AuthImage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.fileId != widget.fileId) {
_bytes = widget.api.peekFile(widget.fileId);
_fail = false;
if (_bytes == null) _load();
}
}
Future<void> _load() async {
try {
final b = await widget.api.fileBytes(widget.fileId);
if (!mounted) return;
setState(() {
_bytes = b;
_fail = b == null;
});
} catch (_) {
if (mounted) setState(() => _fail = true);
}
}
@override
Widget build(BuildContext context) {
if (_bytes != null) {
return Image.memory(
_bytes!,
width: widget.width,
height: widget.height,
fit: widget.fit,
gaplessPlayback: true,
filterQuality: FilterQuality.medium,
);
}
return SizedBox(
width: widget.width,
height: widget.height,
child: widget.placeholder ??
ColoredBox(
color: const Color(0xFFEDEDED),
child: _fail ? const Icon(Icons.broken_image_outlined, color: kMute, size: 18) : const SizedBox.shrink(),
),
);
}
}
+43
View File
@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import '../app_config.dart';
import '../device/device_bridge.dart';
import '../theme.dart';
class BeianFooter extends StatelessWidget {
const BeianFooter({super.key});
Future<void> _open(String url) async {
try {
await DeviceBridge.openUrl(url);
} catch (_) {}
}
@override
Widget build(BuildContext context) {
const style = TextStyle(color: kMute, fontSize: 11, height: 1.6);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Column(
children: [
const Text(AppConfig.company, textAlign: TextAlign.center, style: style),
Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
GestureDetector(
onTap: () => _open(AppConfig.icpUrl),
child: const Text(AppConfig.icp, style: style),
),
const Text(' | ', style: style),
GestureDetector(
onTap: () => _open('tel:${AppConfig.phone}'),
child: const Text('联系电话 ${AppConfig.phone}', style: style),
),
],
),
],
),
);
}
}
+123
View File
@@ -0,0 +1,123 @@
import 'package:flutter/material.dart';
import '../theme.dart';
import '../labels.dart';
/// 与 Web `ApplyBucketBar` 对齐:Segmented + 选中态。
class BucketBar extends StatelessWidget {
const BucketBar({super.key, required this.value, required this.onChanged, this.extra = const []});
final String value;
final ValueChanged<String> onChanged;
final List<(String, String)> extra;
@override
Widget build(BuildContext context) {
final items = <(String, String)>[
('', '全部'),
('pending', '待审批'),
('approved', '已通过'),
('rejected', '已驳回'),
...extra,
];
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Row(
children: [
for (final it in items)
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(it.$2),
selected: value == it.$1,
showCheckmark: false,
visualDensity: VisualDensity.compact,
selectedColor: kBind.withValues(alpha: 0.16),
onSelected: (_) => onChanged(it.$1),
),
),
],
),
);
}
}
class EmptyHint extends StatelessWidget {
const EmptyHint(this.text, {super.key});
final String text;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Text(text, style: const TextStyle(color: kMute)),
),
);
}
}
class KvTile extends StatelessWidget {
const KvTile({super.key, required this.title, this.subtitle, this.trailing, this.onTap, this.badge, this.status});
final String title;
final String? subtitle;
final Widget? trailing;
final VoidCallback? onTap;
final int? badge;
final dynamic status;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.white,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 12, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Container(
padding: const EdgeInsets.only(bottom: 12),
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: kLine, width: 0.5))),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 16, color: kInk)),
if (subtitle != null && subtitle!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(subtitle!, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13, color: kMute)),
),
],
),
),
),
const SizedBox(width: 8),
Padding(
padding: const EdgeInsets.only(top: 2),
child: Column(
children: [
if (status != null) StatusDot(status),
if (badge != null && badge! > 0)
Padding(
padding: const EdgeInsets.only(top: 6),
child: CircleAvatar(
radius: 10,
backgroundColor: kDanger,
child: Text('$badge', style: const TextStyle(color: Colors.white, fontSize: 10)),
),
),
if (trailing != null) trailing!,
if (onTap != null) const Icon(Icons.chevron_right, color: Color(0xFFC0C0C0), size: 20),
],
),
),
],
),
),
),
);
}
}
+411
View File
@@ -0,0 +1,411 @@
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../session/session.dart';
import '../theme.dart';
import 'wecom.dart';
const kDeskBg = Color(0xFFEDEDED);
const kDeskSide = Color(0xFFF7F7F7);
const kDeskPane = Colors.white;
const kDeskActive = Color(0xFFE8F3FF);
class DesktopSidebar extends StatelessWidget {
const DesktopSidebar({
super.key,
required this.session,
required this.api,
required this.index,
required this.onChanged,
required this.items,
});
final SessionStore session;
final OaClient api;
final int index;
final ValueChanged<int> onChanged;
final List<(IconData, IconData, String, int)> items;
@override
Widget build(BuildContext context) {
return ColoredBox(
color: kDeskSide,
child: SafeArea(
child: SizedBox(
width: 210,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 10),
child: Row(
children: [
SquareAvatar(
label: session.displayName,
size: 36,
fileId: '${session.user['avatarFileId'] ?? ''}',
api: api,
),
const SizedBox(width: 10),
Expanded(
child: Text(
session.displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: kInk),
),
),
],
),
),
const Divider(height: 1, color: kLine),
const SizedBox(height: 6),
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 10),
children: [
for (var i = 0; i < items.length; i++)
_NavItem(
active: index == i,
icon: index == i ? items[i].$2 : items[i].$1,
label: items[i].$3,
badge: items[i].$4,
onTap: () => onChanged(i),
),
],
),
),
],
),
),
),
);
}
}
class _NavItem extends StatelessWidget {
const _NavItem({
required this.active,
required this.icon,
required this.label,
required this.badge,
required this.onTap,
});
final bool active;
final IconData icon;
final String label;
final int badge;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Material(
color: active ? kDeskActive : Colors.transparent,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
Stack(
clipBehavior: Clip.none,
children: [
Icon(icon, size: 20, color: active ? kBind : const Color(0xFF646464)),
if (badge > 0)
Positioned(right: -8, top: -4, child: UnreadBadge(badge)),
],
),
const SizedBox(width: 10),
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: 14,
color: active ? kBind : const Color(0xFF323232),
fontWeight: active ? FontWeight.w600 : FontWeight.w400,
),
),
),
],
),
),
),
),
);
}
}
class DesktopPaneHeader extends StatelessWidget {
const DesktopPaneHeader({
super.key,
required this.title,
this.actions = const [],
this.leading,
this.bottom,
});
final String title;
final List<Widget> actions;
final Widget? leading;
final Widget? bottom;
@override
Widget build(BuildContext context) {
return ColoredBox(
color: kDeskPane,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: 52,
child: Row(
children: [
if (leading != null) leading!,
Padding(
padding: EdgeInsets.only(left: leading == null ? 16 : 4),
child: Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: kInk)),
),
const Spacer(),
...actions,
const SizedBox(width: 8),
],
),
),
if (bottom != null) bottom!,
const Divider(height: 1, color: kLine),
],
),
);
}
}
class DesktopSearchBar extends StatelessWidget {
const DesktopSearchBar({super.key, required this.hint, this.onChanged, this.controller});
final String hint;
final ValueChanged<String>? onChanged;
final TextEditingController? controller;
@override
Widget build(BuildContext context) {
return Container(
height: 34,
decoration: BoxDecoration(color: const Color(0xFFF3F3F3), borderRadius: BorderRadius.circular(6)),
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Row(
children: [
const Icon(Icons.search, size: 18, color: kMute),
const SizedBox(width: 6),
Expanded(
child: TextField(
controller: controller,
onChanged: onChanged,
style: const TextStyle(fontSize: 14),
decoration: InputDecoration(
hintText: hint,
isDense: true,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: EdgeInsets.zero,
hintStyle: const TextStyle(color: Color(0xFFB2B2B2), fontSize: 14),
),
),
),
],
),
);
}
}
class DesktopAppCard extends StatelessWidget {
const DesktopAppCard({
super.key,
required this.label,
required this.desc,
required this.icon,
required this.color,
required this.onTap,
});
final String label;
final String desc;
final IconData icon;
final Color color;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(10)),
child: Icon(icon, color: Colors.white, size: 22),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: kInk)),
const SizedBox(height: 4),
Text(desc, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: kMute, height: 1.35)),
],
),
),
],
),
),
),
);
}
}
class DesktopEmptyPane extends StatelessWidget {
const DesktopEmptyPane({super.key, this.hint = '选择左侧会话开始聊天'});
final String hint;
@override
Widget build(BuildContext context) {
return ColoredBox(
color: const Color(0xFFF5F5F5),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Opacity(
opacity: 0.12,
child: Container(
width: 120,
height: 120,
decoration: BoxDecoration(color: kBind, borderRadius: BorderRadius.circular(28)),
child: const Icon(Icons.apartment, size: 64, color: Colors.white),
),
),
const SizedBox(height: 20),
Text(hint, style: const TextStyle(color: kMute, fontSize: 14)),
],
),
),
);
}
}
class DesktopTabBar extends StatelessWidget {
const DesktopTabBar({
super.key,
required this.tabs,
required this.active,
required this.onSelect,
required this.onClose,
});
final List<(String key, String title, IconData icon, Color color)> tabs;
final int active;
final ValueChanged<int> onSelect;
final ValueChanged<int> onClose;
@override
Widget build(BuildContext context) {
return ColoredBox(
color: kDeskPane,
child: Column(
children: [
SizedBox(
height: 44,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8),
children: [
for (var i = 0; i < tabs.length; i++)
_TabChip(
title: tabs[i].$2,
icon: tabs[i].$3,
color: tabs[i].$4,
active: active == i,
closable: i > 0,
onTap: () => onSelect(i),
onClose: () => onClose(i),
),
],
),
),
const Divider(height: 1, color: kLine),
],
),
);
}
}
class _TabChip extends StatelessWidget {
const _TabChip({
required this.title,
required this.icon,
required this.color,
required this.active,
required this.closable,
required this.onTap,
required this.onClose,
});
final String title;
final IconData icon;
final Color color;
final bool active;
final bool closable;
final VoidCallback onTap;
final VoidCallback onClose;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(right: 4, top: 6, bottom: 6),
child: Material(
color: active ? const Color(0xFFF3F6FB) : Colors.transparent,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: EdgeInsets.fromLTRB(10, 6, closable ? 4 : 10, 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 6),
Text(title, style: TextStyle(fontSize: 13, color: active ? kInk : kMute, fontWeight: active ? FontWeight.w600 : FontWeight.w400)),
if (closable) ...[
const SizedBox(width: 4),
InkWell(
onTap: onClose,
borderRadius: BorderRadius.circular(10),
child: const Padding(
padding: EdgeInsets.all(2),
child: Icon(Icons.close, size: 14, color: kMute),
),
),
],
],
),
),
),
),
);
}
}
+157
View File
@@ -0,0 +1,157 @@
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);
});
}
@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../device/device_bridge.dart';
import '../theme.dart';
import 'tencent_map_thumb.dart';
class LocationViewPage extends StatelessWidget {
const LocationViewPage({
super.key,
required this.api,
required this.title,
required this.address,
this.lat,
this.lng,
});
final OaClient api;
final String title;
final String address;
final double? lat;
final double? lng;
Future<void> _openMaps(BuildContext context) async {
if (lat == null || lng == null) return;
final la = lat!;
final ln = lng!;
final q = Uri.encodeComponent(title.isNotEmpty ? title : '$la,$ln');
final urls = [
'https://uri.amap.com/marker?position=$ln,$la&name=$q',
'geo:$la,$ln?q=$la,$ln($q)',
];
for (final u in urls) {
try {
await DeviceBridge.openUrl(u);
return;
} catch (_) {}
}
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('无法打开地图')));
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(title: const Text('位置')),
body: ListView(
children: [
if (lat != null && lng != null)
TencentMapThumb(api: api, lat: lat!, lng: lng!, width: MediaQuery.sizeOf(context).width, height: 220),
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(title.isEmpty ? '位置' : title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
),
if (address.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Text(address, style: const TextStyle(fontSize: 14, color: kMute)),
),
Padding(
padding: const EdgeInsets.all(16),
child: FilledButton.icon(
onPressed: lat == null ? null : () => _openMaps(context),
icon: const Icon(Icons.map_outlined),
label: const Text('用地图打开'),
),
),
],
),
);
}
}
@@ -0,0 +1,65 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../theme.dart';
class TencentMapThumb extends StatefulWidget {
const TencentMapThumb({
super.key,
required this.api,
required this.lat,
required this.lng,
this.height = 110,
this.width,
});
final OaClient api;
final double lat;
final double lng;
final double height;
final double? width;
@override
State<TencentMapThumb> createState() => _TencentMapThumbState();
}
class _TencentMapThumbState extends State<TencentMapThumb> {
Uint8List? _bytes;
@override
void initState() {
super.initState();
_load();
}
@override
void didUpdateWidget(covariant TencentMapThumb oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.lat != widget.lat || oldWidget.lng != widget.lng) _load();
}
Future<void> _load() async {
final b = await widget.api.getBytes('/office/geo/static-map', query: {
'lat': '${widget.lat}',
'lng': '${widget.lng}',
});
if (!mounted) return;
setState(() => _bytes = b);
}
@override
Widget build(BuildContext context) {
if (_bytes != null) {
return Image.memory(_bytes!, width: widget.width, height: widget.height, fit: BoxFit.cover);
}
return SizedBox(
width: widget.width,
height: widget.height,
child: const ColoredBox(
color: Color(0xFFE8F5E9),
child: Center(child: Icon(Icons.place, color: kWeGreen, size: 32)),
),
);
}
}
+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,
);
}
}
+540
View File
@@ -0,0 +1,540 @@
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../theme.dart';
import '../widgets/auth_image.dart';
class WxHeader extends StatelessWidget {
const WxHeader({super.key, required this.title, this.leading, this.actions = const []});
final String title;
final Widget? leading;
final List<Widget> actions;
@override
Widget build(BuildContext context) {
return ColoredBox(
color: kHeader,
child: SafeArea(
bottom: false,
child: SizedBox(
height: 48,
child: Row(
children: [
SizedBox(width: 48, child: leading ?? const SizedBox.shrink()),
Expanded(
child: Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: kInk),
),
),
SizedBox(
width: actions.isEmpty ? 48 : null,
child: Row(mainAxisAlignment: MainAxisAlignment.end, children: actions),
),
],
),
),
),
);
}
}
class WxSearchBar extends StatelessWidget {
const WxSearchBar({super.key, this.hint = '搜索', this.onChanged, this.onTap, this.readonly = false});
final String hint;
final ValueChanged<String>? onChanged;
final VoidCallback? onTap;
final bool readonly;
@override
Widget build(BuildContext context) {
final field = Container(
height: 36,
decoration: BoxDecoration(color: const Color(0xFFF3F3F3), borderRadius: BorderRadius.circular(6)),
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Row(
children: [
const Icon(Icons.search, size: 18, color: kMute),
const SizedBox(width: 6),
Expanded(
child: readonly
? Text(hint, style: const TextStyle(color: Color(0xFFB2B2B2), fontSize: 15))
: TextField(
onChanged: onChanged,
decoration: InputDecoration(
hintText: hint,
isDense: true,
filled: true,
fillColor: Colors.transparent,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: EdgeInsets.zero,
),
),
),
],
),
);
return ColoredBox(
color: Colors.white,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 10),
child: onTap == null ? field : GestureDetector(onTap: onTap, child: field),
),
);
}
}
class SquareAvatar extends StatelessWidget {
const SquareAvatar({super.key, required this.label, this.size = 44, this.color, this.icon, this.fileId, this.api});
final String label;
final double size;
final Color? color;
final IconData? icon;
final String? fileId;
final OaClient? api;
@override
Widget build(BuildContext context) {
final bg = color ?? _colorOf(label);
final id = (fileId ?? '').trim();
return Container(
width: size,
height: size,
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(6)),
alignment: Alignment.center,
clipBehavior: Clip.antiAlias,
child: id.isNotEmpty && api != null
? AuthImage(api: api!, fileId: id, width: size, height: size, fit: BoxFit.cover)
: icon != null
? Icon(icon, color: Colors.white, size: size * 0.5)
: Text(
label.isEmpty ? '?' : String.fromCharCodes(label.runes.take(1)),
style: TextStyle(color: Colors.white, fontSize: size * 0.38, fontWeight: FontWeight.w600),
),
);
}
}
Color _colorOf(String s) {
const palette = [
Color(0xFF267EF0),
Color(0xFF07C160),
Color(0xFFFA9D3B),
Color(0xFF6267F2),
Color(0xFF10AEFF),
Color(0xFFE75D5D),
Color(0xFF00B578),
Color(0xFF8B5CF6),
];
return palette[s.hashCode.abs() % palette.length];
}
class UnreadBadge extends StatelessWidget {
const UnreadBadge(this.count, {super.key, this.dot = false});
final int count;
final bool dot;
@override
Widget build(BuildContext context) {
if (count <= 0 && !dot) return const SizedBox.shrink();
if (dot || count <= 0) {
return Container(width: 8, height: 8, decoration: const BoxDecoration(color: kDanger, shape: BoxShape.circle));
}
final text = count > 99 ? '99+' : '$count';
return Container(
constraints: const BoxConstraints(minWidth: 18, minHeight: 18),
padding: const EdgeInsets.symmetric(horizontal: 5),
decoration: BoxDecoration(color: kDanger, borderRadius: BorderRadius.circular(9)),
alignment: Alignment.center,
child: Text(text, style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w600)),
);
}
}
class ConvTile extends StatelessWidget {
const ConvTile({
super.key,
required this.title,
this.preview,
this.time,
this.unread = 0,
this.muted = false,
this.avatarLabel,
this.avatarColor,
this.avatarIcon,
this.avatarFileId,
this.api,
this.onTap,
this.selected = false,
});
final String title;
final String? preview;
final String? time;
final int unread;
final bool muted;
final String? avatarLabel;
final Color? avatarColor;
final IconData? avatarIcon;
final String? avatarFileId;
final OaClient? api;
final VoidCallback? onTap;
final bool selected;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Container(
color: selected ? const Color(0xFFE8F3FF) : Colors.white,
padding: const EdgeInsets.fromLTRB(12, 10, 12, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SquareAvatar(label: avatarLabel ?? title, color: avatarColor, icon: avatarIcon, fileId: avatarFileId, api: api),
const SizedBox(width: 10),
Expanded(
child: Container(
padding: const EdgeInsets.only(bottom: 10),
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: kLine, width: 0.5))),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 16, color: kInk)),
),
if (time != null && time!.isNotEmpty)
Text(time!, style: const TextStyle(fontSize: 12, color: Color(0xFFB2B2B2))),
],
),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: Text(
preview ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13, color: kMute),
),
),
if (muted) const Padding(padding: EdgeInsets.only(left: 6), child: Icon(Icons.notifications_off_outlined, size: 14, color: kMute)),
if (unread > 0) Padding(padding: const EdgeInsets.only(left: 6), child: UnreadBadge(unread)),
],
),
],
),
),
),
],
),
),
);
}
}
class GroupCard extends StatelessWidget {
const GroupCard({super.key, required this.title, required this.child, this.trailing});
final String title;
final Widget child;
final Widget? trailing;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.fromLTRB(12, 0, 12, 12),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 12, 4),
child: Row(
children: [
Expanded(child: Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: kInk))),
if (trailing != null) trailing!,
],
),
),
child,
],
),
);
}
}
class AppGrid extends StatelessWidget {
const AppGrid({super.key, required this.items});
final List<AppGridItem> items;
@override
Widget build(BuildContext context) {
return GridView.count(
crossAxisCount: 4,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(4, 4, 4, 12),
childAspectRatio: 0.92,
children: [
for (final it in items)
InkWell(
onTap: it.onTap,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(color: it.color, borderRadius: BorderRadius.circular(10)),
child: Icon(it.icon, color: Colors.white, size: 24),
),
const SizedBox(height: 6),
Text(
it.label,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, color: kInk),
),
],
),
),
],
);
}
}
class AppGridItem {
const AppGridItem({required this.label, required this.icon, required this.color, required this.onTap});
final String label;
final IconData icon;
final Color color;
final VoidCallback onTap;
}
class CellGroup extends StatelessWidget {
const CellGroup({super.key, required this.children});
final List<Widget> children;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.fromLTRB(12, 0, 12, 12),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)),
child: Column(children: children),
);
}
}
class Cell extends StatelessWidget {
const Cell({super.key, required this.title, this.subtitle, this.leading, this.trailing, this.onTap, this.showLine = true});
final String title;
final String? subtitle;
final Widget? leading;
final Widget? trailing;
final VoidCallback? onTap;
final bool showLine;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.only(left: 16),
child: Container(
padding: const EdgeInsets.fromLTRB(0, 14, 12, 14),
decoration: showLine ? const BoxDecoration(border: Border(bottom: BorderSide(color: kLine, width: 0.5))) : null,
child: Row(
children: [
if (leading != null) ...[leading!, const SizedBox(width: 12)],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontSize: 16, color: kInk)),
if (subtitle != null && subtitle!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(subtitle!, style: const TextStyle(fontSize: 12, color: kMute)),
),
],
),
),
if (trailing != null) trailing!,
if (onTap != null) const Icon(Icons.chevron_right, color: Color(0xFFC0C0C0), size: 20),
],
),
),
),
);
}
}
class WxTabBar extends StatelessWidget {
const WxTabBar({super.key, required this.index, required this.onChanged, required this.items});
final int index;
final ValueChanged<int> onChanged;
final List<(IconData, IconData, String, int)> items;
@override
Widget build(BuildContext context) {
return ColoredBox(
color: const Color(0xFFF7F7F7),
child: SafeArea(
top: false,
child: SizedBox(
height: 54,
child: Row(
children: [
for (var i = 0; i < items.length; i++)
Expanded(
child: InkWell(
onTap: () => onChanged(i),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Stack(
clipBehavior: Clip.none,
children: [
Icon(index == i ? items[i].$2 : items[i].$1, size: 24, color: index == i ? kBind : const Color(0xFF646464)),
if (items[i].$4 > 0)
Positioned(right: -10, top: -4, child: UnreadBadge(items[i].$4)),
],
),
const SizedBox(height: 2),
Text(items[i].$3, style: TextStyle(fontSize: 10, color: index == i ? kBind : const Color(0xFF646464))),
],
),
),
),
],
),
),
),
);
}
}
Future<T?> showWxSheet<T>(BuildContext context, List<(String, T)> actions) {
return showModalBottomSheet<T>(
context: context,
backgroundColor: Colors.transparent,
builder: (ctx) => Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)),
child: Column(
children: [
for (var i = 0; i < actions.length; i++)
InkWell(
onTap: () => Navigator.pop(ctx, actions[i].$2),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: i == 0 ? null : const BoxDecoration(border: Border(top: BorderSide(color: kLine, width: 0.5))),
alignment: Alignment.center,
child: Text(actions[i].$1, style: const TextStyle(fontSize: 17, color: kInk)),
),
),
],
),
),
const SizedBox(height: 8),
Material(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: () => Navigator.pop(ctx),
borderRadius: BorderRadius.circular(12),
child: const SizedBox(
width: double.infinity,
height: 52,
child: Center(child: Text('取消', style: TextStyle(fontSize: 17, color: kBind, fontWeight: FontWeight.w500))),
),
),
),
],
),
),
);
}
/// 微信风格的页内提示:不遮挡输入框和系统导航区域。
void showWxToast(BuildContext context, String message, {bool error = false}) {
final overlay = Overlay.maybeOf(context);
if (overlay == null || message.trim().isEmpty) return;
late final OverlayEntry entry;
entry = OverlayEntry(
builder: (ctx) => Positioned(
top: MediaQuery.paddingOf(ctx).top + 54,
left: 32,
right: 32,
child: IgnorePointer(
child: Material(
color: Colors.white,
elevation: 8,
shadowColor: Colors.black26,
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
child: Row(
children: [
Icon(error ? Icons.error_outline : Icons.check_circle_outline,
size: 20, color: error ? kDanger : kWeGreen),
const SizedBox(width: 9),
Expanded(
child: Text(message,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 14, color: kInk)),
),
],
),
),
),
),
),
);
overlay.insert(entry);
Future<void>.delayed(const Duration(milliseconds: 1800), () {
if (entry.mounted) entry.remove();
});
}
void showPlusMenu(BuildContext context, List<(IconData, String, VoidCallback)> items) {
final box = context.findRenderObject() as RenderBox?;
final overlay = Overlay.of(context).context.findRenderObject() as RenderBox?;
final pos = box != null && overlay != null
? RelativeRect.fromRect(
Rect.fromPoints(box.localToGlobal(Offset(box.size.width - 8, box.size.height), ancestor: overlay), box.localToGlobal(box.size.bottomRight(Offset.zero), ancestor: overlay)),
Offset.zero & overlay.size,
)
: const RelativeRect.fromLTRB(80, 80, 16, 0);
showMenu<int>(
context: context,
position: pos,
color: const Color(0xFF4C4C4C),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
items: [
for (var i = 0; i < items.length; i++)
PopupMenuItem(
value: i,
child: Row(
children: [
Icon(items[i].$1, color: Colors.white, size: 20),
const SizedBox(width: 12),
Text(items[i].$2, style: const TextStyle(color: Colors.white, fontSize: 15)),
],
),
),
],
).then((i) {
if (i != null) items[i].$3();
});
}