announcement_create_page.dart 26 KB

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