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 "<lang>-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
This commit is contained in:
MHSanaei 2026-05-22 16:33:00 +02:00
parent b72212bbb7
commit f929ea4b14
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
10 changed files with 211 additions and 65 deletions

View file

@ -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",

View file

@ -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",

View file

@ -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;
}

View file

@ -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 (
<div className={`jdp-wrap${isDark ? ' jdp-dark' : ''}${isUltra ? ' jdp-ultra' : ''}${disabled ? ' jdp-disabled' : ''}`}>
<PersianDateTimePicker
value={value ? value.valueOf() : null}
onChange={(next: number | string | null) => {
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}
/>
</div>
);
}
return (
<DatePicker
value={value}

36
frontend/src/env.d.ts vendored
View file

@ -27,3 +27,39 @@ interface Window {
X_UI_CUR_VER?: string;
__SUB_PAGE_DATA__?: SubPageData;
}
declare module 'persian-calendar-suite' {
import type { ComponentType, ReactNode } from 'react';
type DateInput = string | number | null;
type OutputFormat = 'iso' | 'shamsi' | 'gregorian' | 'hijri' | 'timestamp';
interface PersianDateTimePickerProps {
value?: DateInput;
onChange?: (value: number | string | null) => void;
defaultValue?: string | number | 'now' | null;
showTime?: boolean;
minuteStep?: number;
outputFormat?: OutputFormat;
showFooter?: boolean;
theme?: Record<string, unknown>;
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<PersianDateTimePickerProps>;
export const PersianCalendar: ComponentType<Record<string, unknown>>;
export const PersianDateRangePicker: ComponentType<Record<string, unknown>>;
export const PersianTimePicker: ComponentType<Record<string, unknown>>;
export const PersianTimeline: ComponentType<Record<string, unknown>>;
}

View file

@ -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({
</Form.Item>
) : (
<Form.Item label={t('pages.clients.expiryTime')}>
<DatePicker
<DateTimePicker
value={form.expiryDate}
onChange={(d) => update('expiryDate', d || null)}
showTime
style={{ width: '100%' }}
/>
</Form.Item>
)}

View file

@ -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<T = unknown> {
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<string[]>([]);

View file

@ -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) {

View file

@ -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(

View file

@ -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
}