mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
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:
parent
86d6929f0c
commit
83234e2781
7 changed files with 44 additions and 12 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>`
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue