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