feat: render dates in Jalali when Calendar Type is jalalian

- IntlUtil.formatDate accepts an optional calendar arg; appends the
  BCP-47 -u-ca-persian extension so Intl renders Jalali across all UI
  languages, not just fa-IR
- Plumb the panel's datepicker setting into the SubPage via the Go
  injection (window.__SUB_PAGE_DATA__.datepicker)
- Panel pages (inbound list/info, client row, xray log) read the same
  setting through the useDatepicker composable so the whole panel
  stays consistent

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MHSanaei 2026-05-09 00:34:07 +02:00
parent 86d6929f0c
commit 83234e2781
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
7 changed files with 44 additions and 12 deletions

View file

@ -13,6 +13,9 @@ import { Modal } from 'ant-design-vue';
import { SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
import InfinityIcon from '@/components/InfinityIcon.vue';
import { useDatepicker } from '@/composables/useDatepicker.js';
const { datepicker } = useDatepicker();
const { t } = useI18n();
@ -85,7 +88,7 @@ function isClientOnline(email) {
function lastOnlineLabel(email) {
const ts = props.lastOnlineMap[email];
if (!ts) return '-';
return IntlUtil.formatDate(ts);
return IntlUtil.formatDate(ts, datepicker.value);
}
function statsProgress(email) {
@ -299,7 +302,7 @@ function rowKey(client) {
<a-popover>
<template #content>
<span v-if="client.expiryTime < 0">{{ t('pages.client.delayedStart') }}</span>
<span v-else>{{ IntlUtil.formatDate(client.expiryTime) }}</span>
<span v-else>{{ IntlUtil.formatDate(client.expiryTime, datepicker) }}</span>
</template>
<div class="usage-bar">
<span class="usage-text">{{ IntlUtil.formatRelativeTime(client.expiryTime) }}</span>

View file

@ -14,8 +14,10 @@ import {
} from '@/utils';
import { Inbound, Protocols } from '@/models/inbound.js';
import InfinityIcon from '@/components/InfinityIcon.vue';
import { useDatepicker } from '@/composables/useDatepicker.js';
const { t } = useI18n();
const { datepicker } = useDatepicker();
// One modal handles every protocol's info / share view because the
// legacy template did the same. The big v-if forks at the top decide
@ -103,7 +105,7 @@ function getRemainingStats() {
function formatLastOnline(email) {
const ts = props.lastOnlineMap[email];
if (!ts) return '-';
return IntlUtil.formatDate(ts);
return IntlUtil.formatDate(ts, datepicker.value);
}
// === IP log ========================================================
@ -619,14 +621,14 @@ const showSubscriptionTab = computed(
<tr>
<td>{{ t('pages.inbounds.createdAt') }}</td>
<td>
<a-tag v-if="clientSettings.created_at">{{ IntlUtil.formatDate(clientSettings.created_at) }}</a-tag>
<a-tag v-if="clientSettings.created_at">{{ IntlUtil.formatDate(clientSettings.created_at, datepicker) }}</a-tag>
<a-tag v-else>-</a-tag>
</td>
</tr>
<tr>
<td>{{ t('pages.inbounds.updatedAt') }}</td>
<td>
<a-tag v-if="clientSettings.updated_at">{{ IntlUtil.formatDate(clientSettings.updated_at) }}</a-tag>
<a-tag v-if="clientSettings.updated_at">{{ IntlUtil.formatDate(clientSettings.updated_at, datepicker) }}</a-tag>
<a-tag v-else>-</a-tag>
</td>
</tr>
@ -688,7 +690,7 @@ const showSubscriptionTab = computed(
<td>
<a-tag v-if="clientSettings.expiryTime > 0"
:color="ColorUtils.usageColor(Date.now(), expireDiff, clientSettings.expiryTime)">{{
IntlUtil.formatDate(clientSettings.expiryTime) }}</a-tag>
IntlUtil.formatDate(clientSettings.expiryTime, datepicker) }}</a-tag>
<a-tag v-else-if="clientSettings.expiryTime < 0" color="green">
{{ clientSettings.expiryTime / -86400000 }} {{ t('day') }}
</a-tag>

View file

@ -30,6 +30,9 @@ import { DBInbound } from '@/models/dbinbound.js';
import { Inbound } from '@/models/inbound.js';
import InfinityIcon from '@/components/InfinityIcon.vue';
import ClientRowTable from './ClientRowTable.vue';
import { useDatepicker } from '@/composables/useDatepicker.js';
const { datepicker } = useDatepicker();
const { t } = useI18n();
@ -469,7 +472,7 @@ function showQrCodeMenu(dbInbound) {
<!-- ============== Expiry ============== -->
<template v-else-if="column.key === 'expiryTime'">
<a-popover v-if="record.expiryTime > 0">
<template #content>{{ IntlUtil.formatDate(record.expiryTime) }}</template>
<template #content>{{ IntlUtil.formatDate(record.expiryTime, datepicker) }}</template>
<a-tag :color="ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)" style="min-width: 50px">
{{ IntlUtil.formatRelativeTime(record.expiryTime) }}
</a-tag>

View file

@ -4,8 +4,10 @@ import { useI18n } from 'vue-i18n';
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons-vue';
import { HttpUtil, FileManager, IntlUtil, PromiseUtil } from '@/utils';
import { useDatepicker } from '@/composables/useDatepicker.js';
const { t } = useI18n();
const { datepicker } = useDatepicker();
const props = defineProps({
open: { type: Boolean, default: false },
@ -44,7 +46,7 @@ function formatLogs(lines) {
const emailCell = log.Email ? `<td>${escapeHtml(log.Email)}</td>` : '<td></td>';
out += `<tr${rowStyle}>`
+ `<td><b>${escapeHtml(IntlUtil.formatDate(log.DateTime))}</b></td>`
+ `<td><b>${escapeHtml(IntlUtil.formatDate(log.DateTime, datepicker.value))}</b></td>`
+ `<td>${escapeHtml(log.FromAddress)}</td>`
+ `<td>${escapeHtml(log.ToAddress)}</td>`
+ `<td>${escapeHtml(log.Inbound)}</td>`

View file

@ -39,6 +39,9 @@ const subUrl = subData.subUrl || '';
const subJsonUrl = subData.subJsonUrl || '';
const subClashUrl = subData.subClashUrl || '';
const links = Array.isArray(subData.links) ? subData.links : [];
// Panel's "Calendar Type" setting; controls whether expiry / lastOnline
// render in Gregorian or Jalali on this standalone subscription page.
const datepicker = subData.datepicker || 'gregorian';
// Derived state ===============================================
const isUnlimited = computed(() => totalByte <= 0 && expireMs === 0);
@ -239,12 +242,12 @@ const _antdAlgorithm = antdTheme.darkAlgorithm;
{{ remained }}
</a-descriptions-item>
<a-descriptions-item :label="t('lastOnline')">
<template v-if="lastOnlineMs > 0">{{ IntlUtil.formatDate(lastOnlineMs) }}</template>
<template v-if="lastOnlineMs > 0">{{ IntlUtil.formatDate(lastOnlineMs, datepicker) }}</template>
<template v-else>-</template>
</a-descriptions-item>
<a-descriptions-item :label="t('subscription.expiry')">
<template v-if="expireMs === 0">{{ t('subscription.noExpiry') }}</template>
<template v-else>{{ IntlUtil.formatDate(expireMs) }}</template>
<template v-else>{{ IntlUtil.formatDate(expireMs, datepicker) }}</template>
</a-descriptions-item>
</a-descriptions>

View file

@ -889,8 +889,16 @@ export class FileManager {
}
export class IntlUtil {
static formatDate(date) {
// 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.
static formatDate(date, calendar = "gregorian") {
const language = LanguageManager.getLanguage()
const locale = calendar === "jalalian"
? `${language}-u-ca-persian`
: language
let intlOptions = {
year: "numeric",
@ -902,7 +910,7 @@ export class IntlUtil {
}
const intl = new Intl.DateTimeFormat(
language,
locale,
intlOptions
)

View file

@ -10,6 +10,7 @@ import (
"strings"
webpkg "github.com/mhsanaei/3x-ui/v2/web"
"github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/gin-gonic/gin"
)
@ -33,6 +34,7 @@ type SUBController struct {
subService *SubService
subJsonService *SubJsonService
subClashService *SubClashService
settingService service.SettingService
}
// NewSUBController creates a new subscription controller with the given configuration.
@ -172,6 +174,14 @@ func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageD
// object on mount. PageData fields are already in the shape the Vue
// component expects, plus a `links` array carrying the rendered
// share URLs.
// The panel's "Calendar Type" setting decides whether the SubPage
// renders dates in Gregorian or Jalali — surface it here so the SPA
// can match the rest of the panel without a round-trip.
datepicker, _ := a.settingService.GetDatepicker()
if datepicker == "" {
datepicker = "gregorian"
}
subData := map[string]any{
"sId": page.SId,
"enabled": page.Enabled,
@ -189,6 +199,7 @@ func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageD
"subJsonUrl": page.SubJsonUrl,
"subClashUrl": page.SubClashUrl,
"links": page.Result,
"datepicker": datepicker,
}
subDataJSON, err := json.Marshal(subData)
if err != nil {