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:
2026-09-02 10:04:03 +00:00
commit 76f266645d
507 changed files with 99891 additions and 0 deletions
@@ -0,0 +1,262 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../device/device_bridge.dart';
import '../theme.dart';
import '../widgets/tencent_map_thumb.dart';
import '../widgets/wecom.dart';
class GeoPlace {
GeoPlace({required this.lat, required this.lng, required this.title, required this.address, this.distance});
final double lat;
final double lng;
final String title;
final String address;
final num? distance;
Map<String, dynamic> toMeta() => {
'lat': lat,
'lng': lng,
'title': title,
'address': address,
};
}
class LocationPickPage extends StatefulWidget {
const LocationPickPage({super.key, required this.api});
final OaClient api;
@override
State<LocationPickPage> createState() => _LocationPickPageState();
}
class _LocationPickPageState extends State<LocationPickPage> {
double? _lat;
double? _lng;
GeoPlace? _picked;
List<GeoPlace> _pois = [];
bool _loading = true;
String _err = '';
String _q = '';
Timer? _searchDebounce;
@override
void initState() {
super.initState();
_locate();
}
@override
void dispose() {
_searchDebounce?.cancel();
super.dispose();
}
Future<void> _locate() async {
setState(() {
_loading = true;
_err = '';
});
try {
Map<String, double>? loc;
try {
loc = await DeviceBridge.getLocation();
} catch (_) {}
final lat = loc?['lat'];
final lng = loc?['lng'];
if (lat == null || lng == null) {
throw Exception('没有拿到定位,请允许位置权限并打开系统定位');
}
await _loadAround(lat, lng);
} catch (e) {
if (mounted) setState(() { _loading = false; _err = '$e'; });
}
}
Future<void> _loadAround(double lat, double lng) async {
final data = await widget.api.post('/office/geo/reverse', {'lat': lat, 'lng': lng});
if (!mounted) return;
final map = data is Map ? Map<String, dynamic>.from(data) : <String, dynamic>{};
final here = GeoPlace(
lat: lat,
lng: lng,
title: '${map['title'] ?? '当前位置'}',
address: '${map['address'] ?? ''}',
);
final pois = <GeoPlace>[here];
final raw = map['pois'];
if (raw is List) {
for (final e in raw) {
if (e is! Map) continue;
final plat = (e['lat'] as num?)?.toDouble();
final plng = (e['lng'] as num?)?.toDouble();
if (plat == null || plng == null) continue;
pois.add(GeoPlace(
lat: plat,
lng: plng,
title: '${e['title'] ?? e['address'] ?? '地点'}',
address: '${e['address'] ?? ''}',
distance: e['distance'] as num?,
));
}
}
setState(() {
_lat = lat;
_lng = lng;
_picked = here;
_pois = pois;
_loading = false;
_err = '';
});
}
void _onSearch(String q) {
_q = q.trim();
_searchDebounce?.cancel();
if (_q.isEmpty) {
if (_lat != null && _lng != null) _loadAround(_lat!, _lng!);
return;
}
_searchDebounce = Timer(const Duration(milliseconds: 350), () async {
try {
final data = await widget.api.get('/office/geo/suggest', query: {
'keyword': _q,
if (_lat != null) 'lat': '$_lat',
if (_lng != null) 'lng': '$_lng',
});
if (!mounted) return;
final list = data is List ? data : (data is Map ? (data['items'] ?? data['data'] ?? []) : []);
final pois = <GeoPlace>[];
for (final e in (list is List ? list : <dynamic>[])) {
if (e is! Map) continue;
final plat = (e['lat'] as num?)?.toDouble();
final plng = (e['lng'] as num?)?.toDouble();
if (plat == null || plng == null) continue;
pois.add(GeoPlace(
lat: plat,
lng: plng,
title: '${e['title'] ?? '地点'}',
address: '${e['address'] ?? ''}',
distance: e['distance'] as num?,
));
}
setState(() {
_pois = pois;
if (pois.isNotEmpty) _picked = pois.first;
});
} catch (_) {}
});
}
String _dist(num? d) {
if (d == null) return '';
if (d < 1) return '当前位置';
if (d < 1000) return '${d.round()}m';
return '${(d / 1000).toStringAsFixed(1)}km';
}
@override
Widget build(BuildContext context) {
final picked = _picked;
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
leading: TextButton(onPressed: () => Navigator.pop(context), child: const Text('取消')),
title: const Text('发送位置'),
actions: [
Padding(
padding: const EdgeInsets.only(right: 8, top: 8, bottom: 8),
child: FilledButton(
style: FilledButton.styleFrom(backgroundColor: kWeGreen, minimumSize: const Size(64, 32)),
onPressed: picked == null ? null : () => Navigator.pop(context, picked),
child: const Text('发送'),
),
),
],
),
body: Column(
children: [
SizedBox(
height: 220,
width: double.infinity,
child: Stack(
children: [
if (picked != null)
Positioned.fill(
child: TencentMapThumb(api: widget.api, lat: picked.lat, lng: picked.lng, height: 220),
)
else
const ColoredBox(color: Color(0xFFE8F5E9), child: Center(child: CircularProgressIndicator())),
Positioned(
left: 12,
bottom: 12,
child: Material(
color: Colors.white,
shape: const CircleBorder(),
elevation: 2,
child: IconButton(
onPressed: _locate,
icon: const Icon(Icons.my_location, color: kBind),
),
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
child: WxSearchBar(hint: '搜索地点', onChanged: _onSearch),
),
if (_err.isNotEmpty)
Padding(
padding: const EdgeInsets.all(12),
child: Text(_err, style: const TextStyle(color: kDanger, fontSize: 13)),
),
Expanded(
child: _loading
? const Center(child: CircularProgressIndicator())
: ListView.builder(
itemCount: _pois.length,
itemBuilder: (_, i) {
final p = _pois[i];
final on = picked?.lat == p.lat && picked?.lng == p.lng && picked?.title == p.title;
return InkWell(
onTap: () => setState(() => _picked = p),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Row(
children: [
Expanded(
child: Container(
padding: const EdgeInsets.only(bottom: 12),
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: kLine, width: 0.5))),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(p.title, style: const TextStyle(fontSize: 16, color: kInk)),
const SizedBox(height: 2),
Text(
[_dist(p.distance), p.address].where((e) => e.isNotEmpty).join(' | '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, color: kMute),
),
],
),
),
),
if (on) const Padding(padding: EdgeInsets.only(left: 8), child: Icon(Icons.check, color: kWeGreen)),
],
),
),
);
},
),
),
],
),
);
}
}