announcement_create_page.dart 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter_riverpod/flutter_riverpod.dart';
  3. import 'package:go_router/go_router.dart';
  4. import 'package:tdesign_flutter/tdesign_flutter.dart';
  5. import '../../core/utils/responsive.dart';
  6. import '../../shared/widgets/action_bar.dart';
  7. import '../../shared/widgets/form_section.dart';
  8. import '../../core/i18n/app_localizations.dart';
  9. import '../../core/theme/app_colors_extension.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) => TDAlertDialog.vertical(
  51. title: l10n.get('announcementTypes'),
  52. buttons: _types
  53. .map(
  54. (t) => TDDialogButtonOptions(
  55. title: t,
  56. action: () => Navigator.pop(ctx, t),
  57. ),
  58. )
  59. .toList(),
  60. ),
  61. );
  62. if (result != null) setState(() => _type = result);
  63. }
  64. void _pickExpiryDate() {
  65. final l10n = AppLocalizations.of(context);
  66. final initial = _expiryDate ?? DateTime.now().add(const Duration(days: 30));
  67. TDPicker.showDatePicker(
  68. context,
  69. title: l10n.get('selectExpiryDate'),
  70. useYear: true,
  71. useMonth: true,
  72. useDay: true,
  73. useHour: false,
  74. useMinute: false,
  75. initialDate: [initial.year, initial.month, initial.day],
  76. onConfirm: (selected) {
  77. setState(() {
  78. _expiryDate = DateTime(
  79. selected['year'] ?? initial.year,
  80. selected['month'] ?? initial.month,
  81. selected['day'] ?? initial.day,
  82. );
  83. });
  84. },
  85. );
  86. }
  87. void _showScopeDrawer() {
  88. showModalBottomSheet(
  89. context: context,
  90. isScrollControlled: true,
  91. shape: const RoundedRectangleBorder(
  92. borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
  93. ),
  94. builder: (ctx) => _buildScopeDrawerContent(ctx),
  95. );
  96. }
  97. Widget _buildScopeDrawerContent(BuildContext ctx) {
  98. final l10n = AppLocalizations.of(ctx);
  99. return StatefulBuilder(
  100. builder: (context, setInnerState) {
  101. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  102. return Container(
  103. height: MediaQuery.of(context).size.height * 0.7,
  104. padding: const EdgeInsets.all(16),
  105. child: Column(
  106. crossAxisAlignment: CrossAxisAlignment.start,
  107. children: [
  108. Row(
  109. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  110. children: [
  111. Text(
  112. l10n.get('recipientScope'),
  113. style: TextStyle(
  114. fontSize: 16,
  115. fontWeight: FontWeight.w600,
  116. color: colors.textPrimary,
  117. ),
  118. ),
  119. GestureDetector(
  120. onTap: () => Navigator.pop(context),
  121. child: const Icon(Icons.close, size: 20),
  122. ),
  123. ],
  124. ),
  125. const SizedBox(height: 16),
  126. RadioGroup<int>(
  127. groupValue: _scopeMode,
  128. onChanged: (v) {
  129. setInnerState(() => _scopeMode = v!);
  130. setState(() {});
  131. },
  132. child: Column(
  133. crossAxisAlignment: CrossAxisAlignment.start,
  134. children: [
  135. _buildScopeOption(
  136. context,
  137. l10n.get('allStaff'),
  138. l10n.get('scopeAllStaff'),
  139. 0,
  140. setInnerState,
  141. ),
  142. const SizedBox(height: 8),
  143. _buildScopeOption(
  144. context,
  145. l10n.get('byDept'),
  146. l10n.get('byDeptHint'),
  147. 1,
  148. setInnerState,
  149. ),
  150. const SizedBox(height: 8),
  151. _buildScopeOption(
  152. context,
  153. l10n.get('byUser'),
  154. l10n.get('byUserHint'),
  155. 2,
  156. setInnerState,
  157. ),
  158. ],
  159. ),
  160. ),
  161. if (_scopeMode == 1) ...[
  162. const SizedBox(height: 12),
  163. Text(
  164. l10n.get('selectDept'),
  165. style: TextStyle(fontSize: 13, color: colors.textSecondary),
  166. ),
  167. const SizedBox(height: 4),
  168. ..._mockDepts.map(
  169. (dept) => CheckboxListTile(
  170. title: Text(dept, style: const TextStyle(fontSize: 14)),
  171. value: _selectedDepts.contains(dept),
  172. onChanged: (checked) {
  173. setInnerState(() {
  174. if (checked == true) {
  175. _selectedDepts.add(dept);
  176. } else {
  177. _selectedDepts.remove(dept);
  178. }
  179. });
  180. setState(() {});
  181. },
  182. dense: true,
  183. contentPadding: EdgeInsets.zero,
  184. ),
  185. ),
  186. ],
  187. if (_scopeMode == 2) ...[
  188. const SizedBox(height: 12),
  189. Text(
  190. l10n.get('searchEmployee'),
  191. style: TextStyle(fontSize: 13, color: colors.textSecondary),
  192. ),
  193. const SizedBox(height: 4),
  194. Container(
  195. padding: const EdgeInsets.symmetric(
  196. horizontal: 10,
  197. vertical: 2,
  198. ),
  199. decoration: BoxDecoration(
  200. color: colors.bgPage,
  201. borderRadius: BorderRadius.circular(4),
  202. ),
  203. child: TDInput(hintText: l10n.get('searchEmployeeHint')),
  204. ),
  205. const SizedBox(height: 8),
  206. Text(
  207. l10n.getString('selectedCount', args: {'count': '${_selectedUsers.length}'}),
  208. style: TextStyle(fontSize: 12, color: colors.textSecondary),
  209. ),
  210. ],
  211. const Spacer(),
  212. // 覆盖统计
  213. Container(
  214. padding: const EdgeInsets.all(12),
  215. decoration: BoxDecoration(
  216. color: colors.primaryLight,
  217. borderRadius: BorderRadius.circular(8),
  218. ),
  219. child: Row(
  220. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  221. children: [
  222. Text(
  223. l10n.get('coverageCount'),
  224. style: TextStyle(fontSize: 14, color: colors.textPrimary),
  225. ),
  226. Text(
  227. '${_scopeMode == 0 ? _totalCoverage : (_scopeMode == 1 ? _selectedDepts.length * 15 : _selectedUsers.length)} ${l10n.get('personUnit')}',
  228. style: TextStyle(
  229. fontSize: 16,
  230. fontWeight: FontWeight.w700,
  231. color: colors.primary,
  232. ),
  233. ),
  234. ],
  235. ),
  236. ),
  237. const SizedBox(height: 12),
  238. SizedBox(
  239. width: double.infinity,
  240. child: TDButton(
  241. text: l10n.get('confirm'),
  242. size: TDButtonSize.large,
  243. theme: TDButtonTheme.primary,
  244. isBlock: true,
  245. onTap: () => Navigator.pop(context),
  246. ),
  247. ),
  248. ],
  249. ),
  250. );
  251. },
  252. );
  253. }
  254. Widget _buildScopeOption(
  255. BuildContext context,
  256. String title,
  257. String subtitle,
  258. int mode,
  259. void Function(void Function()) setInnerState,
  260. ) {
  261. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  262. final selected = _scopeMode == mode;
  263. return GestureDetector(
  264. onTap: () {
  265. setInnerState(() => _scopeMode = mode);
  266. setState(() {});
  267. },
  268. child: Container(
  269. padding: const EdgeInsets.all(12),
  270. decoration: BoxDecoration(
  271. color: selected ? colors.primaryLight : colors.bgPage,
  272. borderRadius: BorderRadius.circular(8),
  273. border: Border.all(
  274. color: selected ? colors.primary : Colors.transparent,
  275. ),
  276. ),
  277. child: Row(
  278. children: [
  279. Radio<int>(value: mode, activeColor: colors.primary),
  280. const SizedBox(width: 8),
  281. Column(
  282. crossAxisAlignment: CrossAxisAlignment.start,
  283. children: [
  284. Text(
  285. title,
  286. style: TextStyle(fontSize: 14, color: colors.textPrimary),
  287. ),
  288. Text(
  289. subtitle,
  290. style: TextStyle(fontSize: 12, color: colors.textSecondary),
  291. ),
  292. ],
  293. ),
  294. ],
  295. ),
  296. ),
  297. );
  298. }
  299. void _showPreview() {
  300. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  301. final l10n = AppLocalizations.of(context);
  302. showDialog(
  303. context: context,
  304. barrierDismissible: true,
  305. builder: (ctx) => Dialog(
  306. insetPadding: const EdgeInsets.all(16),
  307. child: Container(
  308. width: double.infinity,
  309. height: MediaQuery.of(context).size.height * 0.85,
  310. padding: const EdgeInsets.all(16),
  311. child: Column(
  312. crossAxisAlignment: CrossAxisAlignment.start,
  313. children: [
  314. Row(
  315. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  316. children: [
  317. Text(
  318. l10n.get('previewTitle'),
  319. style: TextStyle(
  320. fontSize: 16,
  321. fontWeight: FontWeight.w600,
  322. color: colors.textPrimary,
  323. ),
  324. ),
  325. GestureDetector(
  326. onTap: () => Navigator.pop(ctx),
  327. child: const Icon(Icons.close, size: 20),
  328. ),
  329. ],
  330. ),
  331. const TDDivider(),
  332. Expanded(
  333. child: SingleChildScrollView(
  334. child: Column(
  335. crossAxisAlignment: CrossAxisAlignment.start,
  336. children: [
  337. Container(width: 60, height: 4, color: colors.danger),
  338. const SizedBox(height: 16),
  339. Text(
  340. _titleCtrl.text.isEmpty ? l10n.get('titleNotFilled') : _titleCtrl.text,
  341. style: TextStyle(
  342. fontSize: 20,
  343. fontWeight: FontWeight.w700,
  344. color: colors.textPrimary,
  345. ),
  346. ),
  347. const SizedBox(height: 12),
  348. Text(
  349. l10n.getString('typeAndPublishDate', args: {'type': _type}),
  350. style: TextStyle(
  351. fontSize: 13,
  352. color: colors.textSecondary,
  353. ),
  354. ),
  355. const SizedBox(height: 16),
  356. Container(
  357. width: double.infinity,
  358. height: 2,
  359. color: colors.danger,
  360. ),
  361. const SizedBox(height: 16),
  362. Text(
  363. _contentCtrl.text.isEmpty
  364. ? l10n.get('contentNotFilled')
  365. : _contentCtrl.text,
  366. style: TextStyle(
  367. fontSize: 14,
  368. color: colors.textSecondary,
  369. height: 1.7,
  370. ),
  371. ),
  372. ],
  373. ),
  374. ),
  375. ),
  376. ],
  377. ),
  378. ),
  379. ),
  380. );
  381. }
  382. void _confirmPublish() {
  383. final l10n = AppLocalizations.of(context);
  384. showDialog(
  385. context: context,
  386. builder: (ctx) => TDAlertDialog(
  387. title: l10n.get('confirmPublishTitle'),
  388. contentWidget: Text(
  389. l10n.getString(
  390. 'confirmPublishContent',
  391. args: {'title': _titleCtrl.text},
  392. ),
  393. ),
  394. leftBtn: TDDialogButtonOptions(
  395. title: l10n.get('cancel'),
  396. action: () => Navigator.pop(ctx),
  397. ),
  398. rightBtn: TDDialogButtonOptions(
  399. title: l10n.get('confirmPublish'),
  400. action: () {
  401. Navigator.pop(ctx);
  402. TDToast.showText(
  403. l10n.get('announcementPublished'),
  404. context: context,
  405. );
  406. context.pop();
  407. },
  408. ),
  409. ),
  410. );
  411. }
  412. void _saveDraft() {
  413. final l10n = AppLocalizations.of(context);
  414. TDToast.showText(l10n.get('draftSavedToast'), context: context);
  415. }
  416. void _pickAttachment() {
  417. final l10n = AppLocalizations.of(context);
  418. TDToast.showText(l10n.get('attachmentPicker'), context: context);
  419. }
  420. @override
  421. Widget build(BuildContext context) {
  422. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  423. final r = ResponsiveHelper.of(context);
  424. final l10n = AppLocalizations.of(context);
  425. return Column(
  426. children: [
  427. Expanded(
  428. child: Align(
  429. alignment: Alignment.topCenter,
  430. child: ConstrainedBox(
  431. constraints: BoxConstraints(maxWidth: r.formMaxWidth),
  432. child: SingleChildScrollView(
  433. padding: const EdgeInsets.symmetric(vertical: 8),
  434. child: Column(
  435. children: [
  436. // 基本信息
  437. FormSection(
  438. title: l10n.get('basicInfo'),
  439. children: [
  440. Container(
  441. padding: const EdgeInsets.symmetric(
  442. horizontal: 10,
  443. vertical: 4,
  444. ),
  445. decoration: BoxDecoration(
  446. color: colors.bgPage,
  447. borderRadius: BorderRadius.circular(4),
  448. ),
  449. child: TDInput(
  450. controller: _titleCtrl,
  451. hintText: l10n.get('enterTitle'),
  452. ),
  453. ),
  454. const SizedBox(height: 12),
  455. GestureDetector(
  456. onTap: _pickType,
  457. child: Container(
  458. padding: const EdgeInsets.symmetric(
  459. horizontal: 10,
  460. vertical: 12,
  461. ),
  462. decoration: BoxDecoration(
  463. color: colors.bgPage,
  464. borderRadius: BorderRadius.circular(4),
  465. ),
  466. child: Row(
  467. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  468. children: [
  469. Text(
  470. _type,
  471. style: TextStyle(
  472. fontSize: 14,
  473. color: colors.textPrimary,
  474. ),
  475. ),
  476. Icon(
  477. Icons.chevron_right,
  478. size: 14,
  479. color: colors.textPlaceholder,
  480. ),
  481. ],
  482. ),
  483. ),
  484. ),
  485. ],
  486. ),
  487. const SizedBox(height: 8),
  488. // 公告正文
  489. FormSection(
  490. title: l10n.get('announcementContent'),
  491. children: [
  492. Container(
  493. padding: const EdgeInsets.all(12),
  494. decoration: BoxDecoration(
  495. color: colors.bgPage,
  496. borderRadius: BorderRadius.circular(4),
  497. ),
  498. height: 200,
  499. child: TDInput(
  500. controller: _contentCtrl,
  501. maxLines: null,
  502. hintText: l10n.get('enterContent'),
  503. ),
  504. ),
  505. ],
  506. ),
  507. const SizedBox(height: 8),
  508. // 附件
  509. FormSection(
  510. title: l10n.get('attachments'),
  511. actionText: _attachments.length >= _maxAttachments
  512. ? l10n.get('limitReached')
  513. : l10n.get('add'),
  514. showAction: _attachments.length < _maxAttachments,
  515. actionIcon: Icons.attach_file,
  516. onActionTap: _pickAttachment,
  517. children: [
  518. if (_attachments.isEmpty)
  519. Text(
  520. l10n.get('attachmentLimit'),
  521. style: TextStyle(
  522. fontSize: 12,
  523. color: colors.textPlaceholder,
  524. ),
  525. )
  526. else
  527. Wrap(
  528. spacing: 8,
  529. runSpacing: 8,
  530. children: _attachments.asMap().entries.map((entry) {
  531. return TDTag(
  532. entry.value,
  533. size: TDTagSize.medium,
  534. needCloseIcon: true,
  535. onCloseTap: () {
  536. setState(
  537. () => _attachments.removeAt(entry.key),
  538. );
  539. },
  540. );
  541. }).toList(),
  542. ),
  543. ],
  544. ),
  545. const SizedBox(height: 8),
  546. // 发布设置
  547. FormSection(
  548. title: l10n.get('publishSettings'),
  549. children: [
  550. _buildSwitchRow(l10n.get('pinAnnouncement'), _isTop, (
  551. v,
  552. ) {
  553. setState(() => _isTop = v);
  554. }),
  555. TDDivider(height: 1, color: colors.border),
  556. GestureDetector(
  557. onTap: _pickExpiryDate,
  558. child: Container(
  559. height: 44,
  560. alignment: Alignment.centerLeft,
  561. child: Row(
  562. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  563. children: [
  564. Text(
  565. l10n.get('validUntil'),
  566. style: TextStyle(
  567. fontSize: 14,
  568. color: colors.textSecondary,
  569. ),
  570. ),
  571. Text(
  572. _expiryDate != null
  573. ? '${_expiryDate!.year}-${_expiryDate!.month.toString().padLeft(2, '0')}-${_expiryDate!.day.toString().padLeft(2, '0')}'
  574. : l10n.get('expiryNever'),
  575. style: TextStyle(
  576. fontSize: 14,
  577. color: _expiryDate != null
  578. ? colors.primary
  579. : colors.textPlaceholder,
  580. ),
  581. ),
  582. Icon(
  583. Icons.chevron_right,
  584. size: 14,
  585. color: colors.textPlaceholder,
  586. ),
  587. ],
  588. ),
  589. ),
  590. ),
  591. TDDivider(height: 1, color: colors.border),
  592. GestureDetector(
  593. onTap: _showScopeDrawer,
  594. child: Container(
  595. height: 44,
  596. alignment: Alignment.centerLeft,
  597. child: Row(
  598. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  599. children: [
  600. Text(
  601. l10n.get('recipientScope'),
  602. style: TextStyle(
  603. fontSize: 14,
  604. color: colors.textSecondary,
  605. ),
  606. ),
  607. Row(
  608. mainAxisSize: MainAxisSize.min,
  609. children: [
  610. Text(
  611. _scopeMode == 0
  612. ? l10n.get('allStaff')
  613. : _scopeMode == 1
  614. ? '${l10n.get('byDept')}(${_selectedDepts.length})'
  615. : '${l10n.get('byUser')}(${_selectedUsers.length})',
  616. style: TextStyle(
  617. fontSize: 14,
  618. color: colors.primary,
  619. ),
  620. ),
  621. Text(
  622. l10n.get('coverageCount'),
  623. style: TextStyle(
  624. fontSize: 12,
  625. color: colors.textPlaceholder,
  626. ),
  627. ),
  628. const SizedBox(width: 4),
  629. Icon(
  630. Icons.chevron_right,
  631. size: 14,
  632. color: colors.textPlaceholder,
  633. ),
  634. ],
  635. ),
  636. ],
  637. ),
  638. ),
  639. ),
  640. ],
  641. ),
  642. const SizedBox(height: 80),
  643. ],
  644. ),
  645. ),
  646. ),
  647. ),
  648. ),
  649. ActionBar(
  650. centerLabel: l10n.get('saveDraft'),
  651. rightLabel: l10n.get('publish'),
  652. showLeft: false,
  653. onCenterTap: _saveDraft,
  654. onRightTap: _confirmPublish,
  655. ),
  656. ],
  657. );
  658. }
  659. Widget _buildSwitchRow(
  660. String label,
  661. bool value,
  662. ValueChanged<bool> onChanged,
  663. ) {
  664. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  665. return Container(
  666. height: 44,
  667. alignment: Alignment.centerLeft,
  668. child: Row(
  669. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  670. children: [
  671. Text(
  672. label,
  673. style: TextStyle(fontSize: 14, color: colors.textSecondary),
  674. ),
  675. TDSwitch(
  676. isOn: value,
  677. onChanged: (v) {
  678. onChanged(v);
  679. return v;
  680. },
  681. ),
  682. ],
  683. ),
  684. );
  685. }
  686. }