announcement_create_page.dart 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter_riverpod/flutter_riverpod.dart';
  3. import 'package:go_router/go_router.dart';
  4. import '../../core/theme/app_colors.dart';
  5. import '../shell/nav_bar_config.dart';
  6. import '../../core/utils/responsive.dart';
  7. import '../../shared/widgets/action_bar.dart';
  8. import '../../shared/widgets/form_section.dart';
  9. import '../../core/i18n/app_localizations.dart';
  10. class AnnouncementCreatePage extends ConsumerStatefulWidget {
  11. const AnnouncementCreatePage({super.key});
  12. @override
  13. ConsumerState<AnnouncementCreatePage> createState() =>
  14. _AnnouncementCreatePageState();
  15. }
  16. class _AnnouncementCreatePageState
  17. extends ConsumerState<AnnouncementCreatePage> {
  18. final _titleCtrl = TextEditingController();
  19. final _contentCtrl = TextEditingController();
  20. String _type = '通知公告';
  21. bool _isTop = false;
  22. DateTime? _expiryDate;
  23. // 接收范围
  24. int _scopeMode = 0; // 0=全员, 1=按部门, 2=按指定用户
  25. final List<String> _selectedDepts = [];
  26. final List<String> _selectedUsers = [];
  27. final int _totalCoverage = 128; // 模拟覆盖人数
  28. // 附件模拟
  29. final List<String> _attachments = [];
  30. static const int _maxAttachments = 5;
  31. final _types = ['通知公告', '人事与制度', '放假与活动'];
  32. final List<String> _mockDepts = [
  33. '市场部',
  34. '技术部',
  35. '销售部',
  36. '财务部',
  37. '人力资源部',
  38. '行政管理部',
  39. ];
  40. @override
  41. void dispose() {
  42. _titleCtrl.dispose();
  43. _contentCtrl.dispose();
  44. super.dispose();
  45. }
  46. Future<void> _pickType() async {
  47. final l10n = AppLocalizations.of(context);
  48. final result = await showDialog<String>(
  49. context: context,
  50. builder: (ctx) => SimpleDialog(
  51. title: Text(l10n.get('announcementTypes')),
  52. children: _types
  53. .map(
  54. (t) => SimpleDialogOption(
  55. onPressed: () => Navigator.pop(ctx, t),
  56. child: Text(t),
  57. ),
  58. )
  59. .toList(),
  60. ),
  61. );
  62. if (result != null) setState(() => _type = result);
  63. }
  64. Future<void> _pickExpiryDate() async {
  65. final picked = await showDatePicker(
  66. context: context,
  67. initialDate: _expiryDate ?? DateTime.now().add(const Duration(days: 30)),
  68. firstDate: DateTime.now(),
  69. lastDate: DateTime(2030),
  70. );
  71. if (picked != null) setState(() => _expiryDate = picked);
  72. }
  73. void _showScopeDrawer() {
  74. showModalBottomSheet(
  75. context: context,
  76. isScrollControlled: true,
  77. shape: const RoundedRectangleBorder(
  78. borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
  79. ),
  80. builder: (ctx) => _buildScopeDrawerContent(ctx),
  81. );
  82. }
  83. Widget _buildScopeDrawerContent(BuildContext ctx) {
  84. return StatefulBuilder(
  85. builder: (context, setInnerState) {
  86. return Container(
  87. height: MediaQuery.of(context).size.height * 0.7,
  88. padding: const EdgeInsets.all(16),
  89. child: Column(
  90. crossAxisAlignment: CrossAxisAlignment.start,
  91. children: [
  92. Row(
  93. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  94. children: [
  95. const Text('接收范围',
  96. style: TextStyle(
  97. fontSize: 16,
  98. fontWeight: FontWeight.w600,
  99. color: AppColors.textPrimary)),
  100. GestureDetector(
  101. onTap: () => Navigator.pop(context),
  102. child: const Icon(Icons.close, size: 20),
  103. ),
  104. ],
  105. ),
  106. const SizedBox(height: 16),
  107. RadioGroup<int>(
  108. groupValue: _scopeMode,
  109. onChanged: (v) {
  110. setInnerState(() => _scopeMode = v!);
  111. setState(() {});
  112. },
  113. child: Column(
  114. crossAxisAlignment: CrossAxisAlignment.start,
  115. children: [
  116. _buildScopeOption(
  117. context,
  118. '全员',
  119. '所有员工均可查看',
  120. 0,
  121. setInnerState,
  122. ),
  123. const SizedBox(height: 8),
  124. _buildScopeOption(
  125. context,
  126. '按部门',
  127. '按部门树多选',
  128. 1,
  129. setInnerState,
  130. ),
  131. const SizedBox(height: 8),
  132. _buildScopeOption(
  133. context,
  134. '按指定用户',
  135. '按员工搜索多选',
  136. 2,
  137. setInnerState,
  138. ),
  139. ],
  140. ),
  141. ),
  142. if (_scopeMode == 1) ...[
  143. const SizedBox(height: 12),
  144. const Text('选择部门',
  145. style: TextStyle(
  146. fontSize: 13, color: AppColors.textSecondary)),
  147. const SizedBox(height: 4),
  148. ..._mockDepts.map(
  149. (dept) => CheckboxListTile(
  150. title: Text(dept,
  151. style: const TextStyle(fontSize: 14)),
  152. value: _selectedDepts.contains(dept),
  153. onChanged: (checked) {
  154. setInnerState(() {
  155. if (checked == true) {
  156. _selectedDepts.add(dept);
  157. } else {
  158. _selectedDepts.remove(dept);
  159. }
  160. });
  161. setState(() {});
  162. },
  163. dense: true,
  164. contentPadding: EdgeInsets.zero,
  165. ),
  166. ),
  167. ],
  168. if (_scopeMode == 2) ...[
  169. const SizedBox(height: 12),
  170. const Text('搜索员工',
  171. style: TextStyle(
  172. fontSize: 13, color: AppColors.textSecondary)),
  173. const SizedBox(height: 4),
  174. Container(
  175. padding:
  176. const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
  177. decoration: BoxDecoration(
  178. color: AppColors.bgPage,
  179. borderRadius: BorderRadius.circular(4),
  180. ),
  181. child: TextField(
  182. decoration: const InputDecoration(
  183. hintText: '输入姓名或工号搜索',
  184. hintStyle: TextStyle(
  185. fontSize: 14, color: AppColors.textPlaceholder),
  186. border: InputBorder.none,
  187. isDense: true,
  188. ),
  189. style: const TextStyle(
  190. fontSize: 14, color: AppColors.textPrimary),
  191. ),
  192. ),
  193. const SizedBox(height: 8),
  194. Text(
  195. '已选 ${_selectedUsers.length} 人',
  196. style: const TextStyle(
  197. fontSize: 12, color: AppColors.textSecondary),
  198. ),
  199. ],
  200. const Spacer(),
  201. // 覆盖统计
  202. Container(
  203. padding: const EdgeInsets.all(12),
  204. decoration: BoxDecoration(
  205. color: AppColors.primaryLight,
  206. borderRadius: BorderRadius.circular(8),
  207. ),
  208. child: Row(
  209. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  210. children: [
  211. const Text('覆盖人数',
  212. style: TextStyle(
  213. fontSize: 14, color: AppColors.textPrimary)),
  214. Text(
  215. '${_scopeMode == 0 ? _totalCoverage : (_scopeMode == 1 ? _selectedDepts.length * 15 : _selectedUsers.length)} 人',
  216. style: const TextStyle(
  217. fontSize: 16,
  218. fontWeight: FontWeight.w700,
  219. color: AppColors.primary),
  220. ),
  221. ],
  222. ),
  223. ),
  224. const SizedBox(height: 12),
  225. SizedBox(
  226. width: double.infinity,
  227. child: ElevatedButton(
  228. onPressed: () => Navigator.pop(context),
  229. style: ElevatedButton.styleFrom(
  230. backgroundColor: AppColors.primary,
  231. foregroundColor: Colors.white,
  232. shape: RoundedRectangleBorder(
  233. borderRadius: BorderRadius.circular(8)),
  234. padding: const EdgeInsets.symmetric(vertical: 12),
  235. ),
  236. child: const Text('确认'),
  237. ),
  238. ),
  239. ],
  240. ),
  241. );
  242. },
  243. );
  244. }
  245. Widget _buildScopeOption(
  246. BuildContext context,
  247. String title,
  248. String subtitle,
  249. int mode,
  250. void Function(void Function()) setInnerState,
  251. ) {
  252. final selected = _scopeMode == mode;
  253. return GestureDetector(
  254. onTap: () {
  255. setInnerState(() => _scopeMode = mode);
  256. setState(() {});
  257. },
  258. child: Container(
  259. padding: const EdgeInsets.all(12),
  260. decoration: BoxDecoration(
  261. color: selected ? AppColors.primaryLight : AppColors.bgPage,
  262. borderRadius: BorderRadius.circular(8),
  263. border: Border.all(
  264. color: selected ? AppColors.primary : Colors.transparent,
  265. ),
  266. ),
  267. child: Row(
  268. children: [
  269. Radio<int>(
  270. value: mode,
  271. activeColor: AppColors.primary,
  272. ),
  273. const SizedBox(width: 8),
  274. Column(
  275. crossAxisAlignment: CrossAxisAlignment.start,
  276. children: [
  277. Text(title,
  278. style: const TextStyle(
  279. fontSize: 14, color: AppColors.textPrimary)),
  280. Text(subtitle,
  281. style: const TextStyle(
  282. fontSize: 12, color: AppColors.textSecondary)),
  283. ],
  284. ),
  285. ],
  286. ),
  287. ),
  288. );
  289. }
  290. void _showPreview() {
  291. final l10n = AppLocalizations.of(context);
  292. showDialog(
  293. context: context,
  294. barrierDismissible: true,
  295. builder: (ctx) => Dialog(
  296. insetPadding: const EdgeInsets.all(16),
  297. child: Container(
  298. width: double.infinity,
  299. height: MediaQuery.of(context).size.height * 0.85,
  300. padding: const EdgeInsets.all(16),
  301. child: Column(
  302. crossAxisAlignment: CrossAxisAlignment.start,
  303. children: [
  304. Row(
  305. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  306. children: [
  307. Text(l10n.get('previewTitle'),
  308. style: const TextStyle(
  309. fontSize: 16,
  310. fontWeight: FontWeight.w600,
  311. color: AppColors.textPrimary)),
  312. GestureDetector(
  313. onTap: () => Navigator.pop(ctx),
  314. child: const Icon(Icons.close, size: 20),
  315. ),
  316. ],
  317. ),
  318. const Divider(),
  319. Expanded(
  320. child: SingleChildScrollView(
  321. child: Column(
  322. crossAxisAlignment: CrossAxisAlignment.start,
  323. children: [
  324. Container(
  325. width: 60,
  326. height: 4,
  327. color: AppColors.danger,
  328. ),
  329. const SizedBox(height: 16),
  330. Text(
  331. _titleCtrl.text.isEmpty ? '(未填写标题)' : _titleCtrl.text,
  332. style: const TextStyle(
  333. fontSize: 20,
  334. fontWeight: FontWeight.w700,
  335. color: AppColors.textPrimary,
  336. ),
  337. ),
  338. const SizedBox(height: 12),
  339. Text(
  340. '$_type · 发布后将显示',
  341. style: const TextStyle(
  342. fontSize: 13, color: AppColors.textSecondary),
  343. ),
  344. const SizedBox(height: 16),
  345. Container(
  346. width: double.infinity,
  347. height: 2,
  348. color: AppColors.danger,
  349. ),
  350. const SizedBox(height: 16),
  351. Text(
  352. _contentCtrl.text.isEmpty
  353. ? '(未填写正文)'
  354. : _contentCtrl.text,
  355. style: const TextStyle(
  356. fontSize: 14,
  357. color: AppColors.textSecondary,
  358. height: 1.7),
  359. ),
  360. ],
  361. ),
  362. ),
  363. ),
  364. ],
  365. ),
  366. ),
  367. ),
  368. );
  369. }
  370. void _confirmPublish() {
  371. final l10n = AppLocalizations.of(context);
  372. showDialog(
  373. context: context,
  374. builder: (ctx) => AlertDialog(
  375. title: Text(l10n.get('confirmPublishTitle')),
  376. content: Text(l10n.getString('confirmPublishContent', args: {'title': _titleCtrl.text})),
  377. actions: [
  378. TextButton(
  379. onPressed: () => Navigator.pop(ctx),
  380. child: Text(l10n.get('cancel')),
  381. ),
  382. TextButton(
  383. onPressed: () {
  384. Navigator.pop(ctx);
  385. ScaffoldMessenger.of(context).showSnackBar(
  386. SnackBar(content: Text(l10n.get('announcementPublished'))),
  387. );
  388. context.pop();
  389. },
  390. child: Text(l10n.get('confirmPublish'),
  391. style: const TextStyle(color: AppColors.primary)),
  392. ),
  393. ],
  394. ),
  395. );
  396. }
  397. void _saveDraft() {
  398. ScaffoldMessenger.of(context).showSnackBar(
  399. const SnackBar(content: Text('已保存为草稿')),
  400. );
  401. }
  402. void _pickAttachment() {
  403. ScaffoldMessenger.of(context).showSnackBar(
  404. const SnackBar(content: Text('模拟:选择附件(PDF/图片/Word/Excel,≤20MB)')),
  405. );
  406. }
  407. @override
  408. Widget build(BuildContext context) {
  409. final r = ResponsiveHelper.of(context);
  410. final l10n = AppLocalizations.of(context);
  411. ref
  412. .read(navBarConfigProvider.notifier)
  413. .update(
  414. NavBarConfig(
  415. title: l10n.get('announcementCreate'),
  416. showBack: true,
  417. showRight: true,
  418. rightWidget: TextButton(
  419. onPressed: _showPreview,
  420. child: Text(l10n.get('preview'),
  421. style: const TextStyle(
  422. fontSize: 14,
  423. color: AppColors.primary,
  424. fontWeight: FontWeight.w500)),
  425. ),
  426. onBack: () => context.pop(),
  427. ),
  428. );
  429. return Column(
  430. children: [
  431. Expanded(
  432. child: Align(
  433. alignment: Alignment.topCenter,
  434. child: ConstrainedBox(
  435. constraints: BoxConstraints(maxWidth: r.formMaxWidth),
  436. child: SingleChildScrollView(
  437. padding: const EdgeInsets.symmetric(vertical: 8),
  438. child: Column(
  439. children: [
  440. // 基本信息
  441. FormSection(
  442. title: l10n.get('basicInfo'),
  443. children: [
  444. Container(
  445. padding: const EdgeInsets.symmetric(
  446. horizontal: 10, vertical: 4),
  447. decoration: BoxDecoration(
  448. color: AppColors.bgPage,
  449. borderRadius: BorderRadius.circular(4),
  450. ),
  451. child: TextField(
  452. controller: _titleCtrl,
  453. decoration: InputDecoration(
  454. hintText: l10n.get('enterTitle'),
  455. hintStyle: TextStyle(
  456. fontSize: 14,
  457. color: AppColors.textPlaceholder),
  458. border: InputBorder.none,
  459. contentPadding:
  460. EdgeInsets.symmetric(vertical: 10),
  461. isDense: true,
  462. ),
  463. style: const TextStyle(
  464. fontSize: 14,
  465. color: AppColors.textPrimary),
  466. ),
  467. ),
  468. const SizedBox(height: 12),
  469. GestureDetector(
  470. onTap: _pickType,
  471. child: Container(
  472. padding: const EdgeInsets.symmetric(
  473. horizontal: 10, vertical: 12),
  474. decoration: BoxDecoration(
  475. color: AppColors.bgPage,
  476. borderRadius: BorderRadius.circular(4),
  477. ),
  478. child: Row(
  479. mainAxisAlignment:
  480. MainAxisAlignment.spaceBetween,
  481. children: [
  482. Text(_type,
  483. style: const TextStyle(
  484. fontSize: 14,
  485. color: AppColors.textPrimary)),
  486. const Icon(Icons.chevron_right,
  487. size: 14,
  488. color: AppColors.textPlaceholder),
  489. ],
  490. ),
  491. ),
  492. ),
  493. ],
  494. ),
  495. const SizedBox(height: 8),
  496. // 公告正文
  497. FormSection(
  498. title: l10n.get('announcementContent'),
  499. children: [
  500. Container(
  501. padding: const EdgeInsets.all(12),
  502. decoration: BoxDecoration(
  503. color: AppColors.bgPage,
  504. borderRadius: BorderRadius.circular(4),
  505. ),
  506. height: 200,
  507. child: TextField(
  508. controller: _contentCtrl,
  509. maxLines: null,
  510. expands: true,
  511. textAlignVertical: TextAlignVertical.top,
  512. decoration: InputDecoration(
  513. hintText: l10n.get('enterContent'),
  514. hintStyle: TextStyle(
  515. fontSize: 14,
  516. color: AppColors.textPlaceholder),
  517. border: InputBorder.none,
  518. contentPadding: EdgeInsets.zero,
  519. ),
  520. style: const TextStyle(
  521. fontSize: 14,
  522. color: AppColors.textPrimary,
  523. height: 1.7),
  524. ),
  525. ),
  526. ],
  527. ),
  528. const SizedBox(height: 8),
  529. // 附件
  530. FormSection(
  531. title: l10n.get('attachments'),
  532. actionText: _attachments.length >= _maxAttachments
  533. ? l10n.get('limitReached')
  534. : l10n.get('add'),
  535. showAction: _attachments.length < _maxAttachments,
  536. actionIcon: Icons.attach_file,
  537. onActionTap: _pickAttachment,
  538. children: [
  539. if (_attachments.isEmpty)
  540. const Text('最多5个附件,支持PDF/图片/Word/Excel,单文件≤20MB',
  541. style: TextStyle(
  542. fontSize: 12,
  543. color: AppColors.textPlaceholder))
  544. else
  545. Wrap(
  546. spacing: 8,
  547. runSpacing: 8,
  548. children: _attachments
  549. .asMap()
  550. .entries
  551. .map((entry) {
  552. return Chip(
  553. label: Text(entry.value,
  554. style: const TextStyle(
  555. fontSize: 12)),
  556. deleteIcon: const Icon(Icons.close,
  557. size: 16),
  558. onDeleted: () {
  559. setState(() =>
  560. _attachments.removeAt(entry.key));
  561. },
  562. );
  563. }).toList(),
  564. ),
  565. ],
  566. ),
  567. const SizedBox(height: 8),
  568. // 发布设置
  569. FormSection(
  570. title: l10n.get('publishSettings'),
  571. children: [
  572. _buildSwitchRow(l10n.get('pinAnnouncement'), _isTop, (v) {
  573. setState(() => _isTop = v);
  574. }),
  575. const Divider(height: 1, color: AppColors.border),
  576. GestureDetector(
  577. onTap: _pickExpiryDate,
  578. child: Container(
  579. height: 44,
  580. alignment: Alignment.centerLeft,
  581. child: Row(
  582. mainAxisAlignment:
  583. MainAxisAlignment.spaceBetween,
  584. children: [
  585. Text(l10n.get('validUntil'),
  586. style: TextStyle(
  587. fontSize: 14,
  588. color: AppColors.textSecondary)),
  589. Text(
  590. _expiryDate != null
  591. ? '${_expiryDate!.year}-${_expiryDate!.month.toString().padLeft(2, '0')}-${_expiryDate!.day.toString().padLeft(2, '0')}'
  592. : l10n.get('expiryNever'),
  593. style: TextStyle(
  594. fontSize: 14,
  595. color: _expiryDate != null
  596. ? AppColors.primary
  597. : AppColors.textPlaceholder,
  598. ),
  599. ),
  600. const Icon(Icons.chevron_right,
  601. size: 14,
  602. color: AppColors.textPlaceholder),
  603. ],
  604. ),
  605. ),
  606. ),
  607. const Divider(height: 1, color: AppColors.border),
  608. GestureDetector(
  609. onTap: _showScopeDrawer,
  610. child: Container(
  611. height: 44,
  612. alignment: Alignment.centerLeft,
  613. child: Row(
  614. mainAxisAlignment:
  615. MainAxisAlignment.spaceBetween,
  616. children: [
  617. Text(l10n.get('recipientScope'),
  618. style: TextStyle(
  619. fontSize: 14,
  620. color: AppColors.textSecondary)),
  621. Row(
  622. mainAxisSize: MainAxisSize.min,
  623. children: [
  624. Text(
  625. _scopeMode == 0
  626. ? l10n.get('allStaff')
  627. : _scopeMode == 1
  628. ? '${l10n.get('byDept')}(${_selectedDepts.length})'
  629. : '${l10n.get('byUser')}(${_selectedUsers.length})',
  630. style: const TextStyle(
  631. fontSize: 14,
  632. color: AppColors.primary),
  633. ),
  634. Text(
  635. ' · $_totalCoverage 人',
  636. style: const TextStyle(
  637. fontSize: 12,
  638. color:
  639. AppColors.textPlaceholder),
  640. ),
  641. const SizedBox(width: 4),
  642. const Icon(Icons.chevron_right,
  643. size: 14,
  644. color: AppColors.textPlaceholder),
  645. ],
  646. ),
  647. ],
  648. ),
  649. ),
  650. ),
  651. ],
  652. ),
  653. const SizedBox(height: 80),
  654. ],
  655. ),
  656. ),
  657. ),
  658. ),
  659. ),
  660. ActionBar(
  661. centerLabel: l10n.get('saveDraft'),
  662. rightLabel: l10n.get('publish'),
  663. showLeft: false,
  664. onCenterTap: _saveDraft,
  665. onRightTap: _confirmPublish,
  666. ),
  667. ],
  668. );
  669. }
  670. Widget _buildSwitchRow(
  671. String label, bool value, ValueChanged<bool> onChanged) {
  672. return Container(
  673. height: 44,
  674. alignment: Alignment.centerLeft,
  675. child: Row(
  676. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  677. children: [
  678. Text(label,
  679. style: const TextStyle(
  680. fontSize: 14, color: AppColors.textSecondary)),
  681. Switch(
  682. value: value,
  683. onChanged: onChanged,
  684. activeThumbColor: AppColors.primary,
  685. ),
  686. ],
  687. ),
  688. );
  689. }
  690. }