feat(frontend): migrate subpage.html to Vue 3 SPA

The subscription info page was the last page still rendered by Go
templates. Move it to the Vite multi-page setup so the whole panel
loads through one toolchain.

Frontend: SubPage.vue mounts at /sub/<id>?html=1 and reads window.__SUB_PAGE_DATA__
for the parsed view-model (traffic / quota / expiry + rendered share
links). Fix descriptions borders against the light-theme card by
painting the row divider on each cell's bottom edge — AD-Vue's <tr>
border doesn't render reliably under border-collapse:collapse.

Backend: serveSubPage reads dist/subpage.html, injects
window.__X_UI_BASE_PATH__ + window.__SUB_PAGE_DATA__ before </head>,
and rewrites Vite's absolute /assets/ URLs when the panel runs under
a URL prefix. Drop the legacy template-FuncMap wiring and switch the
sub server's static mount from web/assets to web/dist/assets.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MHSanaei 2026-05-08 19:44:50 +02:00
parent 440e3208a9
commit aaaa1a015f
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
7 changed files with 588 additions and 395 deletions

View file

@ -0,0 +1,467 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import {
SettingOutlined,
AndroidOutlined,
AppleOutlined,
DownOutlined,
CopyOutlined,
} from '@ant-design/icons-vue';
import { theme as antdTheme, message } from 'ant-design-vue';
import QRious from 'qrious';
import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils';
import {
theme as themeState,
antdThemeConfig,
} from '@/composables/useTheme.js';
import ThemeSwitchLogin from '@/components/ThemeSwitchLogin.vue';
const { t } = useI18n();
// Read the view-model Go injects via window.__SUB_PAGE_DATA__. Falls
// back to safe defaults so the page still renders if the global is
// missing (e.g. during local dev without the backend).
const subData = window.__SUB_PAGE_DATA__ || {};
const sId = subData.sId || '';
const enabled = !!subData.enabled;
const download = subData.download || '0';
const upload = subData.upload || '0';
const total = subData.total || '∞';
const used = subData.used || '0';
const remained = subData.remained || '';
const totalByte = Number(subData.totalByte || 0);
const expireMs = Number(subData.expire || 0) * 1000;
const lastOnlineMs = Number(subData.lastOnline || 0);
const subUrl = subData.subUrl || '';
const subJsonUrl = subData.subJsonUrl || '';
const subClashUrl = subData.subClashUrl || '';
const links = Array.isArray(subData.links) ? subData.links : [];
// Derived state ===============================================
const isUnlimited = computed(() => totalByte <= 0 && expireMs === 0);
const isActive = computed(() => {
if (!enabled) return false;
if (totalByte > 0) {
const used = Number(subData.usedByte || 0)
|| (Number(subData.downloadByte || 0) + Number(subData.uploadByte || 0));
if (used >= totalByte) return false;
}
if (expireMs > 0 && Date.now() >= expireMs) return false;
return true;
});
// Mobile-aware layout shows app dropdowns full-width below 576px
const isMobile = ref(false);
function updateMobile() { isMobile.value = window.innerWidth < 576; }
onMounted(() => {
updateMobile();
window.addEventListener('resize', updateMobile);
});
// Language switcher mirrors the legacy panel: setting the language
// triggers a full-page reload which re-renders with the new locale.
const lang = ref(LanguageManager.getLanguage());
function onLangChange(next) {
LanguageManager.setLanguage(next);
}
// QR code rendering ===========================================
// Each ref points at a canvas element we paint after mount; QRious
// sizes itself from the element's `size` attribute.
const subQr = ref(null);
const subJsonQr = ref(null);
const subClashQr = ref(null);
function paintQr(canvas, value) {
if (!canvas || !value) return;
// eslint-disable-next-line no-new
new QRious({
element: canvas,
size: 220,
value,
background: 'white',
backgroundAlpha: 1,
foreground: 'black',
padding: 4,
level: 'M',
});
}
onMounted(() => {
paintQr(subQr.value, subUrl);
paintQr(subJsonQr.value, subJsonUrl);
paintQr(subClashQr.value, subClashUrl);
});
// Actions =====================================================
async function copy(value) {
if (!value) return;
const ok = await ClipboardManager.copyText(value);
if (ok) message.success(t('copied'));
}
function open(url) {
if (!url) return;
window.open(url, '_blank');
}
// Pretty label per share link pulls protocol + remark out of the
// URL fragment (most clients put the remark after the # sign).
function linkName(link, idx) {
if (!link) return `Link ${idx + 1}`;
const hashIdx = link.indexOf('#');
if (hashIdx >= 0 && hashIdx + 1 < link.length) {
try {
return decodeURIComponent(link.slice(hashIdx + 1));
} catch (_e) {
return link.slice(hashIdx + 1);
}
}
const proto = link.split('://')[0];
return `${proto.toUpperCase()} ${idx + 1}`;
}
// iOS deep links taken verbatim from the legacy subpage. Each
// client expects the sub URL in a slightly different param name.
const shadowrocketUrl = computed(() => `sub://${btoa(subUrl)}`);
const v2boxUrl = computed(() => `v2box://install-sub?url=${encodeURIComponent(subUrl)}&name=${encodeURIComponent(sId)}`);
const streisandUrl = computed(() => `streisand://import/${encodeURIComponent(subUrl)}`);
const v2raytunUrl = computed(() => subUrl);
const npvtunUrl = computed(() => subUrl);
const happUrl = computed(() => `happ://add/${subUrl}`);
// Theme classes for the page wrapper.
const themeClass = computed(() => ({
'is-dark': themeState.isDark,
'is-ultra': themeState.isUltra,
}));
// Pull the AD-Vue theme algorithm in case anything in this file
// references it directly (a-config-provider does).
// eslint-disable-next-line no-unused-vars
const _antdAlgorithm = antdTheme.darkAlgorithm;
</script>
<template>
<a-config-provider :theme="antdThemeConfig">
<a-layout class="subscription-page" :class="themeClass">
<a-layout-content class="content">
<a-row type="flex" justify="center">
<a-col :xs="24" :sm="22" :md="18" :lg="14" :xl="12">
<a-card hoverable class="subscription-card">
<template #title>
<a-space>
<span>{{ t('subscription.title') }}</span>
<a-tag>{{ sId }}</a-tag>
</a-space>
</template>
<template #extra>
<a-popover :title="t('menu.settings')" placement="bottomRight" trigger="click">
<template #content>
<a-space direction="vertical" :size="10" class="settings-popover">
<ThemeSwitchLogin />
<span>{{ t('pages.settings.language') }}</span>
<a-select v-model:value="lang" class="lang-select" @change="onLangChange">
<a-select-option
v-for="l in LanguageManager.supportedLanguages"
:key="l.value"
:value="l.value"
>
<span :aria-label="l.name">{{ l.icon }}</span>
&nbsp;&nbsp;<span>{{ l.name }}</span>
</a-select-option>
</a-select>
</a-space>
</template>
<a-button shape="circle">
<template #icon><SettingOutlined /></template>
</a-button>
</a-popover>
</template>
<!-- ============== QR codes ============== -->
<a-row :gutter="[8, 8]" justify="center" class="qr-row">
<a-col :xs="24" :sm="subJsonUrl || subClashUrl ? 12 : 24" class="qr-col">
<div class="qr-box">
<a-tag color="purple" class="qr-tag">{{ t('pages.settings.subSettings') }}</a-tag>
<canvas
ref="subQr"
class="qr-canvas"
:title="t('copy')"
@click="copy(subUrl)"
/>
</div>
</a-col>
<a-col v-if="subJsonUrl" :xs="24" :sm="12" class="qr-col">
<div class="qr-box">
<a-tag color="purple" class="qr-tag">
{{ t('pages.settings.subSettings') }} JSON
</a-tag>
<canvas
ref="subJsonQr"
class="qr-canvas"
:title="t('copy')"
@click="copy(subJsonUrl)"
/>
</div>
</a-col>
<a-col v-if="subClashUrl" :xs="24" :sm="12" class="qr-col">
<div class="qr-box">
<a-tag color="purple" class="qr-tag">Clash / Mihomo</a-tag>
<canvas
ref="subClashQr"
class="qr-canvas"
:title="t('copy')"
@click="copy(subClashUrl)"
/>
</div>
</a-col>
</a-row>
<!-- ============== Subscription details ============== -->
<a-descriptions bordered :column="1" size="small" class="info-table">
<a-descriptions-item :label="t('subscription.subId')">{{ sId }}</a-descriptions-item>
<a-descriptions-item :label="t('subscription.status')">
<a-tag v-if="!enabled" color="red">{{ t('subscription.inactive') }}</a-tag>
<a-tag v-else-if="isUnlimited" color="purple">{{ t('subscription.unlimited') }}</a-tag>
<a-tag v-else :color="isActive ? 'green' : 'red'">
{{ isActive ? t('subscription.active') : t('subscription.inactive') }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item :label="t('subscription.downloaded')">{{ download }}</a-descriptions-item>
<a-descriptions-item :label="t('subscription.uploaded')">{{ upload }}</a-descriptions-item>
<a-descriptions-item :label="t('usage')">{{ used }}</a-descriptions-item>
<a-descriptions-item :label="t('subscription.totalQuota')">{{ total }}</a-descriptions-item>
<a-descriptions-item v-if="totalByte > 0" :label="t('remained')">
{{ remained }}
</a-descriptions-item>
<a-descriptions-item :label="t('lastOnline')">
<template v-if="lastOnlineMs > 0">{{ IntlUtil.formatDate(lastOnlineMs) }}</template>
<template v-else>-</template>
</a-descriptions-item>
<a-descriptions-item :label="t('subscription.expiry')">
<template v-if="expireMs === 0">{{ t('subscription.noExpiry') }}</template>
<template v-else>{{ IntlUtil.formatDate(expireMs) }}</template>
</a-descriptions-item>
</a-descriptions>
<!-- ============== Individual links ============== -->
<div v-if="links.length" class="links-section">
<div
v-for="(link, idx) in links"
:key="link"
class="link-row"
@click="copy(link)"
>
<a-tag color="purple" class="link-tag">{{ linkName(link, idx) }}</a-tag>
<div class="link-box">
<CopyOutlined class="link-copy-icon" />
{{ link }}
</div>
</div>
</div>
<!-- ============== App dropdowns ============== -->
<a-row :gutter="[8, 8]" justify="center" class="apps-row">
<a-col :xs="24" :sm="12" class="app-col">
<a-dropdown :trigger="['click']">
<a-button :block="isMobile" size="large" type="primary">
<AndroidOutlined /> Android <DownOutlined />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item key="android-v2box" @click="open(`v2box://install-sub?url=${encodeURIComponent(subUrl)}&name=${encodeURIComponent(sId)}`)">V2Box</a-menu-item>
<a-menu-item key="android-v2rayng" @click="open(`v2rayng://install-config?url=${encodeURIComponent(subUrl)}`)">V2RayNG</a-menu-item>
<a-menu-item key="android-singbox" @click="copy(subUrl)">Sing-box</a-menu-item>
<a-menu-item key="android-v2raytun" @click="copy(subUrl)">V2RayTun</a-menu-item>
<a-menu-item key="android-npvtunnel" @click="copy(subUrl)">NPV Tunnel</a-menu-item>
<a-menu-item key="android-happ" @click="open(`happ://add/${subUrl}`)">Happ</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-col>
<a-col :xs="24" :sm="12" class="app-col">
<a-dropdown :trigger="['click']">
<a-button :block="isMobile" size="large" type="primary">
<AppleOutlined /> iOS <DownOutlined />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item key="ios-shadowrocket" @click="open(shadowrocketUrl)">Shadowrocket</a-menu-item>
<a-menu-item key="ios-v2box" @click="open(v2boxUrl)">V2Box</a-menu-item>
<a-menu-item key="ios-streisand" @click="open(streisandUrl)">Streisand</a-menu-item>
<a-menu-item key="ios-v2raytun" @click="copy(v2raytunUrl)">V2RayTun</a-menu-item>
<a-menu-item key="ios-npvtunnel" @click="copy(npvtunUrl)">NPV Tunnel</a-menu-item>
<a-menu-item key="ios-happ" @click="open(happUrl)">Happ</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-col>
</a-row>
</a-card>
</a-col>
</a-row>
</a-layout-content>
</a-layout>
</a-config-provider>
</template>
<style scoped>
.subscription-page {
--bg-page: #e6e8ec;
--bg-card: #ffffff;
min-height: 100vh;
background: var(--bg-page);
}
.subscription-page.is-dark {
--bg-page: #0a1222;
--bg-card: #151f31;
}
.subscription-page.is-dark.is-ultra {
--bg-page: #21242a;
--bg-card: #0c0e12;
}
.subscription-page :deep(.ant-layout),
.subscription-page :deep(.ant-layout-content) {
background: transparent;
}
.content {
padding: 24px 12px;
}
.subscription-card {
margin-top: 8px;
}
/* QR section */
.qr-row {
margin-bottom: 12px;
}
.qr-col {
display: flex;
justify-content: center;
}
.qr-box {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 4px;
width: 220px;
}
.qr-tag {
width: 100%;
text-align: center;
margin: 0;
}
.qr-canvas {
cursor: pointer;
background: #fff;
border-radius: 4px;
}
/* Description list spacing + visible borders. AD-Vue's default
* descriptions border is rgba(5,5,5,0.06) which disappears against
* the white card in light theme. AD-Vue puts the horizontal divider
* on <tr> with border-collapse:collapse browsers treat <tr>
* borders inconsistently in collapse mode, so paint the divider on
* each cell's bottom edge instead. */
.info-table {
margin-top: 12px;
}
.info-table :deep(.ant-descriptions-view),
.info-table :deep(.ant-descriptions-view) table,
.info-table :deep(.ant-descriptions-view) th,
.info-table :deep(.ant-descriptions-view) td {
border-color: rgba(0, 0, 0, 0.18) !important;
}
.info-table :deep(tbody > tr > th),
.info-table :deep(tbody > tr > td) {
border-bottom: 1px solid rgba(0, 0, 0, 0.18) !important;
}
.info-table :deep(tbody > tr:last-child > th),
.info-table :deep(tbody > tr:last-child > td) {
border-bottom: none !important;
}
.is-dark .info-table :deep(.ant-descriptions-view),
.is-dark .info-table :deep(.ant-descriptions-view) table,
.is-dark .info-table :deep(.ant-descriptions-view) th,
.is-dark .info-table :deep(.ant-descriptions-view) td {
border-color: rgba(255, 255, 255, 0.18) !important;
}
.is-dark .info-table :deep(tbody > tr > th),
.is-dark .info-table :deep(tbody > tr > td) {
border-bottom: 1px solid rgba(255, 255, 255, 0.18) !important;
}
.is-dark .info-table :deep(tbody > tr:last-child > th),
.is-dark .info-table :deep(tbody > tr:last-child > td) {
border-bottom: none !important;
}
/* Share links */
.links-section {
margin-top: 16px;
}
.link-row {
position: relative;
margin-bottom: 16px;
text-align: center;
}
.link-tag {
margin-bottom: -10px;
position: relative;
z-index: 2;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.link-box {
cursor: pointer;
border-radius: 12px;
padding: 22px 18px 14px;
margin-top: -10px;
word-break: break-all;
font-size: 13px;
line-height: 1.5;
text-align: left;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
transition: background 120ms ease, border-color 120ms ease;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.08);
background: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.08);
}
.link-box:hover {
background: rgba(0, 0, 0, 0.05);
border-color: rgba(0, 0, 0, 0.14);
}
.link-copy-icon {
margin-right: 6px;
opacity: 0.6;
}
.is-dark .link-box {
background: rgba(0, 0, 0, 0.2);
border-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.85);
}
.is-dark .link-box:hover {
background: rgba(0, 0, 0, 0.3);
border-color: rgba(255, 255, 255, 0.2);
}
/* App dropdown row */
.apps-row {
margin-top: 24px;
}
.app-col {
text-align: center;
}
.settings-popover {
min-width: 220px;
}
.lang-select {
width: 100%;
}
</style>

18
frontend/src/subpage.js Normal file
View file

@ -0,0 +1,18 @@
import { createApp } from 'vue';
import Antd, { message } from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
// The sub page is served by the subscription HTTP server (sub/sub.go)
// at /<linksPath>/<subId>?html=1. Go injects window.__SUB_PAGE_DATA__
// with the parsed traffic/quota/expiry view-model and the rendered
// share links — the SPA reads those at mount.
import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js';
import SubPage from '@/pages/sub/SubPage.vue';
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
createApp(SubPage).use(Antd).use(i18n).mount('#app');

14
frontend/subpage.html Normal file
View file

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex,nofollow" />
<title>Subscription</title>
</head>
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/subpage.js"></script>
</body>
</html>

View file

@ -80,6 +80,7 @@ export default defineConfig({
settings: path.resolve(__dirname, 'settings.html'),
inbounds: path.resolve(__dirname, 'inbounds.html'),
xray: path.resolve(__dirname, 'xray.html'),
subpage: path.resolve(__dirname, 'subpage.html'),
},
},
},

View file

@ -5,13 +5,11 @@ package sub
import (
"context"
"crypto/tls"
"html/template"
"io"
"io/fs"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
@ -26,21 +24,6 @@ import (
"github.com/gin-gonic/gin"
)
// setEmbeddedTemplates parses and sets embedded templates on the engine
func setEmbeddedTemplates(engine *gin.Engine) error {
t, err := template.New("").Funcs(engine.FuncMap).ParseFS(
webpkg.EmbeddedHTML(),
"html/common/page.html",
"html/component/aThemeSwitch.html",
"html/settings/panel/subscription/subpage.html",
)
if err != nil {
return err
}
engine.SetHTMLTemplate(t)
return nil
}
// Server represents the subscription server that serves subscription links and JSON configurations.
type Server struct {
httpServer *http.Server
@ -190,45 +173,25 @@ func (s *Server) initRouter() (*gin.Engine, error) {
// set per-request localizer from headers/cookies
engine.Use(locale.LocalizerMiddleware())
// register i18n function similar to web server
i18nWebFunc := func(key string, params ...string) string {
return locale.I18n(locale.Web, key, params...)
}
engine.SetFuncMap(map[string]any{"i18n": i18nWebFunc})
// Templates: prefer embedded; fallback to disk if necessary
if err := setEmbeddedTemplates(engine); err != nil {
logger.Warning("sub: failed to parse embedded templates:", err)
if files, derr := s.getHtmlFiles(); derr == nil {
engine.LoadHTMLFiles(files...)
} else {
logger.Error("sub: no templates available (embedded parse and disk load failed)", err, derr)
}
}
// Assets: use disk if present, fallback to embedded
// Serve under both root (/assets) and under the subscription path prefix (LinksPath + "assets")
// so reverse proxies with a URI prefix can load assets correctly.
// Determine LinksPath earlier to compute prefixed assets mount.
// Mount the Vite-built dist/assets/ so the subscription page's JS/CSS
// bundles load from `/assets/...`. Also mount the same FS under the
// subscription path prefix (LinksPath + "assets") so reverse proxies
// running the panel under a URI prefix can resolve those URLs too.
// Note: LinksPath always starts and ends with "/" (validated in settings).
var linksPathForAssets string
if LinksPath == "/" {
linksPathForAssets = "/assets"
} else {
// ensure single slash join
linksPathForAssets = strings.TrimRight(LinksPath, "/") + "/assets"
}
// Mount assets in multiple paths to handle different URL patterns
var assetsFS http.FileSystem
if _, err := os.Stat("web/assets"); err == nil {
assetsFS = http.FS(os.DirFS("web/assets"))
if _, err := os.Stat("web/dist/assets"); err == nil {
assetsFS = http.FS(os.DirFS("web/dist/assets"))
} else if subFS, err := fs.Sub(webpkg.EmbeddedDist(), "dist/assets"); err == nil {
assetsFS = http.FS(subFS)
} else {
if subFS, err := fs.Sub(webpkg.EmbeddedAssets(), "assets"); err == nil {
assetsFS = http.FS(subFS)
} else {
logger.Error("sub: failed to mount embedded assets:", err)
}
logger.Error("sub: failed to mount embedded dist assets:", err)
}
if assetsFS != nil {
@ -237,19 +200,17 @@ func (s *Server) initRouter() (*gin.Engine, error) {
engine.StaticFS(linksPathForAssets, assetsFS)
}
// Add middleware to handle dynamic asset paths with subid
// Browser may resolve subpage assets relative to the request URL —
// /sub/<basePath>/<subId>/assets/... — so route those to the same FS.
if LinksPath != "/" {
engine.Use(func(c *gin.Context) {
path := c.Request.URL.Path
// Check if this is an asset request with subid pattern: /sub/path/{subid}/assets/...
pathPrefix := strings.TrimRight(LinksPath, "/") + "/"
if strings.HasPrefix(path, pathPrefix) && strings.Contains(path, "/assets/") {
// Extract the asset path after /assets/
assetsIndex := strings.Index(path, "/assets/")
if assetsIndex != -1 {
assetPath := path[assetsIndex+8:] // +8 to skip "/assets/"
if assetPath != "" {
// Serve the asset file
c.FileFromFS(assetPath, assetsFS)
c.Abort()
return
@ -271,30 +232,6 @@ func (s *Server) initRouter() (*gin.Engine, error) {
return engine, nil
}
// getHtmlFiles loads templates from local folder (used in debug mode)
func (s *Server) getHtmlFiles() ([]string, error) {
dir, _ := os.Getwd()
files := []string{}
// common layout
common := filepath.Join(dir, "web", "html", "common", "page.html")
if _, err := os.Stat(common); err == nil {
files = append(files, common)
}
// components used
theme := filepath.Join(dir, "web", "html", "component", "aThemeSwitch.html")
if _, err := os.Stat(theme); err == nil {
files = append(files, theme)
}
// page itself
page := filepath.Join(dir, "web", "html", "subpage.html")
if _, err := os.Stat(page); err == nil {
files = append(files, page)
} else {
return nil, err
}
return files, nil
}
// Start initializes and starts the subscription server with configured settings.
func (s *Server) Start() (err error) {
// This is an anonymous function, no function name

View file

@ -1,12 +1,15 @@
package sub
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/mhsanaei/3x-ui/v2/config"
webpkg "github.com/mhsanaei/3x-ui/v2/web"
"github.com/gin-gonic/gin"
)
@ -110,7 +113,6 @@ func (a *SUBController) subs(c *gin.Context) {
// If the request expects HTML (e.g., browser) or explicitly asked (?html=1 or ?view=html), render the info page here
accept := c.GetHeader("Accept")
if strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html") {
// Build page data in service
subURL, subJsonURL, subClashURL := a.subService.BuildURLs(scheme, hostWithPort, a.subPath, a.subJsonPath, a.subClashPath, subId)
if !a.jsonEnabled {
subJsonURL = ""
@ -118,43 +120,13 @@ func (a *SUBController) subs(c *gin.Context) {
if !a.clashEnabled {
subClashURL = ""
}
// Get base_path from context (set by middleware)
basePath, exists := c.Get("base_path")
if !exists {
basePath = "/"
}
// Add subId to base_path for asset URLs
basePathStr := basePath.(string)
if basePathStr == "/" {
basePathStr = "/" + subId + "/"
} else {
// Remove trailing slash if exists, add subId, then add trailing slash
basePathStr = strings.TrimRight(basePathStr, "/") + "/" + subId + "/"
}
page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL, subClashURL, basePathStr)
c.HTML(200, "subpage.html", gin.H{
"title": "subscription.title",
"cur_ver": config.GetVersion(),
"host": page.Host,
"base_path": page.BasePath,
"sId": page.SId,
"enabled": page.Enabled,
"download": page.Download,
"upload": page.Upload,
"total": page.Total,
"used": page.Used,
"remained": page.Remained,
"expire": page.Expire,
"lastOnline": page.LastOnline,
"datepicker": page.Datepicker,
"downloadByte": page.DownloadByte,
"uploadByte": page.UploadByte,
"totalByte": page.TotalByte,
"subUrl": page.SubUrl,
"subJsonUrl": page.SubJsonUrl,
"subClashUrl": page.SubClashUrl,
"result": page.Result,
})
a.serveSubPage(c, basePathStr, page)
return
}
@ -174,6 +146,78 @@ func (a *SUBController) subs(c *gin.Context) {
}
}
// serveSubPage renders web/dist/subpage.html for the current subscription
// request. The Vite-built SPA reads window.__SUB_PAGE_DATA__ on mount —
// we inject that here, along with window.__X_UI_BASE_PATH__ so the
// page's static asset references resolve correctly when the panel runs
// behind a URL prefix.
func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageData) {
dist := webpkg.EmbeddedDist()
body, err := dist.ReadFile("dist/subpage.html")
if err != nil {
c.String(http.StatusInternalServerError, "missing embedded subpage")
return
}
// Vite emits absolute asset URLs (`/assets/...`); when the panel is
// installed under a custom URL prefix, rewrite them so the bundle
// loads from `<basePath>assets/...` where the static handler is
// actually mounted.
if basePath != "/" && basePath != "" {
body = bytes.ReplaceAll(body, []byte(`src="/assets/`), []byte(`src="`+basePath+`assets/`))
body = bytes.ReplaceAll(body, []byte(`href="/assets/`), []byte(`href="`+basePath+`assets/`))
}
// JSON-marshal the view-model so the SPA can read it as a plain
// object on mount. PageData fields are already in the shape the Vue
// component expects, plus a `links` array carrying the rendered
// share URLs.
subData := map[string]any{
"sId": page.SId,
"enabled": page.Enabled,
"download": page.Download,
"upload": page.Upload,
"total": page.Total,
"used": page.Used,
"remained": page.Remained,
"expire": page.Expire,
"lastOnline": page.LastOnline,
"downloadByte": page.DownloadByte,
"uploadByte": page.UploadByte,
"totalByte": page.TotalByte,
"subUrl": page.SubUrl,
"subJsonUrl": page.SubJsonUrl,
"subClashUrl": page.SubClashUrl,
"links": page.Result,
}
subDataJSON, err := json.Marshal(subData)
if err != nil {
subDataJSON = []byte("{}")
}
// Defense-in-depth string-escape for the basePath embed — admin-
// controlled but cheap to harden.
jsEscape := strings.NewReplacer(
`\`, `\\`,
`"`, `\"`,
"\n", `\n`,
"\r", `\r`,
"<", `<`,
">", `>`,
"&", `&`,
)
escapedBase := jsEscape.Replace(basePath)
inject := []byte(`<script>window.__X_UI_BASE_PATH__="` + escapedBase + `";` +
`window.__SUB_PAGE_DATA__=` + string(subDataJSON) + `;</script></head>`)
out := bytes.Replace(body, []byte("</head>"), inject, 1)
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
c.Header("Pragma", "no-cache")
c.Header("Expires", "0")
c.Data(http.StatusOK, "text/html; charset=utf-8", out)
}
// subJsons handles HTTP requests for JSON subscription configurations.
func (a *SUBController) subJsons(c *gin.Context) {
subId := c.Param("subid")

View file

@ -1,288 +0,0 @@
{{ template "page/head_start" .}}
<script src="{{ .base_path }}assets/moment/moment.min.js"></script>
<script src="{{ .base_path }}assets/moment/moment-jalali.min.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/vue/vue.min.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/ant-design-vue/antd.min.js"></script>
<script src="{{ .base_path }}assets/js/util/index.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/qrcode/qrious2.min.js?{{ .cur_ver }}"></script>
<style>
.subscription-page tr-qr-box.qr-box {
display: inline-flex;
flex-direction: column;
align-items: center;
width: 220px;
}
.subscription-page tr-qr-box.qr-box .qr-tag {
width: 100%;
justify-content: center;
}
.subscription-page tr-qr-box.qr-box .qr-bg,
.subscription-page tr-qr-box.qr-box .qr-bg-sub {
margin-inline: auto;
}
.subscription-page .subscription-link-box {
cursor: pointer;
border-radius: 12px;
padding: 25px 20px 15px 20px;
margin-top: -12px;
word-break: break-all;
font-size: 13px;
line-height: 1.5;
text-align: left;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
transition: all 0.3s;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.dark.subscription-page .subscription-link-box {
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #fff;
}
.dark.subscription-page .subscription-link-box:hover {
background: rgba(0, 0, 0, 0.3);
border-color: rgba(255, 255, 255, 0.2);
}
.light.subscription-page .subscription-link-box {
background: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.08);
color: rgba(0, 0, 0, 0.85);
}
.light.subscription-page .subscription-link-box:hover {
background: rgba(0, 0, 0, 0.05);
border-color: rgba(0, 0, 0, 0.14);
}
</style>
{{ template "page/head_end" .}}
{{ template "page/body_start" .}}
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' subscription-page'">
<a-layout-content class="p-2">
<a-row type="flex" justify="center" class="mt-2">
<a-col :xs="24" :sm="22" :md="18" :lg="14" :xl="12">
<a-card hoverable class="subscription-card">
<template #title>
<a-space>
<span>{{ i18n "subscription.title" }}</span>
<a-tag>{{ .sId }}</a-tag>
</a-space>
</template>
<template #extra>
<a-popover :overlay-class-name="themeSwitcher.currentTheme" title='{{ i18n "menu.settings" }}'
placement="bottomRight" trigger="click">
<template #content>
<a-space direction="vertical" :size="10">
<a-theme-switch-login></a-theme-switch-login>
<span>{{ i18n "pages.settings.language"
}}</span>
<a-select ref="selectLang" class="w-100" v-model="lang"
@change="LanguageManager.setLanguage(lang)"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option :value="l.value" label="English"
v-for="l in LanguageManager.supportedLanguages" :key="l.value">
<span role="img" :aria-label="l.name" v-text="l.icon"></span>
&nbsp;&nbsp;<span v-text="l.name"></span>
</a-select-option>
</a-select>
</a-space>
</template>
<a-button shape="circle" icon="setting"></a-button>
</a-popover>
</template>
<a-form layout="vertical">
<a-form-item>
<a-space direction="vertical" align="center">
<a-row type="flex" :gutter="[8,8]" justify="center" style="width:100%">
<a-col :xs="24" :sm="app.subJsonUrl || app.subClashUrl ? 12 : 24"
style="text-align:center;">
<tr-qr-box class="qr-box">
<a-tag color="purple" class="qr-tag">
<span>{{ i18n
"pages.settings.subSettings"}}</span>
</a-tag>
<tr-qr-bg class="qr-bg-sub">
<tr-qr-bg-inner class="qr-bg-sub-inner">
<canvas id="qrcode" class="qr-cv" title='{{ i18n "copy" }}'
@click="copy(app.subUrl)"></canvas>
</tr-qr-bg-inner>
</tr-qr-bg>
</tr-qr-box>
</a-col>
<a-col v-if="app.subJsonUrl" :xs="24" :sm="12" style="text-align:center;">
<tr-qr-box class="qr-box">
<a-tag color="purple" class="qr-tag">
<span>{{ i18n
"pages.settings.subSettings"}}
Json</span>
</a-tag>
<tr-qr-bg class="qr-bg-sub">
<tr-qr-bg-inner class="qr-bg-sub-inner">
<canvas id="qrcode-subjson" class="qr-cv" title='{{ i18n "copy" }}'
@click="copy(app.subJsonUrl)"></canvas>
</tr-qr-bg-inner>
</tr-qr-bg>
</tr-qr-box>
</a-col>
<a-col v-if="app.subClashUrl" :xs="24" :sm="12" style="text-align:center;">
<tr-qr-box class="qr-box">
<a-tag color="purple" class="qr-tag">
<span>Clash / Mihomo</span>
</a-tag>
<tr-qr-bg class="qr-bg-sub">
<tr-qr-bg-inner class="qr-bg-sub-inner">
<canvas id="qrcode-subclash" class="qr-cv" title='{{ i18n "copy" }}'
@click="copy(app.subClashUrl)"></canvas>
</tr-qr-bg-inner>
</tr-qr-bg>
</tr-qr-box>
</a-col>
</a-row>
</a-space>
</a-form-item>
<a-form-item>
<a-descriptions bordered :column="1" size="small">
<a-descriptions-item label='{{ i18n "subscription.subId" }}'>[[
app.sId
]]</a-descriptions-item>
<a-descriptions-item label='{{ i18n "subscription.status" }}'>
<template v-if="!app.enabled">
<a-tag color="red">{{ i18n "subscription.inactive" }}</a-tag>
</template>
<template v-else-if="isUnlimited">
<a-tag color="purple">{{ i18n
"subscription.unlimited" }}</a-tag>
</template>
<template v-else>
<a-tag :color="isActive ? 'green' : 'red'">[[
isActive ? '{{ i18n
"subscription.active" }}' : '{{ i18n
"subscription.inactive" }}'
]]</a-tag>
</template>
</a-descriptions-item>
<a-descriptions-item label='{{ i18n "subscription.downloaded" }}'>[[
app.download
]]</a-descriptions-item>
<a-descriptions-item label='{{ i18n "subscription.uploaded" }}'>[[
app.upload
]]</a-descriptions-item>
<a-descriptions-item label='{{ i18n "usage" }}'>[[ app.used
]]</a-descriptions-item>
<a-descriptions-item label='{{ i18n "subscription.totalQuota" }}'>[[
app.total
]]</a-descriptions-item>
<a-descriptions-item v-if="app.totalByte > 0" label='{{ i18n "remained" }}'>[[
app.remained ]]</a-descriptions-item>
<a-descriptions-item label='{{ i18n "lastOnline" }}'>
<template v-if="app.lastOnlineMs > 0">
[[ IntlUtil.formatDate(app.lastOnlineMs) ]]
</template>
<template v-else>
<span>-</span>
</template>
</a-descriptions-item>
<a-descriptions-item label='{{ i18n "subscription.expiry" }}'>
<template v-if="app.expireMs === 0">
{{ i18n "subscription.noExpiry" }}
</template>
<template v-else>
[[ IntlUtil.formatDate(app.expireMs) ]]
</template>
</a-descriptions-item>
</a-descriptions>
</a-form-item>
</a-form>
<br />
<div v-for="(link, idx) in links" :key="link"
style="position: relative; margin-bottom: 20px; text-align: center;">
<div class="qr-box" style="display: inline-block; width: 100%; max-width: 100%;">
<a-tag color="purple"
style="margin-bottom: -10px; position: relative; z-index: 2; box-shadow: 0 2px 4px rgba(0,0,0,0.2);">
<span>[[ linkName(link, idx) ]]</span>
</a-tag>
<div @click="copy(link)" class="subscription-link-box">
[[ link ]]
</div>
</div>
</div>
<br />
<a-form layout="vertical">
<a-form-item>
<a-row type="flex" justify="center" :gutter="[8,8]" style="width:100%">
<a-col :xs="24" :sm="12" style="text-align:center;">
<!-- Android dropdown -->
<a-dropdown :trigger="['click']">
<a-button icon="android" :block="isMobile"
:style="{ marginTop: isMobile ? '6px' : 0 }" size="large" type="primary">
Android <a-icon type="down" />
</a-button>
<a-menu slot="overlay" :class="themeSwitcher.currentTheme">
<a-menu-item key="android-v2box"
@click="open('v2box://install-sub?url=' + encodeURIComponent(app.subUrl) + '&name=' + encodeURIComponent(app.sId))">V2Box</a-menu-item>
<a-menu-item key="android-v2rayng"
@click="open('v2rayng://install-config?url=' + encodeURIComponent(app.subUrl))">V2RayNG</a-menu-item>
<a-menu-item key="android-singbox"
@click="copy(app.subUrl)">Sing-box</a-menu-item>
<a-menu-item key="android-v2raytun"
@click="copy(app.subUrl)">V2RayTun</a-menu-item>
<a-menu-item key="android-npvtunnel" @click="copy(app.subUrl)">NPV
Tunnel</a-menu-item>
<a-menu-item key="android-happ"
@click="open('happ://add/' + app.subUrl)">Happ</a-menu-item>
</a-menu>
</a-dropdown>
</a-col>
<a-col :xs="24" :sm="12" style="text-align:center;">
<!-- iOS dropdown -->
<a-dropdown :trigger="['click']">
<a-button icon="apple" :block="isMobile"
:style="{ marginTop: isMobile ? '6px' : 0 }" size="large" type="primary">
iOS <a-icon type="down" />
</a-button>
<a-menu slot="overlay" :class="themeSwitcher.currentTheme">
<a-menu-item key="ios-shadowrocket"
@click="open(shadowrocketUrl)">Shadowrocket</a-menu-item>
<a-menu-item key="ios-v2box" @click="open(v2boxUrl)">V2Box</a-menu-item>
<a-menu-item key="ios-streisand"
@click="open(streisandUrl)">Streisand</a-menu-item>
<a-menu-item key="ios-v2raytun"
@click="copy(v2raytunUrl)">V2RayTun</a-menu-item>
<a-menu-item key="ios-npvtunnel" @click="copy(npvtunUrl)">NPV
Tunnel
</a-menu-item>
<a-menu-item key="ios-happ" @click="open(happUrl)">Happ</a-menu-item>
</a-menu>
</a-dropdown>
</a-col>
</a-row>
</a-form-item>
</a-form>
</a-card>
</a-col>
</a-row>
</a-layout-content>
</a-layout>
<!-- Bootstrap data for external JS -->
<template id="subscription-data" data-sid="{{ .sId }}" data-sub-url="{{ .subUrl }}" data-subjson-url="{{ .subJsonUrl }}"
data-subclash-url="{{ .subClashUrl }}" data-download="{{ .download }}" data-upload="{{ .upload }}"
data-used="{{ .used }}" data-total="{{ .total }}" data-remained="{{ .remained }}" data-expire="{{ .expire }}"
data-lastonline="{{ .lastOnline }}" data-downloadbyte="{{ .downloadByte }}" data-uploadbyte="{{ .uploadByte }}"
data-totalbyte="{{ .totalByte }}" data-datepicker="{{ .datepicker }}" data-enabled="{{ .enabled }}"></template>
<textarea id="subscription-links" style="display:none">{{ range .result }}{{ . }}
{{ end }}</textarea>
{{template "component/aThemeSwitch" .}}
<script src="{{ .base_path }}assets/js/subscription.js?{{ .cur_ver }}"></script>
{{ template "page/body_end" .}}