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:
@@ -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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user