fix(frontend): Phase 9 — restore index dashboard, fix login/CSRF, port legacy styles

- Index dashboard regains the 8 cards that were lost in the SPA port
  (3X-UI panel info, Operation Hours, System Load, Usage, Overall Speed,
  Total Data, IP Addresses, Connection Stats), plus a Config button that
  shows the live xray config.json. Version display falls back through
  panelUpdateInfo → window.__X_UI_CUR_VER__ → '?' so dev mode isn't blank.
- Xray config no longer hangs on load: useXraySetting surfaces failures
  instead of leaving a perpetual spinner, and the Vite dev proxy stops
  hijacking POST requests to migrated routes (only GETs get bypassed).
- Inbound page no longer throws __asyncLoader/emitsOptions errors —
  inbound.js was missing imports (NumberFormatter, SizeFormatter,
  Wireguard) and InboundList kept emitting after unmount.
- Login round-trip works after logout: a public /csrf-token endpoint
  bootstraps the SPA before authentication, axios caches the token
  module-level, and the dev 401 handler navigates to /login.html
  instead of reloading the dashboard into a redirect loop.
- legacy.css mirrors the legacy panel's surface/text variables so dark
  and ultra-dark themes match main; every SPA entry imports it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MHSanaei 2026-05-08 17:21:03 +02:00
parent 4322a18ee3
commit 36e75143fa
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
26 changed files with 585 additions and 290 deletions

View file

@ -2,7 +2,10 @@ import axios from 'axios';
import qs from 'qs';
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
const CSRF_TOKEN_PATH = '/panel/csrf-token';
// Public CSRF endpoint — works pre-login (the panel-scoped
// /panel/csrf-token sits behind checkLogin and would 401 a fresh
// login page that hasn't authenticated yet).
const CSRF_TOKEN_PATH = '/csrf-token';
// Cached session CSRF token. The legacy panel injects it via a
// <meta name="csrf-token"> tag rendered by Go; the new SPA pages
@ -79,7 +82,18 @@ export function setupAxios() {
async (error) => {
const status = error.response?.status;
if (status === 401) {
// 401 → session is gone. In production, the panel routes
// are gated by Go's checkLogin which redirects to base_path
// serving the login page; a reload is enough. In dev, Vite
// serves /index.html directly at "/", so a reload would put
// the user right back on the dashboard and the interceptor
// would loop. Navigate to the dev login entry instead.
if (import.meta.env.DEV) {
const basePath = window.__X_UI_BASE_PATH__ || '/';
window.location.href = `${basePath}login.html`;
} else {
window.location.reload();
}
return Promise.reject(error);
}
// 403 with a stale/missing CSRF token: drop the cache, re-fetch, retry once.

View file

@ -52,7 +52,7 @@ const tabs = computed(() => [
{ key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') },
{ key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
{ key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
{ key: `${prefix}logout/`, icon: 'logout', title: t('logout') },
{ key: `${prefix}logout`, icon: 'logout', title: t('logout') },
]);
const activeTab = ref([props.requestUri]);

View file

@ -1,6 +1,7 @@
import { createApp } from 'vue';
import Antd, { message } from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
import '@/styles/legacy.css';
import { setupAxios } from '@/api/axios-init.js';
import '@/composables/useTheme.js';

View file

@ -1,6 +1,11 @@
import { createApp } from 'vue';
import Antd, { message } from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
// Legacy panel CSS — overrides AD-Vue defaults to match the
// pre-migration look (palette, dark mode contrast, tag colors,
// table/tooltip styling). Loaded after AD-Vue's reset so its
// rules win.
import '@/styles/legacy.css';
import { setupAxios } from '@/api/axios-init.js';
// Importing useTheme triggers the boot side-effect that applies the

View file

@ -1,6 +1,7 @@
import { createApp } from 'vue';
import Antd, { message } from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
import '@/styles/legacy.css';
import { setupAxios } from '@/api/axios-init.js';
// Importing this module triggers the boot side-effect that applies the

View file

@ -1,4 +1,4 @@
import { ObjectUtil, RandomUtil, Base64 } from '@/utils';
import { ObjectUtil, RandomUtil, Base64, NumberFormatter, SizeFormatter, Wireguard } from '@/utils';
export const Protocols = {
VMESS: 'vmess',

View file

@ -1,5 +1,5 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import {
PlusOutlined,
@ -89,6 +89,10 @@ watch(refreshIntervalMs, (next) => {
localStorage.setItem('refreshInterval', String(next));
if (isRefreshEnabled.value) startAutoRefresh();
});
// Without this, a stale setInterval keeps firing emit('refresh') after
// the component unmounts, which Vue surfaces as "emitsOptions" /
// "__asyncLoader" exceptions on the next tick.
onBeforeUnmount(stopAutoRefresh);
// Toggle the filter mode flip cleans the other input.
function onToggleFilter() {

View file

@ -67,7 +67,9 @@ function importDb() {
<template #description>{{ t('pages.index.exportDatabaseDesc') }}</template>
</a-list-item-meta>
<a-button type="primary" @click="exportDb">
<template #icon><DownloadOutlined /></template>
<template #icon>
<DownloadOutlined />
</template>
</a-button>
</a-list-item>
@ -77,7 +79,9 @@ function importDb() {
<template #description>{{ t('pages.index.importDatabaseDesc') }}</template>
</a-list-item-meta>
<a-button type="primary" @click="importDb">
<template #icon><UploadOutlined /></template>
<template #icon>
<UploadOutlined />
</template>
</a-button>
</a-list-item>
</a-list>
@ -85,6 +89,13 @@ function importDb() {
</template>
<style scoped>
.backup-list { width: 100%; }
.backup-item { display: flex; align-items: center; gap: 16px; }
.backup-list {
width: 100%;
}
.backup-item {
display: flex;
align-items: center;
gap: 16px;
}
</style>

View file

@ -63,21 +63,9 @@ watch(bucket, () => { if (props.open) fetchBucket(); });
</template>
<div class="cpu-chart-wrap">
<Sparkline
:data="points"
:labels="labels"
:vb-width="840"
:height="220"
:stroke="status?.cpu?.color || '#008771'"
:stroke-width="2.2"
:show-grid="true"
:show-axes="true"
:tick-count-x="5"
:max-points="points.length || 1"
:fill-opacity="0.18"
:marker-radius="3.2"
:show-tooltip="true"
/>
<Sparkline :data="points" :labels="labels" :vb-width="840" :height="220" :stroke="status?.cpu?.color || '#008771'"
:stroke-width="2.2" :show-grid="true" :show-axes="true" :tick-count-x="5" :max-points="points.length || 1"
:fill-opacity="0.18" :marker-radius="3.2" :show-tooltip="true" />
<div class="cpu-chart-meta">
Timeframe: {{ bucket }} sec per point (total {{ points.length }} points)
</div>

View file

@ -84,15 +84,9 @@ async function submit() {
</script>
<template>
<a-modal
:open="open"
:title="editing ? t('pages.index.customGeoModalEdit') : t('pages.index.customGeoModalAdd')"
:confirm-loading="saving"
:ok-text="t('pages.index.customGeoModalSave')"
:cancel-text="t('close')"
@ok="submit"
@cancel="close"
>
<a-modal :open="open" :title="editing ? t('pages.index.customGeoModalEdit') : t('pages.index.customGeoModalAdd')"
:confirm-loading="saving" :ok-text="t('pages.index.customGeoModalSave')" :cancel-text="t('close')" @ok="submit"
@cancel="close">
<a-form layout="vertical">
<a-form-item :label="t('pages.index.customGeoType')">
<a-select v-model:value="form.type" :disabled="editing">
@ -101,11 +95,8 @@ async function submit() {
</a-select>
</a-form-item>
<a-form-item :label="t('pages.index.customGeoAlias')">
<a-input
v-model:value="form.alias"
:disabled="editing"
:placeholder="t('pages.index.customGeoAliasPlaceholder')"
/>
<a-input v-model:value="form.alias" :disabled="editing"
:placeholder="t('pages.index.customGeoAliasPlaceholder')" />
</a-form-item>
<a-form-item :label="t('pages.index.customGeoUrl')">
<a-input v-model:value="form.url" placeholder="https://" />

View file

@ -134,34 +134,26 @@ watch(() => props.active, (next) => { if (next) loadList(); }, { immediate: true
<template>
<div class="custom-geo-section">
<a-alert
type="info"
show-icon
class="mb-10"
:message="t('pages.index.customGeoRoutingHint')"
/>
<a-alert type="info" show-icon class="mb-10" :message="t('pages.index.customGeoRoutingHint')" />
<div class="toolbar">
<a-button type="primary" :loading="loading" @click="openAdd">
<template #icon><PlusOutlined /></template>
<template #icon>
<PlusOutlined />
</template>
{{ t('pages.index.customGeoAdd') }}
</a-button>
<a-button :loading="updatingAll" :disabled="!list.length" @click="updateAll">
<template #icon><ReloadOutlined /></template>
<template #icon>
<ReloadOutlined />
</template>
{{ t('pages.index.geofilesUpdateAll') }}
</a-button>
<span v-if="list.length" class="custom-geo-count">{{ list.length }}</span>
</div>
<a-table
:columns="columns"
:data-source="list"
:pagination="false"
:row-key="(r) => r.id"
:loading="loading"
size="small"
:scroll="{ x: 760 }"
>
<a-table :columns="columns" :data-source="list" :pagination="false" :row-key="(r) => r.id" :loading="loading"
size="small" :scroll="{ x: 760 }">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'alias'">
<div class="custom-geo-alias-cell">
@ -199,22 +191,23 @@ watch(() => props.active, (next) => { if (next) loadList(); }, { immediate: true
<a-space size="small">
<a-tooltip :title="t('pages.index.customGeoEdit')">
<a-button type="link" size="small" @click="openEdit(record)">
<template #icon><EditOutlined /></template>
<template #icon>
<EditOutlined />
</template>
</a-button>
</a-tooltip>
<a-tooltip :title="t('pages.index.customGeoDownload')">
<a-button
type="link"
size="small"
:loading="actionId === record.id"
@click="downloadOne(record.id)"
>
<template #icon><ReloadOutlined /></template>
<a-button type="link" size="small" :loading="actionId === record.id" @click="downloadOne(record.id)">
<template #icon>
<ReloadOutlined />
</template>
</a-button>
</a-tooltip>
<a-tooltip :title="t('pages.index.customGeoDelete')">
<a-button type="link" size="small" danger @click="confirmDelete(record)">
<template #icon><DeleteOutlined /></template>
<template #icon>
<DeleteOutlined />
</template>
</a-button>
</a-tooltip>
</a-space>
@ -229,16 +222,14 @@ watch(() => props.active, (next) => { if (next) loadList(); }, { immediate: true
</template>
</a-table>
<CustomGeoFormModal
v-model:open="formOpen"
:record="editingRecord"
@saved="loadList"
/>
<CustomGeoFormModal v-model:open="formOpen" :record="editingRecord" @saved="loadList" />
</div>
</template>
<style scoped>
.mb-10 { margin-bottom: 10px; }
.mb-10 {
margin-bottom: 10px;
}
.toolbar {
display: flex;
@ -256,6 +247,7 @@ watch(() => props.active, (next) => { if (next) loadList(); }, { immediate: true
font-size: 12px;
opacity: 0.75;
}
:global(body.dark) .custom-geo-count {
background: rgba(255, 255, 255, 0.08);
}
@ -265,10 +257,12 @@ watch(() => props.active, (next) => { if (next) loadList(); }, { immediate: true
align-items: center;
gap: 6px;
}
.custom-geo-alias {
font-weight: 500;
word-break: break-all;
}
.custom-geo-type-tag {
margin: 0;
}
@ -286,12 +280,15 @@ watch(() => props.active, (next) => { if (next) loadList(); }, { immediate: true
background: rgba(0, 0, 0, 0.05);
user-select: all;
}
.custom-geo-copyable:hover {
background: rgba(0, 0, 0, 0.1);
}
:global(body.dark) .custom-geo-ext-code {
background: rgba(255, 255, 255, 0.08);
}
:global(body.dark) .custom-geo-copyable:hover {
background: rgba(255, 255, 255, 0.14);
}
@ -305,6 +302,7 @@ watch(() => props.active, (next) => { if (next) loadList(); }, { immediate: true
padding: 18px 0;
opacity: 0.6;
}
.custom-geo-empty-icon {
font-size: 32px;
margin-bottom: 6px;

View file

@ -2,15 +2,29 @@
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { theme as antdTheme } from 'ant-design-vue';
import { BarsOutlined, CloudServerOutlined, CloudDownloadOutlined } from '@ant-design/icons-vue';
import {
BarsOutlined,
ControlOutlined,
CloudServerOutlined,
CloudDownloadOutlined,
CloudUploadOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
GlobalOutlined,
SwapOutlined,
EyeOutlined,
EyeInvisibleOutlined,
} from '@ant-design/icons-vue';
const { t } = useI18n();
import { HttpUtil } from '@/utils';
import { HttpUtil, SizeFormatter, TimeFormatter } from '@/utils';
import { theme as themeState } from '@/composables/useTheme.js';
import { useStatus } from '@/composables/useStatus.js';
import { useMediaQuery } from '@/composables/useMediaQuery.js';
import AppSidebar from '@/components/AppSidebar.vue';
import CustomStatistic from '@/components/CustomStatistic.vue';
import TextModal from '@/components/TextModal.vue';
import StatusCard from './StatusCard.vue';
import XrayStatusCard from './XrayStatusCard.vue';
import PanelUpdateModal from './PanelUpdateModal.vue';
@ -47,6 +61,16 @@ onMounted(() => {
const basePath = window.__X_UI_BASE_PATH__ || '';
const requestUri = window.location.pathname;
// In production, dist.go injects window.__X_UI_CUR_VER__ at serve time.
// In dev, Vite serves the HTML directly so the global is missing fall
// back to currentVersion from the panel-update API once it answers.
const displayVersion = computed(
() => panelUpdateInfo.value?.currentVersion || window.__X_UI_CUR_VER__ || '?',
);
// Hide/reveal the public IPv4/IPv6 same pattern as legacy.
const showIp = ref(false);
// Modal open state.
const logsOpen = ref(false);
const backupOpen = ref(false);
@ -54,6 +78,8 @@ const panelUpdateOpen = ref(false);
const cpuHistoryOpen = ref(false);
const xrayLogsOpen = ref(false);
const versionOpen = ref(false);
const configTextOpen = ref(false);
const configText = ref('');
// Page-level loading overlay; modals can request it via @busy.
const loading = ref(false);
@ -76,6 +102,20 @@ async function restartXray() {
function openCpuHistory() { cpuHistoryOpen.value = true; }
function openXrayLogs() { xrayLogsOpen.value = true; }
function openVersionSwitch() { versionOpen.value = true; }
// Legacy "Config" action fetch the rendered xray config and show
// it as JSON in the shared TextModal (same UX as main).
async function openConfig() {
loading.value = true;
try {
const msg = await HttpUtil.get('/panel/api/server/getConfigJson');
if (!msg?.success) return;
configText.value = JSON.stringify(msg.obj, null, 2);
configTextOpen.value = true;
} finally {
loading.value = false;
}
}
</script>
<template>
@ -94,68 +134,191 @@ function openVersionSwitch() { versionOpen.value = true; }
</a-col>
<a-col :sm="24" :lg="12">
<XrayStatusCard
:status="status"
:is-mobile="isMobile"
:ip-limit-enable="ipLimitEnable"
@stop-xray="stopXray"
@restart-xray="restartXray"
@open-xray-logs="openXrayLogs"
@open-logs="logsOpen = true"
@open-version-switch="openVersionSwitch"
/>
<XrayStatusCard :status="status" :is-mobile="isMobile" :ip-limit-enable="ipLimitEnable"
@stop-xray="stopXray" @restart-xray="restartXray" @open-xray-logs="openXrayLogs"
@open-logs="logsOpen = true" @open-version-switch="openVersionSwitch" />
</a-col>
<a-col :sm="24" :lg="12">
<a-card :title="t('menu.link')" hoverable>
<template v-if="panelUpdateInfo.updateAvailable" #extra>
<a-tooltip :title="`${t('pages.index.updatePanel')}: ${panelUpdateInfo.latestVersion}`">
<a-tag color="orange" class="update-tag" @click="panelUpdateOpen = true">
<CloudDownloadOutlined />
{{ panelUpdateInfo.latestVersion }}
<span v-if="!isMobile">{{ t('update') }}</span>
</a-tag>
</a-tooltip>
</template>
<template #actions>
<a-space class="action" @click="logsOpen = true">
<BarsOutlined />
<span v-if="!isMobile">{{ t('pages.index.logs') }}</span>
</a-space>
<a-space class="action" @click="openConfig">
<ControlOutlined />
<span v-if="!isMobile">{{ t('pages.index.config') }}</span>
</a-space>
<a-space class="action" @click="backupOpen = true">
<CloudServerOutlined />
<span v-if="!isMobile">{{ t('pages.index.backupTitle') }}</span>
</a-space>
<a-space class="action" @click="panelUpdateOpen = true">
<CloudDownloadOutlined />
<span v-if="!isMobile">
{{ panelUpdateInfo.updateAvailable
? `${t('update')}${panelUpdateInfo.latestVersion}`
: t('pages.index.panelUpToDate') }}
</span>
</a-space>
</template>
</a-card>
</a-col>
<a-col :sm="24" :lg="12">
<a-card title="3X-UI" hoverable>
<template v-if="panelUpdateInfo.updateAvailable" #extra>
<a-tooltip :title="`${t('pages.index.updatePanel')}: ${panelUpdateInfo.latestVersion}`">
<a-tag color="orange" class="update-tag" @click="panelUpdateOpen = true">
<CloudDownloadOutlined />
{{ panelUpdateInfo.latestVersion }}
<span v-if="!isMobile">{{ t('pages.index.updatePanel') }}</span>
</a-tag>
</a-tooltip>
</template>
<a href="https://github.com/MHSanaei/3x-ui/releases" target="_blank" rel="noopener noreferrer">
<a-tag color="green">v{{ displayVersion }}</a-tag>
</a>
<a href="https://t.me/XrayUI" target="_blank" rel="noopener noreferrer">
<a-tag color="green">@XrayUI</a-tag>
</a>
<a href="https://github.com/MHSanaei/3x-ui/wiki" target="_blank" rel="noopener noreferrer">
<a-tag color="purple">{{ t('pages.index.documentation') }}</a-tag>
</a>
</a-card>
</a-col>
<a-col :sm="24" :lg="12">
<a-card :title="t('pages.index.operationHours')" hoverable>
<a-tag :color="status.xray.color">
Xray: {{ TimeFormatter.formatSecond(status.appStats.uptime) }}
</a-tag>
<a-tag color="green">OS: {{ TimeFormatter.formatSecond(status.uptime) }}</a-tag>
</a-card>
</a-col>
<a-col :sm="24" :lg="12">
<a-card :title="t('pages.index.systemLoad')" hoverable>
<a-tooltip :title="t('pages.index.systemLoadDesc')">
<a-tag color="green">
{{ status.loads[0] }} | {{ status.loads[1] }} | {{ status.loads[2] }}
</a-tag>
</a-tooltip>
</a-card>
</a-col>
<a-col :sm="24" :lg="12">
<a-card :title="t('usage')" hoverable>
<a-tag color="green">
{{ t('pages.index.memory') }}: {{ SizeFormatter.sizeFormat(status.appStats.mem) }}
</a-tag>
<a-tag color="green">
{{ t('pages.index.threads') }}: {{ status.appStats.threads }}
</a-tag>
</a-card>
</a-col>
<a-col :sm="24" :lg="12">
<a-card :title="t('pages.index.overallSpeed')" hoverable>
<a-row :gutter="isMobile ? [8, 8] : 0">
<a-col :span="12">
<CustomStatistic :title="t('pages.index.upload')"
:value="SizeFormatter.sizeFormat(status.netIO.up)">
<template #prefix>
<ArrowUpOutlined />
</template>
<template #suffix>/s</template>
</CustomStatistic>
</a-col>
<a-col :span="12">
<CustomStatistic :title="t('pages.index.download')"
:value="SizeFormatter.sizeFormat(status.netIO.down)">
<template #prefix>
<ArrowDownOutlined />
</template>
<template #suffix>/s</template>
</CustomStatistic>
</a-col>
</a-row>
</a-card>
</a-col>
<a-col :sm="24" :lg="12">
<a-card :title="t('pages.index.totalData')" hoverable>
<a-row :gutter="isMobile ? [8, 8] : 0">
<a-col :span="12">
<CustomStatistic :title="t('pages.index.sent')"
:value="SizeFormatter.sizeFormat(status.netTraffic.sent)">
<template #prefix>
<CloudUploadOutlined />
</template>
</CustomStatistic>
</a-col>
<a-col :span="12">
<CustomStatistic :title="t('pages.index.received')"
:value="SizeFormatter.sizeFormat(status.netTraffic.recv)">
<template #prefix>
<CloudDownloadOutlined />
</template>
</CustomStatistic>
</a-col>
</a-row>
</a-card>
</a-col>
<a-col :sm="24" :lg="12">
<a-card :title="t('pages.index.ipAddresses')" hoverable>
<template #extra>
<a-tooltip :title="t('pages.index.toggleIpVisibility')" :placement="isMobile ? 'topRight' : 'top'">
<component :is="showIp ? EyeOutlined : EyeInvisibleOutlined" class="ip-toggle-icon"
@click="showIp = !showIp" />
</a-tooltip>
</template>
<a-row :class="showIp ? 'ip-visible' : 'ip-hidden'" :gutter="isMobile ? [8, 8] : 0">
<a-col :span="isMobile ? 24 : 12">
<CustomStatistic title="IPv4" :value="status.publicIP.ipv4">
<template #prefix>
<GlobalOutlined />
</template>
</CustomStatistic>
</a-col>
<a-col :span="isMobile ? 24 : 12">
<CustomStatistic title="IPv6" :value="status.publicIP.ipv6">
<template #prefix>
<GlobalOutlined />
</template>
</CustomStatistic>
</a-col>
</a-row>
</a-card>
</a-col>
<a-col :sm="24" :lg="12">
<a-card :title="t('pages.index.connectionCount')" hoverable>
<a-row :gutter="isMobile ? [8, 8] : 0">
<a-col :span="12">
<CustomStatistic title="TCP" :value="status.tcpCount">
<template #prefix>
<SwapOutlined />
</template>
</CustomStatistic>
</a-col>
<a-col :span="12">
<CustomStatistic title="UDP" :value="status.udpCount">
<template #prefix>
<SwapOutlined />
</template>
</CustomStatistic>
</a-col>
</a-row>
</a-card>
</a-col>
</a-row>
</a-spin>
</a-layout-content>
</a-layout>
<PanelUpdateModal
v-model:open="panelUpdateOpen"
:info="panelUpdateInfo"
@busy="setBusy"
/>
<PanelUpdateModal v-model:open="panelUpdateOpen" :info="panelUpdateInfo" @busy="setBusy" />
<LogModal v-model:open="logsOpen" />
<BackupModal
v-model:open="backupOpen"
:base-path="basePath"
@busy="setBusy"
/>
<BackupModal v-model:open="backupOpen" :base-path="basePath" @busy="setBusy" />
<CpuHistoryModal v-model:open="cpuHistoryOpen" :status="status" />
<XrayLogModal v-model:open="xrayLogsOpen" />
<VersionModal v-model:open="versionOpen" :status="status" @busy="setBusy" />
<TextModal v-model:open="configTextOpen" :title="t('pages.index.config')" :content="configText"
file-name="config.json" />
</a-layout>
</a-config-provider>
</template>
@ -184,8 +347,13 @@ function openVersionSwitch() { versionOpen.value = true; }
background: transparent;
}
.content-shell { background: transparent; }
.content-area { padding: 24px; }
.content-shell {
background: transparent;
}
.content-area {
padding: 24px;
}
.loading-spacer {
min-height: calc(100vh - 120px);
@ -203,4 +371,18 @@ function openVersionSwitch() { versionOpen.value = true; }
align-items: center;
gap: 4px;
}
.ip-toggle-icon {
cursor: pointer;
font-size: 16px;
}
.ip-hidden :deep(.ant-statistic-content-value) {
filter: blur(6px);
transition: filter 0.2s ease;
}
.ip-visible :deep(.ant-statistic-content-value) {
filter: none;
}
</style>

View file

@ -125,7 +125,9 @@ watch([rows, level, syslog], () => { if (props.open) refresh(); });
</a-form-item>
<a-form-item style="margin-left: auto">
<a-button type="primary" @click="download">
<template #icon><DownloadOutlined /></template>
<template #icon>
<DownloadOutlined />
</template>
</a-button>
</a-form-item>
</a-form>

View file

@ -61,13 +61,8 @@ function updatePanel() {
<template>
<a-modal :open="open" :title="t('pages.index.updatePanel')" :closable="true" :footer="null" @cancel="close">
<a-alert
v-if="info.updateAvailable"
type="warning"
class="mb-12"
:message="t('pages.index.panelUpdateDesc')"
show-icon
/>
<a-alert v-if="info.updateAvailable" type="warning" class="mb-12" :message="t('pages.index.panelUpdateDesc')"
show-icon />
<a-list bordered class="version-list">
<a-list-item class="version-list-item">
@ -86,7 +81,9 @@ function updatePanel() {
<div class="actions-row">
<a-button type="primary" :disabled="!info.updateAvailable" @click="updatePanel">
<template #icon><CloudDownloadOutlined /></template>
<template #icon>
<CloudDownloadOutlined />
</template>
{{ t('pages.index.updatePanel') }}
</a-button>
</div>
@ -94,8 +91,22 @@ function updatePanel() {
</template>
<style scoped>
.mb-12 { margin-bottom: 12px; }
.version-list { width: 100%; }
.version-list-item { display: flex; justify-content: space-between; }
.actions-row { display: flex; justify-content: flex-end; margin-top: 12px; }
.mb-12 {
margin-bottom: 12px;
}
.version-list {
width: 100%;
}
.version-list-item {
display: flex;
justify-content: space-between;
}
.actions-row {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
</style>

View file

@ -21,37 +21,32 @@ defineEmits(['open-cpu-history']);
<a-col :sm="24" :md="12">
<a-row>
<a-col :span="12" class="text-center">
<a-progress
type="dashboard"
status="normal"
:stroke-color="status.cpu.color"
:percent="status.cpu.percent"
/>
<a-progress type="dashboard" status="normal" :stroke-color="status.cpu.color"
:percent="status.cpu.percent" />
<div>
<b>{{ t('pages.index.cpu') }}:</b> {{ CPUFormatter.cpuCoreFormat(status.cpuCores) }}
<a-tooltip>
<template #title>
<div><b>{{ t('pages.index.logicalProcessors') }}:</b> {{ status.logicalPro }}</div>
<div><b>{{ t('pages.index.frequency') }}:</b> {{ CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz) }}</div>
<div><b>{{ t('pages.index.frequency') }}:</b> {{ CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz) }}
</div>
</template>
<AreaChartOutlined />
</a-tooltip>
<a-tooltip>
<template #title>{{ t('pages.index.cpu') }}</template>
<a-button size="small" shape="circle" class="ml-8" @click="$emit('open-cpu-history')">
<template #icon><HistoryOutlined /></template>
<template #icon>
<HistoryOutlined />
</template>
</a-button>
</a-tooltip>
</div>
</a-col>
<a-col :span="12" class="text-center">
<a-progress
type="dashboard"
status="normal"
:stroke-color="status.mem.color"
:percent="status.mem.percent"
/>
<a-progress type="dashboard" status="normal" :stroke-color="status.mem.color"
:percent="status.mem.percent" />
<div>
<b>{{ t('pages.index.memory') }}:</b> {{ SizeFormatter.sizeFormat(status.mem.current) }} /
{{ SizeFormatter.sizeFormat(status.mem.total) }}
@ -64,12 +59,8 @@ defineEmits(['open-cpu-history']);
<a-col :sm="24" :md="12">
<a-row>
<a-col :span="12" class="text-center">
<a-progress
type="dashboard"
status="normal"
:stroke-color="status.swap.color"
:percent="status.swap.percent"
/>
<a-progress type="dashboard" status="normal" :stroke-color="status.swap.color"
:percent="status.swap.percent" />
<div>
<b>{{ t('pages.index.swap') }}:</b> {{ SizeFormatter.sizeFormat(status.swap.current) }} /
{{ SizeFormatter.sizeFormat(status.swap.total) }}
@ -77,12 +68,8 @@ defineEmits(['open-cpu-history']);
</a-col>
<a-col :span="12" class="text-center">
<a-progress
type="dashboard"
status="normal"
:stroke-color="status.disk.color"
:percent="status.disk.percent"
/>
<a-progress type="dashboard" status="normal" :stroke-color="status.disk.color"
:percent="status.disk.percent" />
<div>
<b>{{ t('pages.index.storage') }}:</b> {{ SizeFormatter.sizeFormat(status.disk.current) }} /
{{ SizeFormatter.sizeFormat(status.disk.total) }}
@ -98,6 +85,7 @@ defineEmits(['open-cpu-history']);
.text-center {
text-align: center;
}
.ml-8 {
margin-left: 8px;
}

View file

@ -87,19 +87,11 @@ watch(() => props.open, (next) => { if (next) fetchVersions(); });
<a-spin :spinning="loading">
<a-collapse v-model:active-key="activeKey" accordion>
<a-collapse-panel key="1" header="Xray">
<a-alert
type="warning"
class="mb-12"
:message="t('pages.index.xraySwitchClickDesk')"
show-icon
/>
<a-alert type="warning" class="mb-12" :message="t('pages.index.xraySwitchClickDesk')" show-icon />
<a-list bordered class="version-list">
<a-list-item v-for="(version, index) in versions" :key="version" class="version-list-item">
<a-tag :color="index % 2 === 0 ? 'purple' : 'green'">{{ version }}</a-tag>
<a-radio
:checked="version === `v${status?.xray?.version}`"
@click="switchXrayVersion(version)"
/>
<a-radio :checked="version === `v${status?.xray?.version}`" @click="switchXrayVersion(version)" />
</a-list-item>
</a-list>
</a-collapse-panel>
@ -127,9 +119,19 @@ watch(() => props.open, (next) => { if (next) fetchVersions(); });
</template>
<style scoped>
.mb-12 { margin-bottom: 12px; }
.version-list { width: 100%; }
.version-list-item { display: flex; justify-content: space-between; align-items: center; }
.mb-12 {
margin-bottom: 12px;
}
.version-list {
width: 100%;
}
.version-list-item {
display: flex;
justify-content: space-between;
align-items: center;
}
.reload-icon {
cursor: pointer;

View file

@ -129,7 +129,9 @@ watch([rows, showDirect, showBlocked, showProxy], () => { if (props.open) refres
</a-form-item>
<a-form-item style="margin-left: auto">
<a-button type="primary" @click="download">
<template #icon><DownloadOutlined /></template>
<template #icon>
<DownloadOutlined />
</template>
</a-button>
</a-form-item>
</a-form>
@ -170,6 +172,7 @@ watch([rows, showDirect, showBlocked, showProxy], () => { if (props.open) refres
border-collapse: collapse;
width: auto;
}
.xraylog-table td,
.xraylog-table th {
padding: 2px 15px;

View file

@ -42,12 +42,8 @@ function badgeAnimationClass(color) {
<template #extra>
<template v-if="status.xray.state !== 'error'">
<a-badge
status="processing"
:class="['xray-processing-animation', badgeAnimationClass(status.xray.color)]"
:text="status.xray.stateMsg"
:color="status.xray.color"
/>
<a-badge status="processing" :class="['xray-processing-animation', badgeAnimationClass(status.xray.color)]"
:text="status.xray.stateMsg" :color="status.xray.color" />
</template>
<template v-else>
<a-popover>
@ -64,12 +60,8 @@ function badgeAnimationClass(color) {
{{ line }}
</span>
</template>
<a-badge
status="processing"
:text="status.xray.stateMsg"
:color="status.xray.color"
:class="['xray-processing-animation', 'xray-error-animation']"
/>
<a-badge status="processing" :text="status.xray.stateMsg" :color="status.xray.color"
:class="['xray-processing-animation', 'xray-error-animation']" />
</a-popover>
</template>
</template>
@ -122,18 +114,31 @@ function badgeAnimationClass(color) {
.xray-processing-animation .ant-badge-status-dot {
animation: xray-pulse 1.2s linear infinite;
}
.xray-running-animation .ant-badge-status-processing::after {
border-color: var(--color-primary-100, #008771);
}
.xray-stop-animation .ant-badge-status-processing::after {
border-color: #fa8c16;
}
.xray-error-animation .ant-badge-status-processing::after {
border-color: #f5222d;
}
@keyframes xray-pulse {
0%, 50%, 100% { transform: scale(1); opacity: 1; }
10% { transform: scale(1.5); opacity: 0.2; }
0%,
50%,
100% {
transform: scale(1);
opacity: 1;
}
10% {
transform: scale(1.5);
opacity: 0.2;
}
}
</style>

View file

@ -99,12 +99,15 @@ async function login() {
<div v-else>
<div class="login-settings">
<a-popover :overlay-class-name="currentTheme" :title="t('menu.settings')" placement="bottomRight" trigger="click">
<a-popover :overlay-class-name="currentTheme" :title="t('menu.settings')" placement="bottomRight"
trigger="click">
<template #content>
<ThemeSwitchLogin />
</template>
<a-button shape="circle">
<template #icon><SettingOutlined /></template>
<template #icon>
<SettingOutlined />
</template>
</a-button>
</a-popover>
</div>
@ -121,39 +124,29 @@ async function login() {
<a-form layout="vertical" @submit.prevent="login">
<a-form-item>
<a-input
v-model:value="user.username"
autocomplete="username"
name="username"
:placeholder="t('username')"
autofocus
required
>
<template #prefix><UserOutlined /></template>
<a-input v-model:value="user.username" autocomplete="username" name="username"
:placeholder="t('username')" autofocus required>
<template #prefix>
<UserOutlined />
</template>
</a-input>
</a-form-item>
<a-form-item>
<a-input-password
v-model:value="user.password"
autocomplete="current-password"
name="password"
:placeholder="t('password')"
required
>
<template #prefix><LockOutlined /></template>
<a-input-password v-model:value="user.password" autocomplete="current-password" name="password"
:placeholder="t('password')" required>
<template #prefix>
<LockOutlined />
</template>
</a-input-password>
</a-form-item>
<a-form-item v-if="twoFactorEnable">
<a-input
v-model:value="user.twoFactorCode"
autocomplete="one-time-code"
name="twoFactorCode"
:placeholder="t('twoFactorCode')"
required
>
<template #prefix><KeyOutlined /></template>
<a-input v-model:value="user.twoFactorCode" autocomplete="one-time-code" name="twoFactorCode"
:placeholder="t('twoFactorCode')" required>
<template #prefix>
<KeyOutlined />
</template>
</a-input>
</a-form-item>
@ -200,9 +193,12 @@ async function login() {
}
.login-app.is-dark {
--bg-page: #222d42; /* legacy .dark .under = surface-200 */
--bg-wave-header: #0a1222; /* legacy --dark-color-background (login-bg defaults to this) */
--bg-card: #151f31; /* legacy surface-100 */
--bg-page: #222d42;
/* legacy .dark .under = surface-200 */
--bg-wave-header: #0a1222;
/* legacy --dark-color-background (login-bg defaults to this) */
--bg-card: #151f31;
/* legacy surface-100 */
--color-title: rgba(255, 255, 255, 0.92);
--shadow-card: 0 4px 16px rgba(0, 0, 0, 0.45);
--wave-fill: #222d42;
@ -210,9 +206,12 @@ async function login() {
}
.login-app.is-dark.is-ultra {
--bg-page: #0f2d32; /* legacy ultra .under = login-wave override */
--bg-wave-header: #0a2227; /* legacy ultra --dark-color-login-background */
--bg-card: #0c0e12; /* legacy ultra surface-100 */
--bg-page: #0f2d32;
/* legacy ultra .under = login-wave override */
--bg-wave-header: #0a2227;
/* legacy ultra --dark-color-login-background */
--bg-card: #0c0e12;
/* legacy ultra surface-100 */
/* Top three waves use a brighter teal so motion reads against the
* dark wave-header bg. Bottom wave keeps the legacy color so its
* flat lower edge merges into bg-page without a visible seam if
@ -229,6 +228,7 @@ async function login() {
.login-app :deep(.ant-layout-content) {
background: transparent;
}
.login-app {
background: var(--bg-page);
}
@ -290,10 +290,12 @@ async function login() {
.headline-leave-active {
transition: opacity 0.4s ease, transform 0.4s ease;
}
.headline-enter-from {
opacity: 0;
transform: translateY(-12px);
}
.headline-leave-to {
opacity: 0;
transform: translateY(12px);
@ -327,14 +329,30 @@ async function login() {
* inline fill="..." on each <use> which made them lock to one palette.
* Animation durations match the legacy (4s/7s/10s/13s) so the bottom
* wave actually visibly moves in dark mode where contrast is low. */
.parallax > use {
.parallax>use {
fill: var(--wave-fill);
animation: move-forever 25s cubic-bezier(0.55, 0.5, 0.45, 0.5) infinite;
}
.parallax > use:nth-child(1) { animation-delay: -2s; animation-duration: 4s; opacity: 0.2; }
.parallax > use:nth-child(2) { animation-delay: -3s; animation-duration: 7s; opacity: 0.4; }
.parallax > use:nth-child(3) { animation-delay: -4s; animation-duration: 10s; opacity: 0.6; }
.parallax > use:nth-child(4) {
.parallax>use:nth-child(1) {
animation-delay: -2s;
animation-duration: 4s;
opacity: 0.2;
}
.parallax>use:nth-child(2) {
animation-delay: -3s;
animation-duration: 7s;
opacity: 0.4;
}
.parallax>use:nth-child(3) {
animation-delay: -4s;
animation-duration: 10s;
opacity: 0.6;
}
.parallax>use:nth-child(4) {
animation-delay: -5s;
animation-duration: 13s;
fill: var(--wave-fill-bottom);
@ -342,7 +360,12 @@ async function login() {
}
@keyframes move-forever {
0% { transform: translate3d(-90px, 0, 0); }
100% { transform: translate3d(85px, 0, 0); }
0% {
transform: translate3d(-90px, 0, 0);
}
100% {
transform: translate3d(85px, 0, 0);
}
}
</style>

View file

@ -24,6 +24,9 @@ export function useXraySetting() {
const fetched = ref(false);
const spinning = ref(false);
const saveDisabled = ref(true);
// Holds a user-facing message when fetchAll fails; lets the page
// render an error UI instead of an endless spinner.
const fetchError = ref('');
const xraySetting = ref('');
const oldXraySetting = ref('');
@ -46,9 +49,22 @@ export function useXraySetting() {
const outboundTestStates = ref({});
async function fetchAll() {
fetchError.value = '';
const msg = await HttpUtil.post('/panel/xray/');
if (!msg?.success) return;
const obj = JSON.parse(msg.obj);
if (!msg?.success) {
fetchError.value = msg?.msg || 'Failed to load xray config';
// Mark as fetched so the spinner clears and the error UI renders.
fetched.value = true;
return;
}
let obj;
try {
obj = JSON.parse(msg.obj);
} catch (e) {
fetchError.value = `Malformed xray config response: ${e?.message || e}`;
fetched.value = true;
return;
}
const pretty = JSON.stringify(obj.xraySetting, null, 2);
syncing = true;
xraySetting.value = pretty;
@ -188,6 +204,7 @@ export function useXraySetting() {
fetched,
spinning,
saveDisabled,
fetchError,
xraySetting,
templateSettings,
outboundTestUrl,

View file

@ -1,6 +1,7 @@
import { createApp } from 'vue';
import Antd, { message } from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
import '@/styles/legacy.css';
import { setupAxios } from '@/api/axios-init.js';
// Importing useTheme triggers the boot side-effect that applies the

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,7 @@
import { createApp } from 'vue';
import Antd, { message } from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
import '@/styles/legacy.css';
import { setupAxios } from '@/api/axios-init.js';
import '@/composables/useTheme.js';

View file

@ -35,7 +35,12 @@ function makeBackendProxy(target, patterns) {
// Returning a path from bypass tells Vite to serve that file from
// its own dev server instead of forwarding the request — used here
// to short-circuit /panel/<route> for pages we've already migrated.
//
// Only GETs get bypassed: the xray page reuses its page URL
// (`POST /panel/xray/`) for data, so a method-blind bypass would
// hand HTML back to fetch calls and break the page in dev.
bypass(req) {
if (req.method !== 'GET') return undefined;
const url = req.url.split('?')[0];
if (Object.prototype.hasOwnProperty.call(MIGRATED_ROUTES, url)) {
return MIGRATED_ROUTES[url];
@ -85,7 +90,7 @@ export default defineConfig({
// Patterns are anchored regex so /login.html and /index.html
// (which Vite serves itself) are NOT forwarded — only the bare
// backend paths and their sub-routes.
'^/(login|logout|getTwoFactorEnable)$',
'^/(login|logout|getTwoFactorEnable|csrf-token)$',
'^/(panel|server)(/|$)',
]),
},

View file

@ -3,11 +3,16 @@ package controller
import (
"bytes"
"embed"
htmlpkg "html"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v2/config"
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/web/session"
)
// distFS is filled in once at startup by the web package via SetDistFS.
@ -72,7 +77,7 @@ func serveDistPage(c *gin.Context, name string) {
// Escape just enough that a hostile basePath setting can't break
// out of the JS string literal. The setting is admin-controlled
// but defense-in-depth costs nothing here.
escaped := strings.NewReplacer(
jsEscape := strings.NewReplacer(
`\`, `\\`,
`"`, `\"`,
"\n", `\n`,
@ -80,8 +85,27 @@ func serveDistPage(c *gin.Context, name string) {
"<", `<`,
">", `>`,
"&", `&`,
).Replace(basePath)
inject := []byte(`<script>window.__X_UI_BASE_PATH__="` + escaped + `";</script></head>`)
)
escapedBase := jsEscape.Replace(basePath)
escapedVer := jsEscape.Replace(config.GetVersion())
// Embed a CSRF token in the served HTML the same way the legacy
// templates did via `<meta name="csrf-token">`. Without this the
// SPA login page has no way to acquire a token (the existing
// /panel/csrf-token endpoint sits behind checkLogin), and POST
// /login is rejected by CSRFMiddleware. EnsureCSRFToken creates
// a session token on first call even for anonymous visitors.
csrfToken, err := session.EnsureCSRFToken(c)
if err != nil {
logger.Warning("Unable to mint CSRF token for", name+":", err)
csrfToken = ""
}
csrfMeta := []byte(`<meta name="csrf-token" content="` + htmlpkg.EscapeString(csrfToken) + `">`)
inject := []byte(`<script>window.__X_UI_BASE_PATH__="` + escapedBase +
`";window.__X_UI_CUR_VER__="` + escapedVer + `";</script>`)
inject = append(inject, csrfMeta...)
inject = append(inject, []byte(`</head>`)...)
out := bytes.Replace(body, []byte("</head>"), inject, 1)
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")

View file

@ -40,6 +40,12 @@ func NewIndexController(g *gin.RouterGroup) *IndexController {
func (a *IndexController) initRouter(g *gin.RouterGroup) {
g.GET("/", a.index)
g.GET("/logout", a.logout)
// Public CSRF endpoint — the SPA login page (served by Vite in
// dev or by serveDistPage in prod) needs a token to POST /login,
// but the panel-side /panel/csrf-token sits behind checkLogin.
// EnsureCSRFToken creates a session token even for anonymous
// callers, so any pre-login flow can bootstrap from here.
g.GET("/csrf-token", a.csrfToken)
g.POST("/login", middleware.CSRFMiddleware(), a.login)
g.POST("/getTwoFactorEnable", middleware.CSRFMiddleware(), a.getTwoFactorEnable)
@ -148,6 +154,17 @@ func (a *IndexController) logout(c *gin.Context) {
c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path"))
}
// csrfToken returns the session CSRF token. Public — the login page
// needs a token before authenticating.
func (a *IndexController) csrfToken(c *gin.Context) {
token, err := session.EnsureCSRFToken(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "msg": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "obj": token})
}
// getTwoFactorEnable retrieves the current status of two-factor authentication.
func (a *IndexController) getTwoFactorEnable(c *gin.Context) {
status, err := a.settingService.GetTwoFactorEnable()