Files
Public/apps/native/lib/desktop/widgets/desk_widgets.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

574 lines
18 KiB
Dart

import 'package:flutter/material.dart';
import '../../api/oa_client.dart';
import '../../session/session.dart';
import '../../widgets/auth_image.dart';
import '../desk_theme.dart';
class DeskNavRail extends StatelessWidget {
const DeskNavRail({
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: kDeskRail,
child: SizedBox(
width: kDeskRailWidth,
child: Column(
children: [
const SizedBox(height: 18),
DeskAvatar(
label: session.displayName,
fileId: '${session.user['avatarFileId'] ?? ''}',
api: api,
size: 36,
),
const SizedBox(height: 18),
Expanded(
child: ListView(
padding: EdgeInsets.zero,
children: [
for (var i = 0; i < items.length; i++)
_RailItem(
active: index == i,
icon: index == i ? items[i].$2 : items[i].$1,
label: items[i].$3,
badge: items[i].$4,
onTap: () => onChanged(i),
),
],
),
),
const SizedBox(height: 12),
],
),
),
);
}
}
class _RailItem extends StatelessWidget {
const _RailItem({
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.symmetric(horizontal: 8, vertical: 3),
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(vertical: 8),
child: Column(
children: [
Stack(
clipBehavior: Clip.none,
children: [
Icon(icon, size: 22, color: active ? kDeskBind : const Color(0xFF5C5C5C)),
if (badge > 0)
Positioned(
right: -10,
top: -6,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
constraints: const BoxConstraints(minWidth: 16, minHeight: 16),
decoration: BoxDecoration(
color: const Color(0xFFFA5151),
borderRadius: BorderRadius.circular(8),
),
alignment: Alignment.center,
child: Text(
badge > 99 ? '99+' : '$badge',
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w600),
),
),
),
],
),
const SizedBox(height: 3),
Text(
label,
style: TextStyle(
fontSize: 11,
color: active ? kDeskBind : const Color(0xFF5C5C5C),
fontWeight: active ? FontWeight.w600 : FontWeight.w400,
),
),
],
),
),
),
),
);
}
}
class DeskAvatar extends StatelessWidget {
const DeskAvatar({
super.key,
required this.label,
required this.api,
this.fileId,
this.size = 40,
this.color,
this.icon,
});
final String label;
final OaClient api;
final String? fileId;
final double size;
final Color? color;
final IconData? icon;
@override
Widget build(BuildContext context) {
final id = fileId ?? '';
if (id.isNotEmpty) {
return ClipRRect(
borderRadius: BorderRadius.circular(6),
child: AuthImage(fileId: id, api: api, width: size, height: size, fit: BoxFit.cover),
);
}
final c = color ?? kDeskBind;
final ch = label.isNotEmpty ? label.characters.first : '?';
return Container(
width: size,
height: size,
decoration: BoxDecoration(color: c, borderRadius: BorderRadius.circular(6)),
alignment: Alignment.center,
child: icon != null
? Icon(icon, color: Colors.white, size: size * 0.5)
: Text(ch, style: TextStyle(color: Colors.white, fontSize: size * 0.38, fontWeight: FontWeight.w600)),
);
}
}
class DeskPaneHeader extends StatelessWidget {
const DeskPaneHeader({
super.key,
required this.title,
this.actions = const [],
this.bottom,
this.leading,
});
final String title;
final List<Widget> actions;
final Widget? bottom;
final Widget? leading;
@override
Widget build(BuildContext context) {
return ColoredBox(
color: kDeskPane,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: 48,
child: Row(
children: [
if (leading != null) leading!,
Padding(
padding: EdgeInsets.only(left: leading == null ? 14 : 4),
child: Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: kDeskInk)),
),
const Spacer(),
...actions,
const SizedBox(width: 6),
],
),
),
if (bottom != null) bottom!,
const Divider(height: 1, color: kDeskLine),
],
),
);
}
}
class DeskSearchField extends StatelessWidget {
const DeskSearchField({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: 32,
decoration: BoxDecoration(color: const Color(0xFFEFEFEF), borderRadius: BorderRadius.circular(6)),
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Row(
children: [
const Icon(Icons.search, size: 16, color: kDeskMute),
const SizedBox(width: 6),
Expanded(
child: TextField(
controller: controller,
onChanged: onChanged,
style: const TextStyle(fontSize: 13),
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: 13),
),
),
),
],
),
);
}
}
class DeskConvRow extends StatelessWidget {
const DeskConvRow({
super.key,
required this.name,
required this.preview,
required this.time,
required this.unread,
required this.selected,
required this.onTap,
this.avatarId,
this.api,
this.muted = false,
this.pinned = false,
this.onSecondaryTap,
});
final String name;
final String preview;
final String time;
final int unread;
final bool selected;
final VoidCallback onTap;
final String? avatarId;
final OaClient? api;
final bool muted;
final bool pinned;
final void Function(TapDownDetails)? onSecondaryTap;
@override
Widget build(BuildContext context) {
return Material(
color: selected ? kDeskActive : Colors.transparent,
child: InkWell(
onTap: onTap,
onSecondaryTapDown: onSecondaryTap,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
child: Row(
children: [
if (api != null)
DeskAvatar(label: name, fileId: avatarId, api: api!, size: 40)
else
DeskAvatar(label: name, api: OaClient(SessionStore()), size: 40),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (pinned)
const Padding(
padding: EdgeInsets.only(right: 4),
child: Icon(Icons.push_pin, size: 12, color: kDeskMute),
),
Expanded(
child: Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: kDeskInk),
),
),
Text(time, style: const TextStyle(fontSize: 11, color: kDeskMute)),
],
),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: Text(
preview,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: muted ? kDeskMute : const Color(0xFF9A9A9A)),
),
),
if (muted)
const Icon(Icons.notifications_off_outlined, size: 14, color: kDeskMute),
if (unread > 0) ...[
const SizedBox(width: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: const Color(0xFFFA5151),
borderRadius: BorderRadius.circular(9),
),
child: Text(
unread > 99 ? '99+' : '$unread',
style: const TextStyle(color: Colors.white, fontSize: 10),
),
),
],
],
),
],
),
),
],
),
),
),
);
}
}
class DeskEmptyChat extends StatelessWidget {
const DeskEmptyChat({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.1,
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(color: kDeskBind, borderRadius: BorderRadius.circular(24)),
child: const Icon(Icons.chat_bubble_outline, size: 52, color: Colors.white),
),
),
const SizedBox(height: 16),
Text(hint, style: const TextStyle(color: kDeskMute, fontSize: 13)),
],
),
),
);
}
}
class DeskTabStrip extends StatelessWidget {
const DeskTabStrip({
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: 40,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8),
children: [
for (var i = 0; i < tabs.length; i++)
Padding(
padding: const EdgeInsets.only(right: 2, top: 6, bottom: 6),
child: Material(
color: active == i ? const Color(0xFFF0F4FA) : Colors.transparent,
borderRadius: BorderRadius.circular(6),
child: InkWell(
onTap: () => onSelect(i),
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: EdgeInsets.fromLTRB(10, 5, i > 0 ? 4 : 10, 5),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(tabs[i].$3, size: 14, color: tabs[i].$4),
const SizedBox(width: 5),
Text(
tabs[i].$2,
style: TextStyle(
fontSize: 12,
color: active == i ? kDeskInk : kDeskMute,
fontWeight: active == i ? FontWeight.w600 : FontWeight.w400,
),
),
if (i > 0) ...[
const SizedBox(width: 2),
InkWell(
onTap: () => onClose(i),
borderRadius: BorderRadius.circular(8),
child: const Padding(
padding: EdgeInsets.all(2),
child: Icon(Icons.close, size: 12, color: kDeskMute),
),
),
],
],
),
),
),
),
),
],
),
),
const Divider(height: 1, color: kDeskLine),
],
),
);
}
}
class DeskAppCard extends StatelessWidget {
const DeskAppCard({
super.key,
required this.title,
required this.desc,
required this.icon,
required this.color,
required this.onTap,
});
final String title;
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(8),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 42,
height: 42,
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(title, maxLines: 1, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: kDeskInk)),
const SizedBox(height: 4),
Text(desc, maxLines: 2, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, color: kDeskMute, height: 1.35)),
],
),
),
],
),
),
),
);
}
}
class DeskFilterChips extends StatelessWidget {
const DeskFilterChips({super.key, required this.value, required this.onChanged, required this.items});
final String value;
final ValueChanged<String> onChanged;
final List<(String, String)> items;
@override
Widget build(BuildContext context) {
return Row(
children: [
for (final it in items) ...[
_chip(it.$1, it.$2),
if (it != items.last) const SizedBox(width: 6),
],
],
);
}
Widget _chip(String id, String label) {
final on = value == id;
return GestureDetector(
onTap: () => onChanged(id),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: on ? kDeskActive : Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: on ? const Color(0xFFB8D4FF) : kDeskLine),
),
child: Text(label, style: TextStyle(fontSize: 12, color: on ? kDeskBind : kDeskMute, fontWeight: on ? FontWeight.w600 : FontWeight.w400)),
),
);
}
}
void deskToast(BuildContext context, String msg, {bool error = false}) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(msg),
backgroundColor: error ? const Color(0xFFFA5151) : null,
behavior: SnackBarBehavior.floating,
width: 320,
),
);
}