mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
chore: frontend UX improvements, CI pipeline, and dev tooling
- AppSidebar: logout via POST /logout instead of navigating to GET - InboundList: persist filter state (search, protocol, node) to localStorage across page reloads; add protocol and node filter dropdowns - IndexPage: add health status strip (Xray, CPU, Memory, Update) with quick-action buttons - dependabot: weekly go mod and npm update schedule - ci.yml: add GitHub Actions workflow for build and vet - .nvmrc: pin Node 22 for local development - frontend: bump package.json and package-lock.json - SubPage, DnsPresetsModal, api-docs: minor fixes
This commit is contained in:
parent
6343c43f62
commit
11629fba80
11 changed files with 234 additions and 16 deletions
8
.github/dependabot.yml
vendored
8
.github/dependabot.yml
vendored
|
|
@ -9,3 +9,11 @@ updates:
|
||||||
directory: "/" # Location of package manifests
|
directory: "/" # Location of package manifests
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
|
- package-ecosystem: "gomod"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/frontend"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
|
|
||||||
59
.github/workflows/ci.yml
vendored
Normal file
59
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
go-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: true
|
||||||
|
- name: Test
|
||||||
|
run: |
|
||||||
|
go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt
|
||||||
|
go test $(cat /tmp/go-packages.txt)
|
||||||
|
|
||||||
|
govulncheck:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: true
|
||||||
|
- name: Install govulncheck
|
||||||
|
run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||||
|
- name: Run govulncheck
|
||||||
|
run: govulncheck ./...
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
- name: Install
|
||||||
|
run: npm ci
|
||||||
|
working-directory: frontend
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
|
working-directory: frontend
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
working-directory: frontend
|
||||||
|
- name: Audit
|
||||||
|
run: npm audit --audit-level=high
|
||||||
|
working-directory: frontend
|
||||||
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
22
|
||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
|
|
@ -7,6 +7,10 @@
|
||||||
"": {
|
"": {
|
||||||
"name": "3x-ui-frontend",
|
"name": "3x-ui-frontend",
|
||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.0.0",
|
||||||
|
"npm": ">=10.0.0"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons-vue": "^7.0.1",
|
"@ant-design/icons-vue": "^7.0.1",
|
||||||
"ant-design-vue": "^4.2.6",
|
"ant-design-vue": "^4.2.6",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@
|
||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "3x-ui panel frontend (Vue 3 + Ant Design Vue 4 + Vite 8).",
|
"description": "3x-ui panel frontend (Vue 3 + Ant Design Vue 4 + Vite 8).",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.0.0",
|
||||||
|
"npm": ">=10.0.0"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
|
|
||||||
import { theme, currentTheme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
|
import { theme, currentTheme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
|
@ -45,7 +46,7 @@ const tabs = computed(() => [
|
||||||
{ key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
|
{ key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
|
||||||
{ key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
|
{ key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
|
||||||
{ key: `${prefix}panel/api-docs`, icon: 'apidocs', title: t('menu.apiDocs') },
|
{ key: `${prefix}panel/api-docs`, icon: 'apidocs', title: t('menu.apiDocs') },
|
||||||
{ key: `${prefix}logout`, icon: 'logout', title: t('logout') },
|
{ key: 'logout', icon: 'logout', title: t('logout') },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const navTabs = computed(() => tabs.value.filter((tab) => tab.icon !== 'logout'));
|
const navTabs = computed(() => tabs.value.filter((tab) => tab.icon !== 'logout'));
|
||||||
|
|
@ -55,7 +56,12 @@ const drawerOpen = ref(false);
|
||||||
const collapsed = ref(JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false'));
|
const collapsed = ref(JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false'));
|
||||||
const drawerWidth = 'min(82vw, 320px)';
|
const drawerWidth = 'min(82vw, 320px)';
|
||||||
|
|
||||||
function openLink(key) {
|
async function openLink(key) {
|
||||||
|
if (key === 'logout') {
|
||||||
|
await HttpUtil.post('/logout');
|
||||||
|
window.location.href = props.basePath || '/';
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (key.startsWith('http')) {
|
if (key.startsWith('http')) {
|
||||||
window.open(key);
|
window.open(key);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,9 @@ export const sections = [
|
||||||
'{\n "success": true,\n "msg": "Logged in successfully"\n}',
|
'{\n "success": true,\n "msg": "Logged in successfully"\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'POST',
|
||||||
path: '/logout',
|
path: '/logout',
|
||||||
summary: 'Clear the session cookie. Redirects back to the login page; not useful from non-browser clients.',
|
summary: 'Clear the session cookie. Requires the CSRF header for browser sessions.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
|
@ -43,7 +43,7 @@ export const sections = [
|
||||||
id: 'inbounds',
|
id: 'inbounds',
|
||||||
title: 'Inbounds API',
|
title: 'Inbounds API',
|
||||||
description:
|
description:
|
||||||
'Manage inbound configurations and their clients. All endpoints live under /panel/api/inbounds and require a logged-in session or Bearer token. Link-generating endpoints honour X-Forwarded-Host / X-Forwarded-Proto, so callers behind a reverse proxy get the correct external host in returned URLs.',
|
'Manage inbound configurations and their clients. All endpoints live under /panel/api/inbounds and require a logged-in session or Bearer token. Link-generating endpoints honour forwarded headers only when the request comes from a configured trusted proxy.',
|
||||||
endpoints: [
|
endpoints: [
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
|
@ -531,7 +531,7 @@ export const sections = [
|
||||||
description: 'Operations that interact with the configured Telegram bot.',
|
description: 'Operations that interact with the configured Telegram bot.',
|
||||||
endpoints: [
|
endpoints: [
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'POST',
|
||||||
path: '/panel/api/backuptotgbot',
|
path: '/panel/api/backuptotgbot',
|
||||||
summary: 'Send a fresh DB backup to every Telegram chat configured as an admin recipient. No body, no params.',
|
summary: 'Send a fresh DB backup to every Telegram chat configured as an admin recipient. No body, no params.',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
|
|
@ -67,9 +67,29 @@ const emit = defineEmits([
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ============ Toolbar / search & filter =============================
|
// ============ Toolbar / search & filter =============================
|
||||||
const enableFilter = ref(false);
|
const FILTER_STATE_KEY = 'inboundsFilterState';
|
||||||
const searchKey = ref('');
|
const savedFilterState = (() => {
|
||||||
const filterBy = ref('');
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}');
|
||||||
|
} catch (_e) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const enableFilter = ref(!!savedFilterState.enableFilter);
|
||||||
|
const searchKey = ref(savedFilterState.searchKey || '');
|
||||||
|
const filterBy = ref(savedFilterState.filterBy || '');
|
||||||
|
const protocolFilter = ref(savedFilterState.protocolFilter || '');
|
||||||
|
const nodeFilter = ref(savedFilterState.nodeFilter || '');
|
||||||
|
|
||||||
|
watch([enableFilter, searchKey, filterBy, protocolFilter, nodeFilter], () => {
|
||||||
|
localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({
|
||||||
|
enableFilter: enableFilter.value,
|
||||||
|
searchKey: searchKey.value,
|
||||||
|
filterBy: filterBy.value,
|
||||||
|
protocolFilter: protocolFilter.value,
|
||||||
|
nodeFilter: nodeFilter.value,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
// Toggle the filter mode — flip cleans the other input.
|
// Toggle the filter mode — flip cleans the other input.
|
||||||
function onToggleFilter() {
|
function onToggleFilter() {
|
||||||
|
|
@ -77,6 +97,35 @@ function onToggleFilter() {
|
||||||
else filterBy.value = '';
|
else filterBy.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const protocolOptions = computed(() => {
|
||||||
|
const values = new Set(props.dbInbounds.map((i) => i.protocol).filter(Boolean));
|
||||||
|
return [...values].sort();
|
||||||
|
});
|
||||||
|
|
||||||
|
const nodeOptions = computed(() => {
|
||||||
|
const values = new Map();
|
||||||
|
if (props.dbInbounds.some((i) => i.nodeId == null)) {
|
||||||
|
values.set('local', t('pages.inbounds.localPanel'));
|
||||||
|
}
|
||||||
|
for (const dbInbound of props.dbInbounds) {
|
||||||
|
if (dbInbound.nodeId == null) continue;
|
||||||
|
const node = props.nodesById.get(dbInbound.nodeId);
|
||||||
|
values.set(String(dbInbound.nodeId), node?.name || `#${dbInbound.nodeId}`);
|
||||||
|
}
|
||||||
|
return [...values.entries()].map(([value, label]) => ({ value, label }));
|
||||||
|
});
|
||||||
|
|
||||||
|
function applySecondaryFilters(rows) {
|
||||||
|
return rows.filter((dbInbound) => {
|
||||||
|
if (protocolFilter.value && dbInbound.protocol !== protocolFilter.value) return false;
|
||||||
|
if (nodeFilter.value) {
|
||||||
|
const nodeValue = dbInbound.nodeId == null ? 'local' : String(dbInbound.nodeId);
|
||||||
|
if (nodeValue !== nodeFilter.value) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ============ Search / filter projection =============================
|
// ============ Search / filter projection =============================
|
||||||
// Mirrors the legacy logic: when searching, keep inbounds that match
|
// Mirrors the legacy logic: when searching, keep inbounds that match
|
||||||
// anywhere (deep search); when filtering, keep inbounds that have at
|
// anywhere (deep search); when filtering, keep inbounds that have at
|
||||||
|
|
@ -99,7 +148,7 @@ function projectInbound(dbInbound, predicate) {
|
||||||
|
|
||||||
const visibleInbounds = computed(() => {
|
const visibleInbounds = computed(() => {
|
||||||
if (enableFilter.value) {
|
if (enableFilter.value) {
|
||||||
if (ObjectUtil.isEmpty(filterBy.value)) return [...props.dbInbounds];
|
if (ObjectUtil.isEmpty(filterBy.value)) return applySecondaryFilters([...props.dbInbounds]);
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const dbInbound of props.dbInbounds) {
|
for (const dbInbound of props.dbInbounds) {
|
||||||
const c = props.clientCount[dbInbound.id];
|
const c = props.clientCount[dbInbound.id];
|
||||||
|
|
@ -107,15 +156,15 @@ const visibleInbounds = computed(() => {
|
||||||
const list = c[filterBy.value];
|
const list = c[filterBy.value];
|
||||||
out.push(projectInbound(dbInbound, (client) => list.includes(client.email)));
|
out.push(projectInbound(dbInbound, (client) => list.includes(client.email)));
|
||||||
}
|
}
|
||||||
return out;
|
return applySecondaryFilters(out);
|
||||||
}
|
}
|
||||||
if (ObjectUtil.isEmpty(searchKey.value)) return [...props.dbInbounds];
|
if (ObjectUtil.isEmpty(searchKey.value)) return applySecondaryFilters([...props.dbInbounds]);
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const dbInbound of props.dbInbounds) {
|
for (const dbInbound of props.dbInbounds) {
|
||||||
if (!ObjectUtil.deepSearch(dbInbound, searchKey.value)) continue;
|
if (!ObjectUtil.deepSearch(dbInbound, searchKey.value)) continue;
|
||||||
out.push(projectInbound(dbInbound, (client) => ObjectUtil.deepSearch(client, searchKey.value)));
|
out.push(projectInbound(dbInbound, (client) => ObjectUtil.deepSearch(client, searchKey.value)));
|
||||||
}
|
}
|
||||||
return out;
|
return applySecondaryFilters(out);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============ Columns =================================================
|
// ============ Columns =================================================
|
||||||
|
|
@ -269,6 +318,18 @@ function showQrCodeMenu(dbInbound) {
|
||||||
<a-radio-button value="expiring">{{ t('depletingSoon') }}</a-radio-button>
|
<a-radio-button value="expiring">{{ t('depletingSoon') }}</a-radio-button>
|
||||||
<a-radio-button value="online">{{ t('online') }}</a-radio-button>
|
<a-radio-button value="online">{{ t('online') }}</a-radio-button>
|
||||||
</a-radio-group>
|
</a-radio-group>
|
||||||
|
<a-select v-model:value="protocolFilter" allow-clear :placeholder="t('pages.inbounds.protocol')"
|
||||||
|
:size="isMobile ? 'small' : 'middle'" :style="{ width: '150px' }">
|
||||||
|
<a-select-option v-for="protocol in protocolOptions" :key="protocol" :value="protocol">
|
||||||
|
{{ protocol }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
<a-select v-if="nodeOptions.length > 0" v-model:value="nodeFilter" allow-clear
|
||||||
|
:placeholder="t('pages.inbounds.node')" :size="isMobile ? 'small' : 'middle'" :style="{ width: '170px' }">
|
||||||
|
<a-select-option v-for="node in nodeOptions" :key="node.value" :value="node.value">
|
||||||
|
{{ node.label }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ====================== Mobile: card list ======================= -->
|
<!-- ====================== Mobile: card list ======================= -->
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,33 @@ const displayVersion = computed(
|
||||||
() => panelUpdateInfo.value?.currentVersion || window.X_UI_CUR_VER || '?',
|
() => panelUpdateInfo.value?.currentVersion || window.X_UI_CUR_VER || '?',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const healthItems = computed(() => {
|
||||||
|
const cpuPercent = Number(status.cpu?.percent || 0);
|
||||||
|
const memPercent = Number(status.mem?.percent || 0);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Xray',
|
||||||
|
value: status.xray.state,
|
||||||
|
color: status.xray.color,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'CPU',
|
||||||
|
value: `${cpuPercent.toFixed(1)}%`,
|
||||||
|
color: cpuPercent > 85 ? 'red' : cpuPercent > 65 ? 'orange' : 'green',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Memory',
|
||||||
|
value: `${memPercent.toFixed(1)}%`,
|
||||||
|
color: memPercent > 85 ? 'red' : 'blue',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Update',
|
||||||
|
value: panelUpdateInfo.value.updateAvailable ? panelUpdateInfo.value.latestVersion : 'current',
|
||||||
|
color: panelUpdateInfo.value.updateAvailable ? 'orange' : 'green',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
// Hide/reveal the public IPv4/IPv6 — same pattern as legacy.
|
// Hide/reveal the public IPv4/IPv6 — same pattern as legacy.
|
||||||
const showIp = ref(false);
|
const showIp = ref(false);
|
||||||
|
|
||||||
|
|
@ -124,6 +151,25 @@ async function openConfig() {
|
||||||
<div v-if="!fetched" class="loading-spacer" />
|
<div v-if="!fetched" class="loading-spacer" />
|
||||||
|
|
||||||
<a-row v-else :gutter="[isMobile ? 8 : 16, 12]">
|
<a-row v-else :gutter="[isMobile ? 8 : 16, 12]">
|
||||||
|
<a-col :span="24">
|
||||||
|
<div class="health-strip">
|
||||||
|
<div class="health-tags">
|
||||||
|
<a-tag v-for="item in healthItems" :key="item.label" :color="item.color">
|
||||||
|
{{ item.label }}: {{ item.value }}
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
<a-space :size="8" wrap class="critical-actions">
|
||||||
|
<a-button size="small" @click="refresh">{{ t('refresh') }}</a-button>
|
||||||
|
<a-button size="small" danger @click="restartXray">{{ t('pages.index.restartXray') }}</a-button>
|
||||||
|
<a-button size="small" @click="openXrayLogs">{{ t('pages.index.logs') }}</a-button>
|
||||||
|
<a-button v-if="panelUpdateInfo.updateAvailable" size="small" type="primary"
|
||||||
|
@click="panelUpdateOpen = true">
|
||||||
|
{{ t('pages.index.updatePanel') }}
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</div>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
<a-col :span="24">
|
<a-col :span="24">
|
||||||
<StatusCard :status="status" :is-mobile="isMobile" />
|
<StatusCard :status="status" :is-mobile="isMobile" />
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|
@ -369,6 +415,36 @@ async function openConfig() {
|
||||||
min-height: calc(100vh - 120px);
|
min-height: calc(100vh - 120px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.health-strip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-tags,
|
||||||
|
.critical-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-tags :deep(.ant-tag) {
|
||||||
|
margin-inline-end: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.health-strip {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.action {
|
.action {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,6 @@ const subUrl = subData.subUrl || '';
|
||||||
const subJsonUrl = subData.subJsonUrl || '';
|
const subJsonUrl = subData.subJsonUrl || '';
|
||||||
const subClashUrl = subData.subClashUrl || '';
|
const subClashUrl = subData.subClashUrl || '';
|
||||||
const subTitle = subData.subTitle || '';
|
const subTitle = subData.subTitle || '';
|
||||||
const subSupportUrl = subData.subSupportUrl || '';
|
|
||||||
const links = Array.isArray(subData.links) ? subData.links : [];
|
const links = Array.isArray(subData.links) ? subData.links : [];
|
||||||
// Panel's "Calendar Type" setting; controls whether expiry / lastOnline
|
// Panel's "Calendar Type" setting; controls whether expiry / lastOnline
|
||||||
// render in Gregorian or Jalali on this standalone subscription page.
|
// render in Gregorian or Jalali on this standalone subscription page.
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const props = defineProps({
|
defineProps({
|
||||||
open: { type: Boolean, default: false },
|
open: { type: Boolean, default: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue