Files
Public/apps/native/lib/widgets/desktop_ui.dart
T
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

412 lines
12 KiB
Dart

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),
),
),
],
],
),
),
),
),
);
}
}