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,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),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user