mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-13 17:46:02 +00:00
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:
parent
4322a18ee3
commit
36e75143fa
26 changed files with 585 additions and 290 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { ObjectUtil, RandomUtil, Base64 } from '@/utils';
|
||||
import { ObjectUtil, RandomUtil, Base64, NumberFormatter, SizeFormatter, Wireguard } from '@/utils';
|
||||
|
||||
export const Protocols = {
|
||||
VMESS: 'vmess',
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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://" />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -331,9 +333,25 @@ async function login() {
|
|||
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(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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1
frontend/src/styles/legacy.css
Normal file
1
frontend/src/styles/legacy.css
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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)(/|$)',
|
||||
]),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue