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

541 lines
18 KiB
Dart

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();
});
}