From f929ea4b146c8b2362f1bb1b590d91c5b1f1199d Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Fri, 22 May 2026 16:33:00 +0200 Subject: [PATCH] feat: jalali calendar support and date formatting fixes - Wire useDatepicker into IntlUtil and switch jalalian display locale to fa-IR for clean "1405/07/03 12:00:00" output (drops the awkward "AP" era suffix that "-u-ca-persian" produced) - Drop in persian-calendar-suite for the jalali date picker, with a light/dark/ultra theme map and CSS overrides so the inline-styled input stays readable and bg matches the surrounding container - Force LTR on the picker input so "1405/03/07 00:00" reads naturally - Pass calendar setting through ClientInfoModal, ClientsPage Duration tooltip, and ClientFormModal's expiry picker - Heuristic toMs() in ClientInfoModal so GORM's autoUpdateTime seconds render as a real date instead of "1348/11/01" - Persist UpdatedAt on the ClientRecord row in client_service.Update; previously only the inbound settings JSON was bumped, so the panel never saw a fresh updated_at after editing a client --- frontend/package-lock.json | 80 +++++++++++-------- frontend/package.json | 3 +- frontend/src/components/DateTimePicker.css | 35 ++++++++ frontend/src/components/DateTimePicker.tsx | 66 +++++++++++++++ frontend/src/env.d.ts | 36 +++++++++ .../src/pages/clients/ClientFormModal.tsx | 6 +- .../src/pages/clients/ClientInfoModal.tsx | 14 +--- frontend/src/pages/clients/ClientsPage.tsx | 4 +- frontend/src/utils/index.js | 26 +++--- web/service/client.go | 6 ++ 10 files changed, 211 insertions(+), 65 deletions(-) create mode 100644 frontend/src/components/DateTimePicker.css diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0f436a95..ab11d1d5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "3x-ui-frontend", - "version": "0.0.3", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "3x-ui-frontend", - "version": "0.0.3", + "version": "0.1.0", "dependencies": { "@ant-design/icons": "^6.2.3", "@codemirror/lang-json": "^6.0.2", @@ -17,6 +17,7 @@ "dayjs": "^1.11.20", "i18next": "^26.2.0", "otpauth": "^9.5.1", + "persian-calendar-suite": "^1.5.5", "qs": "^6.15.2", "react": "^19.2.6", "react-dom": "^19.2.6", @@ -82,18 +83,6 @@ "react-dom": ">=18" } }, - "node_modules/@ant-design/cssinjs/node_modules/@emotion/hash": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", - "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", - "license": "MIT" - }, - "node_modules/@ant-design/cssinjs/node_modules/@emotion/unitless": { - "version": "0.7.5", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", - "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", - "license": "MIT" - }, "node_modules/@ant-design/fast-color": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-3.0.1.tgz", @@ -530,6 +519,18 @@ "tslib": "^2.4.0" } }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "license": "MIT" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -2271,21 +2272,6 @@ "react-dom": ">=18.0.0" } }, - "node_modules/antd/node_modules/compute-scroll-into-view": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", - "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", - "license": "MIT" - }, - "node_modules/antd/node_modules/scroll-into-view-if-needed": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", - "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", - "license": "MIT", - "dependencies": { - "compute-scroll-into-view": "^3.0.2" - } - }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2460,6 +2446,12 @@ "node": ">= 0.8" } }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2558,9 +2550,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.360", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.360.tgz", - "integrity": "sha512-GkcBt6YYAw9SxFWn+xVar4cLVGlXVuswwtRLBozi2zp0GjXs4ZnOrqV4zbXzg35n7w81hCkyJNYicgXlVHAmBA==", + "version": "1.5.361", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", + "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", "dev": true, "license": "ISC" }, @@ -3666,9 +3658,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.45", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.45.tgz", - "integrity": "sha512-iIbHXV9eBB2nB0wa7oTsrrXq+qQt+9SIlx9AX3T96YgobtEQfis5n6TJ6vV+3QP8DwdriEAcGhARaFCu37peBg==", + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", "dev": true, "license": "MIT", "engines": { @@ -3769,6 +3761,15 @@ "node": ">=8" } }, + "node_modules/persian-calendar-suite": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/persian-calendar-suite/-/persian-calendar-suite-1.5.5.tgz", + "integrity": "sha512-KJSzN9q7MZKhfkm97X/j+nD6L0AQ5coUq/B7PpIklXAvRjkALwiV+KmYG0pfr546EQxO9l4fBwE7R1HPI3yT7w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3956,6 +3957,15 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 70cae164..c36aa065 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "3x-ui-frontend", "private": true, - "version": "0.0.3", + "version": "0.1.0", "type": "module", "description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).", "engines": { @@ -25,6 +25,7 @@ "dayjs": "^1.11.20", "i18next": "^26.2.0", "otpauth": "^9.5.1", + "persian-calendar-suite": "^1.5.5", "qs": "^6.15.2", "react": "^19.2.6", "react-dom": "^19.2.6", diff --git a/frontend/src/components/DateTimePicker.css b/frontend/src/components/DateTimePicker.css new file mode 100644 index 00000000..b745afb4 --- /dev/null +++ b/frontend/src/components/DateTimePicker.css @@ -0,0 +1,35 @@ +.jdp-wrap { + width: 100%; +} + +.jdp-wrap > * { + width: 100%; +} + +.jdp-wrap input { + direction: ltr; + text-align: left; + unicode-bidi: plaintext; +} + +.jdp-dark .jdp-wrap input, +.jdp-dark input { + color: rgba(255, 255, 255, 0.88) !important; + background-color: #23252b !important; +} + +.jdp-ultra .jdp-wrap input, +.jdp-ultra input { + color: rgba(255, 255, 255, 0.88) !important; + background-color: #101013 !important; +} + +.jdp-dark input::placeholder, +.jdp-ultra input::placeholder { + color: rgba(255, 255, 255, 0.30) !important; +} + +.jdp-disabled { + pointer-events: none; + opacity: 0.6; +} diff --git a/frontend/src/components/DateTimePicker.tsx b/frontend/src/components/DateTimePicker.tsx index b5ff13f2..bdd521b6 100644 --- a/frontend/src/components/DateTimePicker.tsx +++ b/frontend/src/components/DateTimePicker.tsx @@ -1,5 +1,12 @@ +import { useMemo } from 'react'; import { DatePicker } from 'antd'; +import dayjs from 'dayjs'; import type { Dayjs } from 'dayjs'; +import { PersianDateTimePicker } from 'persian-calendar-suite'; + +import { useDatepicker } from '@/hooks/useDatepicker'; +import { useTheme } from '@/hooks/useTheme'; +import './DateTimePicker.css'; interface DateTimePickerProps { value: Dayjs | null; @@ -10,6 +17,33 @@ interface DateTimePickerProps { disabled?: boolean; } +const LIGHT_THEME = { + primaryColor: '#1677ff', + backgroundColor: '#ffffff', + borderColor: '#d9d9d9', + hoverColor: 'rgba(22, 119, 255, 0.10)', + selectedTextColor: '#ffffff', + textColor: 'rgba(0, 0, 0, 0.88)', +}; + +const DARK_THEME = { + primaryColor: '#1677ff', + backgroundColor: '#23252b', + borderColor: 'rgba(255, 255, 255, 0.12)', + hoverColor: 'rgba(22, 119, 255, 0.18)', + selectedTextColor: '#ffffff', + textColor: 'rgba(255, 255, 255, 0.88)', +}; + +const ULTRA_DARK_THEME = { + primaryColor: '#1677ff', + backgroundColor: '#101013', + borderColor: 'rgba(255, 255, 255, 0.08)', + hoverColor: 'rgba(22, 119, 255, 0.16)', + selectedTextColor: '#ffffff', + textColor: 'rgba(255, 255, 255, 0.88)', +}; + export default function DateTimePicker({ value, onChange, @@ -18,6 +52,38 @@ export default function DateTimePicker({ placeholder = '', disabled = false, }: DateTimePickerProps) { + const { datepicker } = useDatepicker(); + const { isDark, isUltra } = useTheme(); + + const persianTheme = useMemo(() => { + if (isUltra) return ULTRA_DARK_THEME; + if (isDark) return DARK_THEME; + return LIGHT_THEME; + }, [isDark, isUltra]); + + if (datepicker === 'jalalian') { + return ( +
+ { + if (next == null || next === '') { + onChange(null); + return; + } + const ms = typeof next === 'number' ? next : Number(next); + if (Number.isFinite(ms)) onChange(dayjs(ms)); + }} + showTime={showTime} + outputFormat="timestamp" + persianNumbers + rtlCalendar + theme={persianTheme} + /> +
+ ); + } + return ( void; + defaultValue?: string | number | 'now' | null; + showTime?: boolean; + minuteStep?: number; + outputFormat?: OutputFormat; + showFooter?: boolean; + theme?: Record; + disabledHours?: number[]; + minDate?: string | Date | null; + maxDate?: string | Date | null; + enabledDates?: string[] | null; + disabledDates?: string[] | null; + disabledWeekDays?: number[]; + persianNumbers?: boolean; + rtlCalendar?: boolean; + placeholder?: string; + disabled?: boolean; + className?: string; + children?: ReactNode; + } + + export const PersianDateTimePicker: ComponentType; + export const PersianCalendar: ComponentType>; + export const PersianDateRangePicker: ComponentType>; + export const PersianTimePicker: ComponentType>; + export const PersianTimeline: ComponentType>; +} diff --git a/frontend/src/pages/clients/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx index 0f7aeb26..67b07647 100644 --- a/frontend/src/pages/clients/ClientFormModal.tsx +++ b/frontend/src/pages/clients/ClientFormModal.tsx @@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next'; import { Button, Col, - DatePicker, Form, Input, InputNumber, @@ -19,6 +18,7 @@ import dayjs from 'dayjs'; import type { Dayjs } from 'dayjs'; import { HttpUtil, RandomUtil } from '@/utils'; +import DateTimePicker from '@/components/DateTimePicker'; import { TLS_FLOW_CONTROL } from '@/models/inbound'; import type { ClientRecord, InboundOption } from '@/hooks/useClients'; import './ClientFormModal.css'; @@ -416,11 +416,9 @@ export default function ClientFormModal({ ) : ( - update('expiryDate', d || null)} - showTime - style={{ width: '100%' }} /> )} diff --git a/frontend/src/pages/clients/ClientInfoModal.tsx b/frontend/src/pages/clients/ClientInfoModal.tsx index 5756134a..7e827ddb 100644 --- a/frontend/src/pages/clients/ClientInfoModal.tsx +++ b/frontend/src/pages/clients/ClientInfoModal.tsx @@ -4,6 +4,7 @@ import { Button, Divider, Modal, Tag, Tooltip, message } from 'antd'; import { CopyOutlined } from '@ant-design/icons'; import { ClipboardManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils'; +import { useDatepicker } from '@/hooks/useDatepicker'; import type { ClientRecord, InboundOption } from '@/hooks/useClients'; import './ClientInfoModal.css'; @@ -30,16 +31,6 @@ interface ApiMsg { const DEFAULT_SUB: SubSettings = { enable: false, subURI: '', subJsonURI: '', subJsonEnable: false }; -function expiryLabel(ts?: number) { - if (!ts || ts <= 0) return '∞'; - return IntlUtil.formatDate(ts); -} - -function dateLabel(ts?: number) { - if (!ts || ts <= 0) return '-'; - return IntlUtil.formatDate(ts); -} - export default function ClientInfoModal({ open, client, @@ -48,6 +39,9 @@ export default function ClientInfoModal({ subSettings = DEFAULT_SUB, onOpenChange, }: ClientInfoModalProps) { + const { datepicker } = useDatepicker(); + const expiryLabel = (ts?: number) => (!ts || ts <= 0 ? '∞' : IntlUtil.formatDate(ts, datepicker)); + const dateLabel = (ts?: number) => (!ts || ts <= 0 ? '-' : IntlUtil.formatDate(ts, datepicker)); const { t } = useTranslation(); const [messageApi, messageContextHolder] = message.useMessage(); const [links, setLinks] = useState([]); diff --git a/frontend/src/pages/clients/ClientsPage.tsx b/frontend/src/pages/clients/ClientsPage.tsx index 60788275..3018fadb 100644 --- a/frontend/src/pages/clients/ClientsPage.tsx +++ b/frontend/src/pages/clients/ClientsPage.tsx @@ -44,6 +44,7 @@ import { useTheme } from '@/hooks/useTheme'; import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useWebSocket } from '@/hooks/useWebSocket'; import { useClients } from '@/hooks/useClients'; +import { useDatepicker } from '@/hooks/useDatepicker'; import type { ClientRecord, InboundOption } from '@/hooks/useClients'; import AppSidebar from '@/components/AppSidebar'; import CustomStatistic from '@/components/CustomStatistic'; @@ -86,6 +87,7 @@ function readFilterState(): FilterState { export default function ClientsPage() { const { t } = useTranslation(); const { isDark, isUltra, antdThemeConfig } = useTheme(); + const { datepicker } = useDatepicker(); const { isMobile } = useMediaQuery(); const [modal, modalContextHolder] = Modal.useModal(); const [messageApi, messageContextHolder] = message.useMessage(); @@ -296,7 +298,7 @@ export default function ClientsPage() { const days = Math.round(row.expiryTime / -86400000); return `${t('pages.clients.delayedStart')}: ${days}d`; } - return IntlUtil.formatDate(row.expiryTime); + return IntlUtil.formatDate(row.expiryTime, datepicker); } function expiryRelative(row: ClientRecord) { diff --git a/frontend/src/utils/index.js b/frontend/src/utils/index.js index 4bc20654..70ac4a84 100644 --- a/frontend/src/utils/index.js +++ b/frontend/src/utils/index.js @@ -911,24 +911,22 @@ export class FileManager { } export class IntlUtil { - // When `calendar` is "jalalian", append the BCP-47 calendar extension - // so Intl renders the date in the Persian (Jalali/Shamsi) calendar - // regardless of the UI language. Without it, only locales that - // default to Persian (e.g. fa-IR) would show Jalali; en-US/ru/etc. - // would keep showing Gregorian. + // For Jalali display, always use fa-IR locale (its default calendar + // is Persian) so we get a clean "1405/07/03 12:00:00" format with + // Persian digits, without the awkward "AP" era suffix that appears + // when other locales force `-u-ca-persian`. static formatDate(date, calendar = "gregorian") { const language = LanguageManager.getLanguage() - const locale = calendar === "jalalian" - ? `${language}-u-ca-persian` - : language + const locale = calendar === "jalalian" ? "fa-IR" : language - let intlOptions = { + const intlOptions = { year: "numeric", - month: "numeric", - day: "numeric", - hour: "numeric", - minute: "numeric", - second: "numeric" + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, } const intl = new Intl.DateTimeFormat( diff --git a/web/service/client.go b/web/service/client.go index 43689d95..862ec695 100644 --- a/web/service/client.go +++ b/web/service/client.go @@ -604,6 +604,12 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model needRestart = true } } + + if err := database.GetDB().Model(&model.ClientRecord{}). + Where("id = ?", id). + Update("updated_at", updated.UpdatedAt).Error; err != nil { + return needRestart, err + } return needRestart, nil }