TelegramTab.tsx 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. import { useMemo, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
  4. import { BellOutlined, SettingOutlined } from '@ant-design/icons';
  5. import { LanguageManager } from '@/utils';
  6. import type { AllSetting } from '@/models/setting';
  7. import { SettingListItem } from '@/components/ui';
  8. import { useMediaQuery } from '@/hooks/useMediaQuery';
  9. import { catTabLabel } from './catTabLabel';
  10. interface TelegramTabProps {
  11. allSetting: AllSetting;
  12. updateSetting: (patch: Partial<AllSetting>) => void;
  13. }
  14. // The notification schedule is fed straight to robfig/cron's AddJob (see
  15. // web.go startTask), which accepts @every <duration>, the @hourly/@daily/...
  16. // macros, and full crontab expressions. This builder covers the common cases
  17. // with dropdowns so users don't have to memorise the syntax, while "Custom"
  18. // preserves the raw crontab escape hatch.
  19. type Unit = 's' | 'm' | 'h';
  20. type Macro = '@hourly' | '@daily' | '@weekly' | '@monthly';
  21. type Mode = 'every' | Macro | 'custom';
  22. const MACROS: Macro[] = ['@hourly', '@daily', '@weekly', '@monthly'];
  23. const EVERY_RE = /^@every\s+(\d+)\s*([smh])$/i;
  24. interface RunTime {
  25. mode: Mode;
  26. num: number;
  27. unit: Unit;
  28. custom: string;
  29. }
  30. function parseRunTime(raw: string): RunTime {
  31. const v = (raw ?? '').trim();
  32. const m = v.match(EVERY_RE);
  33. if (m) {
  34. return { mode: 'every', num: Math.max(1, Number(m[1]) || 1), unit: m[2].toLowerCase() as Unit, custom: '' };
  35. }
  36. if ((MACROS as string[]).includes(v)) {
  37. return { mode: v as Macro, num: 1, unit: 'h', custom: '' };
  38. }
  39. return { mode: 'custom', num: 1, unit: 'h', custom: v };
  40. }
  41. function composeRunTime(s: RunTime): string {
  42. if (s.mode === 'every') return `@every ${Math.max(1, s.num || 1)}${s.unit}`;
  43. if (s.mode === 'custom') return s.custom;
  44. return s.mode;
  45. }
  46. // The panel's cron runs with seconds enabled (cron.WithSeconds() in web.go), so
  47. // crontab expressions are 6-field: "second minute hour day month weekday". When
  48. // the user drops into Custom we seed the box with the crontab equivalent of the
  49. // current selection rather than a bare @macro, so they get a real expression to
  50. // edit (and one that the 6-field parser accepts).
  51. function toCrontab(s: RunTime): string {
  52. switch (s.mode) {
  53. case '@hourly': return '0 0 * * * *';
  54. case '@daily': return '0 0 0 * * *';
  55. case '@weekly': return '0 0 0 * * 0';
  56. case '@monthly': return '0 0 0 1 * *';
  57. case 'every': {
  58. const n = Math.max(1, s.num || 1);
  59. if (s.unit === 's') return `*/${n} * * * * *`;
  60. if (s.unit === 'm') return `0 */${n} * * * *`;
  61. return `0 0 */${n} * * *`;
  62. }
  63. default: return s.custom;
  64. }
  65. }
  66. function NotifyTimeField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
  67. const { t } = useTranslation();
  68. // Init once: the Settings tabs only mount after settings are fetched, so the
  69. // incoming value is already the persisted one.
  70. const [state, setState] = useState<RunTime>(() => parseRunTime(value));
  71. function update(patch: Partial<RunTime>) {
  72. const next = { ...state, ...patch };
  73. setState(next);
  74. onChange(composeRunTime(next));
  75. }
  76. function onModeChange(mode: Mode) {
  77. // Seed Custom with the crontab equivalent of the current selection so the
  78. // box starts from a real expression (e.g. "0 0 0 * * *", not "@daily").
  79. if (mode === 'custom' && !state.custom.trim()) {
  80. update({ mode, custom: toCrontab(state) });
  81. } else {
  82. update({ mode });
  83. }
  84. }
  85. const modeOptions = [
  86. { value: 'every', label: t('pages.settings.notifyTime.every') },
  87. { value: '@hourly', label: t('pages.settings.notifyTime.hourly') },
  88. { value: '@daily', label: t('pages.settings.notifyTime.daily') },
  89. { value: '@weekly', label: t('pages.settings.notifyTime.weekly') },
  90. { value: '@monthly', label: t('pages.settings.notifyTime.monthly') },
  91. { value: 'custom', label: t('pages.settings.notifyTime.custom') },
  92. ];
  93. const unitOptions = [
  94. { value: 's', label: t('pages.settings.notifyTime.seconds') },
  95. { value: 'm', label: t('pages.settings.notifyTime.minutes') },
  96. { value: 'h', label: t('pages.settings.notifyTime.hours') },
  97. ];
  98. return (
  99. <Space direction="vertical" size="small" style={{ width: '100%' }}>
  100. <Select<Mode>
  101. style={{ width: '100%' }}
  102. value={state.mode}
  103. options={modeOptions}
  104. onChange={onModeChange}
  105. />
  106. {state.mode === 'every' && (
  107. <Space.Compact style={{ width: '100%' }}>
  108. <InputNumber
  109. min={1}
  110. style={{ width: '50%' }}
  111. value={state.num}
  112. onChange={(v) => update({ num: Math.max(1, Number(v) || 1) })}
  113. />
  114. <Select<Unit>
  115. style={{ width: '50%' }}
  116. value={state.unit}
  117. options={unitOptions}
  118. onChange={(unit) => update({ unit })}
  119. />
  120. </Space.Compact>
  121. )}
  122. {state.mode === 'custom' && (
  123. <Input
  124. value={state.custom}
  125. placeholder="0 30 8 * * *"
  126. onChange={(e) => update({ custom: e.target.value })}
  127. />
  128. )}
  129. </Space>
  130. );
  131. }
  132. export default function TelegramTab({ allSetting, updateSetting }: TelegramTabProps) {
  133. const { t } = useTranslation();
  134. const { isMobile } = useMediaQuery();
  135. const langOptions = useMemo(
  136. () => LanguageManager.supportedLanguages.map((l: { value: string; name: string; icon: string }) => ({
  137. value: l.value,
  138. label: (
  139. <>
  140. <span role="img" aria-label={l.name}>{l.icon}</span>
  141. &nbsp;&nbsp;<span>{l.name}</span>
  142. </>
  143. ),
  144. })),
  145. [],
  146. );
  147. return (
  148. <Tabs defaultActiveKey="1" items={[
  149. {
  150. key: '1',
  151. label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile),
  152. children: (
  153. <>
  154. <SettingListItem paddings="small" title={t('pages.settings.telegramBotEnable')} description={t('pages.settings.telegramBotEnableDesc')}>
  155. <Switch checked={allSetting.tgBotEnable} onChange={(v) => updateSetting({ tgBotEnable: v })} />
  156. </SettingListItem>
  157. <SettingListItem
  158. paddings="small"
  159. title={t('pages.settings.telegramToken')}
  160. description={allSetting.hasTgBotToken ? 'Configured; leave blank to keep current token.' : t('pages.settings.telegramTokenDesc')}
  161. >
  162. <Input.Password
  163. value={allSetting.tgBotToken}
  164. placeholder={allSetting.hasTgBotToken ? 'Configured - enter a new token to replace' : ''}
  165. onChange={(e) => updateSetting({ tgBotToken: e.target.value })}
  166. />
  167. </SettingListItem>
  168. <SettingListItem paddings="small" title={t('pages.settings.telegramChatId')} description={t('pages.settings.telegramChatIdDesc')}>
  169. <Input value={allSetting.tgBotChatId} onChange={(e) => updateSetting({ tgBotChatId: e.target.value })} />
  170. </SettingListItem>
  171. <SettingListItem paddings="small" title={t('pages.settings.telegramBotLanguage')}>
  172. <Select
  173. value={allSetting.tgLang}
  174. onChange={(v) => updateSetting({ tgLang: v })}
  175. style={{ width: '100%' }}
  176. options={langOptions}
  177. />
  178. </SettingListItem>
  179. <SettingListItem paddings="small" title={t('pages.settings.telegramAPIServer')} description={t('pages.settings.telegramAPIServerDesc')}>
  180. <Input value={allSetting.tgBotAPIServer} placeholder="https://api.example.com"
  181. onChange={(e) => updateSetting({ tgBotAPIServer: e.target.value })} />
  182. </SettingListItem>
  183. </>
  184. ),
  185. },
  186. {
  187. key: '2',
  188. label: catTabLabel(<BellOutlined />, t('pages.settings.notifications'), isMobile),
  189. children: (
  190. <>
  191. <SettingListItem paddings="small" title={t('pages.settings.telegramNotifyTime')} description={t('pages.settings.telegramNotifyTimeDesc')}>
  192. <NotifyTimeField value={allSetting.tgRunTime} onChange={(v) => updateSetting({ tgRunTime: v })} />
  193. </SettingListItem>
  194. <SettingListItem paddings="small" title={t('pages.settings.tgNotifyBackup')} description={t('pages.settings.tgNotifyBackupDesc')}>
  195. <Switch checked={allSetting.tgBotBackup} onChange={(v) => updateSetting({ tgBotBackup: v })} />
  196. </SettingListItem>
  197. <SettingListItem paddings="small" title={t('pages.settings.tgNotifyLogin')} description={t('pages.settings.tgNotifyLoginDesc')}>
  198. <Switch checked={allSetting.tgBotLoginNotify} onChange={(v) => updateSetting({ tgBotLoginNotify: v })} />
  199. </SettingListItem>
  200. <SettingListItem paddings="small" title={t('pages.settings.tgNotifyCpu')} description={t('pages.settings.tgNotifyCpuDesc')}>
  201. <InputNumber value={allSetting.tgCpu} min={0} max={100} style={{ width: '100%' }}
  202. onChange={(v) => updateSetting({ tgCpu: Number(v) || 0 })} />
  203. </SettingListItem>
  204. </>
  205. ),
  206. },
  207. ]} />
  208. );
  209. }