Browse Source

Subscription

mhsanaei 1 day ago
parent
commit
10025ffa66

+ 55 - 0
sub/sub.go

@@ -6,11 +6,14 @@ import (
 	"io"
 	"io"
 	"net"
 	"net"
 	"net/http"
 	"net/http"
+	"os"
+	"path/filepath"
 	"strconv"
 	"strconv"
 
 
 	"x-ui/config"
 	"x-ui/config"
 	"x-ui/logger"
 	"x-ui/logger"
 	"x-ui/util/common"
 	"x-ui/util/common"
+	"x-ui/web/locale"
 	"x-ui/web/middleware"
 	"x-ui/web/middleware"
 	"x-ui/web/network"
 	"x-ui/web/network"
 	"x-ui/web/service"
 	"x-ui/web/service"
@@ -57,6 +60,11 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 		engine.Use(middleware.DomainValidatorMiddleware(subDomain))
 		engine.Use(middleware.DomainValidatorMiddleware(subDomain))
 	}
 	}
 
 
+	// Provide base_path in context for templates
+	engine.Use(func(c *gin.Context) {
+		c.Set("base_path", "/")
+	})
+
 	LinksPath, err := s.settingService.GetSubPath()
 	LinksPath, err := s.settingService.GetSubPath()
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
@@ -112,6 +120,29 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 		SubTitle = ""
 		SubTitle = ""
 	}
 	}
 
 
+	// init i18n for sub server using disk FS so templates can use {{ i18n }}
+	// Root FS is project root; translation files are under web/translation
+	if err := locale.InitLocalizerFS(os.DirFS("web"), &s.settingService); err != nil {
+		logger.Warning("sub: i18n init failed:", err)
+	}
+	// set per-request localizer from headers/cookies
+	engine.Use(locale.LocalizerMiddleware())
+
+	// load HTML templates needed for subscription page (common layout + page + component + subscription)
+	if files, err := s.getHtmlFiles(); err != nil {
+		logger.Warning("sub: getHtmlFiles failed:", err)
+	} else {
+		// 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})
+		engine.LoadHTMLFiles(files...)
+	}
+
+	// serve assets from web/assets to use shared JS/CSS like other pages
+	engine.StaticFS("/assets", http.FS(os.DirFS("web/assets")))
+
 	g := engine.Group("/")
 	g := engine.Group("/")
 
 
 	s.sub = NewSUBController(
 	s.sub = NewSUBController(
@@ -121,6 +152,30 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 	return engine, nil
 	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", "subscription.html")
+	if _, err := os.Stat(page); err == nil {
+		files = append(files, page)
+	} else {
+		return nil, err
+	}
+	return files, nil
+}
+
 func (s *Server) Start() (err error) {
 func (s *Server) Start() (err error) {
 	// This is an anonymous function, no function name
 	// This is an anonymous function, no function name
 	defer func() {
 	defer func() {

+ 38 - 48
sub/subController.go

@@ -2,7 +2,6 @@ package sub
 
 
 import (
 import (
 	"encoding/base64"
 	"encoding/base64"
-	"net"
 	"strings"
 	"strings"
 
 
 	"github.com/gin-gonic/gin"
 	"github.com/gin-gonic/gin"
@@ -58,21 +57,8 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) {
 
 
 func (a *SUBController) subs(c *gin.Context) {
 func (a *SUBController) subs(c *gin.Context) {
 	subId := c.Param("subid")
 	subId := c.Param("subid")
-	var host string
-	if h, err := getHostFromXFH(c.GetHeader("X-Forwarded-Host")); err == nil {
-		host = h
-	}
-	if host == "" {
-		host = c.GetHeader("X-Real-IP")
-	}
-	if host == "" {
-		var err error
-		host, _, err = net.SplitHostPort(c.Request.Host)
-		if err != nil {
-			host = c.Request.Host
-		}
-	}
-	subs, header, err := a.subService.GetSubs(subId, host)
+	scheme, host, hostWithPort, hostHeader := a.subService.ResolveRequest(c)
+	subs, header, lastOnline, err := a.subService.GetSubs(subId, host)
 	if err != nil || len(subs) == 0 {
 	if err != nil || len(subs) == 0 {
 		c.String(400, "Error!")
 		c.String(400, "Error!")
 	} else {
 	} else {
@@ -81,10 +67,38 @@ func (a *SUBController) subs(c *gin.Context) {
 			result += sub + "\n"
 			result += sub + "\n"
 		}
 		}
 
 
-		// Add headers
-		c.Writer.Header().Set("Subscription-Userinfo", header)
-		c.Writer.Header().Set("Profile-Update-Interval", a.updateInterval)
-		c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(a.subTitle)))
+		// Add headers via service
+		a.subService.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle)
+		a.subService.ApplyBase64ContentHeader(c, result)
+
+		// 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 := a.subService.BuildURLs(scheme, hostWithPort, a.subPath, a.subJsonPath, subId)
+			page := a.subService.BuildPageData(subId, hostHeader, header, lastOnline, subs, subURL, subJsonURL)
+			c.HTML(200, "subscription.html", gin.H{
+				"title":        "subscription.title",
+				"host":         page.Host,
+				"base_path":    page.BasePath,
+				"sId":          page.SId,
+				"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,
+				"result":       page.Result,
+			})
+			return
+		}
 
 
 		if a.subEncrypt {
 		if a.subEncrypt {
 			c.String(200, base64.StdEncoding.EncodeToString([]byte(result)))
 			c.String(200, base64.StdEncoding.EncodeToString([]byte(result)))
@@ -96,41 +110,17 @@ func (a *SUBController) subs(c *gin.Context) {
 
 
 func (a *SUBController) subJsons(c *gin.Context) {
 func (a *SUBController) subJsons(c *gin.Context) {
 	subId := c.Param("subid")
 	subId := c.Param("subid")
-	var host string
-	if h, err := getHostFromXFH(c.GetHeader("X-Forwarded-Host")); err == nil {
-		host = h
-	}
-	if host == "" {
-		host = c.GetHeader("X-Real-IP")
-	}
-	if host == "" {
-		var err error
-		host, _, err = net.SplitHostPort(c.Request.Host)
-		if err != nil {
-			host = c.Request.Host
-		}
-	}
+	_, host, _, _ := a.subService.ResolveRequest(c)
 	jsonSub, header, err := a.subJsonService.GetJson(subId, host)
 	jsonSub, header, err := a.subJsonService.GetJson(subId, host)
 	if err != nil || len(jsonSub) == 0 {
 	if err != nil || len(jsonSub) == 0 {
 		c.String(400, "Error!")
 		c.String(400, "Error!")
 	} else {
 	} else {
 
 
-		// Add headers
-		c.Writer.Header().Set("Subscription-Userinfo", header)
-		c.Writer.Header().Set("Profile-Update-Interval", a.updateInterval)
-		c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(a.subTitle)))
+		// Add headers via service
+		a.subService.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle)
 
 
 		c.String(200, jsonSub)
 		c.String(200, jsonSub)
 	}
 	}
 }
 }
 
 
-func getHostFromXFH(s string) (string, error) {
-	if strings.Contains(s, ":") {
-		realHost, _, err := net.SplitHostPort(s)
-		if err != nil {
-			return "", err
-		}
-		return realHost, nil
-	}
-	return s, nil
-}
+// Note: host parsing and page data preparation moved to SubService

+ 196 - 7
sub/subService.go

@@ -3,10 +3,15 @@ package sub
 import (
 import (
 	"encoding/base64"
 	"encoding/base64"
 	"fmt"
 	"fmt"
+	"net"
 	"net/url"
 	"net/url"
+	"strconv"
 	"strings"
 	"strings"
 	"time"
 	"time"
 
 
+	"github.com/gin-gonic/gin"
+	"github.com/goccy/go-json"
+
 	"x-ui/database"
 	"x-ui/database"
 	"x-ui/database/model"
 	"x-ui/database/model"
 	"x-ui/logger"
 	"x-ui/logger"
@@ -14,8 +19,6 @@ import (
 	"x-ui/util/random"
 	"x-ui/util/random"
 	"x-ui/web/service"
 	"x-ui/web/service"
 	"x-ui/xray"
 	"x-ui/xray"
-
-	"github.com/goccy/go-json"
 )
 )
 
 
 type SubService struct {
 type SubService struct {
@@ -34,19 +37,20 @@ func NewSubService(showInfo bool, remarkModel string) *SubService {
 	}
 	}
 }
 }
 
 
-func (s *SubService) GetSubs(subId string, host string) ([]string, string, error) {
+func (s *SubService) GetSubs(subId string, host string) ([]string, string, int64, error) {
 	s.address = host
 	s.address = host
 	var result []string
 	var result []string
 	var header string
 	var header string
 	var traffic xray.ClientTraffic
 	var traffic xray.ClientTraffic
+	var lastOnline int64
 	var clientTraffics []xray.ClientTraffic
 	var clientTraffics []xray.ClientTraffic
 	inbounds, err := s.getInboundsBySubId(subId)
 	inbounds, err := s.getInboundsBySubId(subId)
 	if err != nil {
 	if err != nil {
-		return nil, "", err
+		return nil, "", 0, err
 	}
 	}
 
 
 	if len(inbounds) == 0 {
 	if len(inbounds) == 0 {
-		return nil, "", common.NewError("No inbounds found with ", subId)
+		return nil, "", 0, common.NewError("No inbounds found with ", subId)
 	}
 	}
 
 
 	s.datepicker, err = s.settingService.GetDatepicker()
 	s.datepicker, err = s.settingService.GetDatepicker()
@@ -73,7 +77,11 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, string, error
 			if client.Enable && client.SubID == subId {
 			if client.Enable && client.SubID == subId {
 				link := s.getLink(inbound, client.Email)
 				link := s.getLink(inbound, client.Email)
 				result = append(result, link)
 				result = append(result, link)
-				clientTraffics = append(clientTraffics, s.getClientTraffics(inbound.ClientStats, client.Email))
+				ct := s.getClientTraffics(inbound.ClientStats, client.Email)
+				clientTraffics = append(clientTraffics, ct)
+				if ct.LastOnline > lastOnline {
+					lastOnline = ct.LastOnline
+				}
 			}
 			}
 		}
 		}
 	}
 	}
@@ -101,7 +109,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, string, error
 		}
 		}
 	}
 	}
 	header = fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
 	header = fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
-	return result, header, nil
+	return result, header, lastOnline, nil
 }
 }
 
 
 func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) {
 func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) {
@@ -1001,3 +1009,184 @@ func searchHost(headers any) string {
 
 
 	return ""
 	return ""
 }
 }
+
+// PageData is a view model for subscription.html
+type PageData struct {
+	Host         string
+	BasePath     string
+	SId          string
+	Download     string
+	Upload       string
+	Total        string
+	Used         string
+	Remained     string
+	Expire       int64
+	LastOnline   int64
+	Datepicker   string
+	DownloadByte int64
+	UploadByte   int64
+	TotalByte    int64
+	SubUrl       string
+	SubJsonUrl   string
+	Result       []string
+}
+
+// ResolveRequest extracts scheme and host info from request/headers consistently.
+func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string, hostWithPort string, hostHeader string) {
+	// scheme
+	scheme = "http"
+	if c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") {
+		scheme = "https"
+	}
+
+	// base host (no port)
+	if h, err := getHostFromXFH(c.GetHeader("X-Forwarded-Host")); err == nil && h != "" {
+		host = h
+	}
+	if host == "" {
+		host = c.GetHeader("X-Real-IP")
+	}
+	if host == "" {
+		var err error
+		host, _, err = net.SplitHostPort(c.Request.Host)
+		if err != nil {
+			host = c.Request.Host
+		}
+	}
+
+	// host:port for URLs
+	hostWithPort = c.GetHeader("X-Forwarded-Host")
+	if hostWithPort == "" {
+		hostWithPort = c.Request.Host
+	}
+	if hostWithPort == "" {
+		hostWithPort = host
+	}
+
+	// header display host
+	hostHeader = c.GetHeader("X-Forwarded-Host")
+	if hostHeader == "" {
+		hostHeader = c.GetHeader("X-Real-IP")
+	}
+	if hostHeader == "" {
+		hostHeader = host
+	}
+	return
+}
+
+// BuildURLs constructs absolute subscription and json URLs.
+func (s *SubService) BuildURLs(scheme, hostWithPort, subPath, subJsonPath, subId string) (subURL, subJsonURL string) {
+	if strings.HasSuffix(subPath, "/") {
+		subURL = scheme + "://" + hostWithPort + subPath + subId
+	} else {
+		subURL = scheme + "://" + hostWithPort + strings.TrimRight(subPath, "/") + "/" + subId
+	}
+	if strings.HasSuffix(subJsonPath, "/") {
+		subJsonURL = scheme + "://" + hostWithPort + subJsonPath + subId
+	} else {
+		subJsonURL = scheme + "://" + hostWithPort + strings.TrimRight(subJsonPath, "/") + "/" + subId
+	}
+	return
+}
+
+// BuildPageData parses header and prepares the template view model.
+func (s *SubService) BuildPageData(subId, hostHeader, header string, lastOnline int64, subs []string, subURL, subJsonURL string) PageData {
+	// Parse header values
+	var uploadByte, downloadByte, totalByte, expire int64
+	parts := strings.Split(header, ";")
+	for _, p := range parts {
+		kv := strings.Split(strings.TrimSpace(p), "=")
+		if len(kv) != 2 {
+			continue
+		}
+		key := strings.ToLower(strings.TrimSpace(kv[0]))
+		val := strings.TrimSpace(kv[1])
+		switch key {
+		case "upload":
+			if v, err := parseInt64(val); err == nil {
+				uploadByte = v
+			}
+		case "download":
+			if v, err := parseInt64(val); err == nil {
+				downloadByte = v
+			}
+		case "total":
+			if v, err := parseInt64(val); err == nil {
+				totalByte = v
+			}
+		case "expire":
+			if v, err := parseInt64(val); err == nil {
+				expire = v
+			}
+		}
+	}
+
+	download := common.FormatTraffic(downloadByte)
+	upload := common.FormatTraffic(uploadByte)
+	total := "∞"
+	used := common.FormatTraffic(uploadByte + downloadByte)
+	remained := ""
+	if totalByte > 0 {
+		total = common.FormatTraffic(totalByte)
+		left := totalByte - (uploadByte + downloadByte)
+		if left < 0 {
+			left = 0
+		}
+		remained = common.FormatTraffic(left)
+	}
+
+	datepicker := s.datepicker
+	if datepicker == "" {
+		datepicker = "gregorian"
+	}
+
+	return PageData{
+		Host:         hostHeader,
+		BasePath:     "/",
+		SId:          subId,
+		Download:     download,
+		Upload:       upload,
+		Total:        total,
+		Used:         used,
+		Remained:     remained,
+		Expire:       expire,
+		LastOnline:   lastOnline,
+		Datepicker:   datepicker,
+		DownloadByte: downloadByte,
+		UploadByte:   uploadByte,
+		TotalByte:    totalByte,
+		SubUrl:       subURL,
+		SubJsonUrl:   subJsonURL,
+		Result:       subs,
+	}
+}
+
+func getHostFromXFH(s string) (string, error) {
+	if strings.Contains(s, ":") {
+		realHost, _, err := net.SplitHostPort(s)
+		if err != nil {
+			return "", err
+		}
+		return realHost, nil
+	}
+	return s, nil
+}
+
+func parseInt64(s string) (int64, error) {
+	// handle potential quotes
+	s = strings.Trim(s, "\"'")
+	n, err := strconv.ParseInt(s, 10, 64)
+	return n, err
+}
+
+// ApplyCommonHeaders sets standard subscription headers on the response writer.
+func (s *SubService) ApplyCommonHeaders(c *gin.Context, header, updateInterval, profileTitle string) {
+	c.Writer.Header().Set("Subscription-Userinfo", header)
+	c.Writer.Header().Set("Profile-Update-Interval", updateInterval)
+	c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileTitle)))
+}
+
+// ApplyBase64ContentHeader adds the full subscription content as base64 header for convenience.
+func (s *SubService) ApplyBase64ContentHeader(c *gin.Context, content string) {
+	c.Writer.Header().Set("Subscription-Content-Base64", base64.StdEncoding.EncodeToString([]byte(content)))
+}

File diff suppressed because it is too large
+ 0 - 0
web/assets/css/custom.min.css


+ 125 - 0
web/assets/js/subscription.js

@@ -0,0 +1,125 @@
+(function () {
+  // Vue app for Subscription page
+  const el = document.getElementById('subscription-data');
+  if (!el) return;
+  const textarea = document.getElementById('subscription-links');
+  const rawLinks = (textarea?.value || '').split('\n').filter(Boolean);
+
+  const data = {
+    sId: el.getAttribute('data-sid') || '',
+    subUrl: el.getAttribute('data-sub-url') || '',
+    subJsonUrl: el.getAttribute('data-subjson-url') || '',
+    download: el.getAttribute('data-download') || '',
+    upload: el.getAttribute('data-upload') || '',
+    used: el.getAttribute('data-used') || '',
+    total: el.getAttribute('data-total') || '',
+    remained: el.getAttribute('data-remained') || '',
+    expireMs: (parseInt(el.getAttribute('data-expire') || '0', 10) || 0) * 1000,
+    lastOnlineMs: (parseInt(el.getAttribute('data-lastonline') || '0', 10) || 0),
+    downloadByte: parseInt(el.getAttribute('data-downloadbyte') || '0', 10) || 0,
+    uploadByte: parseInt(el.getAttribute('data-uploadbyte') || '0', 10) || 0,
+    totalByte: parseInt(el.getAttribute('data-totalbyte') || '0', 10) || 0,
+    datepicker: el.getAttribute('data-datepicker') || 'gregorian',
+  };
+
+  // Normalize lastOnline to milliseconds if it looks like seconds
+  if (data.lastOnlineMs && data.lastOnlineMs < 10_000_000_000) {
+    data.lastOnlineMs *= 1000;
+  }
+
+  function renderLink(item) {
+    return (
+      Vue.h('a-list-item', {}, [
+        Vue.h('a-space', { props: { size: 'small' } }, [
+          Vue.h('a-button', { props: { size: 'small' }, on: { click: () => copy(item) } }, [Vue.h('a-icon', { props: { type: 'copy' } })]),
+          Vue.h('span', { class: 'break-all' }, item)
+        ])
+      ])
+    );
+  }
+
+  function copy(text) {
+    ClipboardManager.copyText(text).then(ok => {
+      const messageType = ok ? 'success' : 'error';
+      Vue.prototype.$message[messageType](ok ? 'Copied' : 'Copy failed');
+    });
+  }
+
+  function open(url) {
+    window.location.href = url;
+  }
+
+  function drawQR(value) {
+    try { new QRious({ element: document.getElementById('qrcode'), value, size: 220 }); } catch (e) { console.warn(e); }
+  }
+
+  // Try to extract a human label (email/ps) from different link types
+  function linkName(link, idx) {
+    try {
+      if (link.startsWith('vmess://')) {
+        const json = JSON.parse(atob(link.replace('vmess://', '')));
+        if (json.ps) return json.ps;
+        if (json.add && json.id) return json.add; // fallback host
+      } else if (link.startsWith('vless://') || link.startsWith('trojan://')) {
+        // vless://<id>@host:port?...#name
+        const hashIdx = link.indexOf('#');
+        if (hashIdx !== -1) return decodeURIComponent(link.substring(hashIdx + 1));
+        // email sometimes in query params like sni or remark
+        const qIdx = link.indexOf('?');
+        if (qIdx !== -1) {
+          const qs = new URL('http://x/?' + link.substring(qIdx + 1, hashIdx !== -1 ? hashIdx : undefined)).searchParams;
+          if (qs.get('remark')) return qs.get('remark');
+          if (qs.get('email')) return qs.get('email');
+        }
+        // else take user@host
+        const at = link.indexOf('@');
+        const protSep = link.indexOf('://');
+        if (at !== -1 && protSep !== -1) return link.substring(protSep + 3, at);
+      } else if (link.startsWith('ss://')) {
+        // shadowsocks: label often after #
+        const hashIdx = link.indexOf('#');
+        if (hashIdx !== -1) return decodeURIComponent(link.substring(hashIdx + 1));
+      }
+    } catch (e) { /* ignore and fallback */ }
+    return 'Link ' + (idx + 1);
+  }
+
+  const app = new Vue({
+    delimiters: ['[[', ']]'],
+    el: '#app',
+    data: {
+      themeSwitcher,
+      app: data,
+      links: rawLinks,
+      lang: '',
+      viewportWidth: (typeof window !== 'undefined' ? window.innerWidth : 1024),
+    },
+    async mounted() {
+      this.lang = LanguageManager.getLanguage();
+      // Discover subJsonUrl if provided via template bootstrap
+      const tpl = document.getElementById('subscription-data');
+      const sj = tpl ? tpl.getAttribute('data-subjson-url') : '';
+      if (sj) this.app.subJsonUrl = sj;
+      drawQR(this.app.subUrl);
+      // Draw second QR if available
+      try { new QRious({ element: document.getElementById('qrcode-subjson'), value: this.app.subJsonUrl || '', size: 220 }); } catch (e) { /* ignore */ }
+      // Track viewport width for responsive behavior
+      this._onResize = () => { this.viewportWidth = window.innerWidth; };
+      window.addEventListener('resize', this._onResize);
+    },
+    beforeDestroy() {
+      if (this._onResize) window.removeEventListener('resize', this._onResize);
+    },
+    computed: {
+      isMobile() { return this.viewportWidth < 576; },
+      isUnlimited() { return !this.app.totalByte; },
+      isActive() {
+        const now = Date.now();
+        const expiryOk = !this.app.expireMs || this.app.expireMs >= now;
+        const trafficOk = !this.app.totalByte || (this.app.uploadByte + this.app.downloadByte) <= this.app.totalByte;
+        return expiryOk && trafficOk;
+      },
+    },
+    methods: { renderLink, copy, open, linkName, i18nLabel(key) { return '{{ i18n "' + key + '" }}'; } },
+  });
+})();

+ 272 - 0
web/html/subscription.html

@@ -0,0 +1,272 @@
+{{ template "page/head_start" .}}
+{{ template "page/head_end" .}}
+
+{{ template "page/body_start" .}}
+<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme">
+    <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="12"
+                                        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 :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-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="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">
+                                        <template
+                                            v-if="app.datepicker === 'gregorian'">
+                                            [[
+                                            DateUtil.formatMillis(app.lastOnlineMs)
+                                            ]]
+                                        </template>
+                                        <template v-else>
+                                            [[
+                                            DateUtil.convertToJalalian(moment(app.lastOnlineMs))
+                                            ]]
+                                        </template>
+                                    </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>
+                                        <template
+                                            v-if="app.datepicker === 'gregorian'">
+                                            [[
+                                            DateUtil.formatMillis(app.expireMs)
+                                            ]]
+                                        </template>
+                                        <template v-else>
+                                            [[
+                                            DateUtil.convertToJalalian(moment(app.expireMs))
+                                            ]]
+                                        </template>
+                                    </template>
+                                </a-descriptions-item>
+                            </a-descriptions>
+                        </a-form-item>
+                    </a-form>
+
+                    <br />
+                    <a-list bordered>
+                        <a-list-item v-for="(link, idx) in links" :key="link">
+                            <div style="width:100%; text-align:center;">
+                                <a-button type="primary" :block="isMobile"
+                                    @click="copy(link)">[[ linkName(link, idx)
+                                    ]]</a-button>
+                            </div>
+                        </a-list-item>
+                    </a-list>
+                    <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 :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>
+                                    </a-dropdown>
+                                </a-col>
+                                <a-col :xs="24" :sm="12"
+                                    style="text-align:center;">
+                                    <!-- iOS dropdown -->
+                                    <a-dropdown :trigger="['click']">
+                                        <a-button :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('shadowrocket://add/subscription?url=' + encodeURIComponent(app.subUrl) + '&remark=' + encodeURIComponent(app.sId))">Shadowrocket</a-menu-item>
+                                            <a-menu-item key="ios-v2box"
+                                                @click="open('v2box://install-sub?url=' + encodeURIComponent(app.subUrl) + '&name=' + encodeURIComponent(app.sId))">V2Box</a-menu-item>
+                                            <a-menu-item key="ios-streisand"
+                                                @click="open('streisand://import/' + encodeURIComponent(app.subUrl))">Streisand</a-menu-item>
+                                            <a-menu-item key="ios-v2raytun"
+                                                @click="copy(app.subUrl)">V2RayTun</a-menu-item>
+                                            <a-menu-item key="ios-npvtunnel"
+                                                @click="copy(app.subUrl)">NPV
+                                                Tunnel</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-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 }}"></template>
+<textarea id="subscription-links"
+    style="display:none">{{ range .result }}{{ . }}
+{{ end }}</textarea>
+
+{{template "page/body_scripts" .}}
+<script
+    src="{{ .base_path }}assets/moment/moment-jalali.min.js?{{ .cur_ver }}"></script>
+<script
+    src="{{ .base_path }}assets/qrcode/qrious2.min.js?{{ .cur_ver }}"></script>
+{{template "component/aThemeSwitch" .}}
+<script src="{{ .base_path }}assets/js/subscription.js?{{ .cur_ver }}"></script>
+{{ template "page/body_end" .}}

+ 19 - 3
web/locale/locale.go

@@ -48,6 +48,22 @@ func InitLocalizer(i18nFS embed.FS, settingService SettingService) error {
 	return nil
 	return nil
 }
 }
 
 
+// InitLocalizerFS allows initializing i18n from any fs.FS (e.g., disk), rooted at a directory containing a "translation" folder
+func InitLocalizerFS(fsys fs.FS, settingService SettingService) error {
+	// set default bundle to english
+	i18nBundle = i18n.NewBundle(language.MustParse("en-US"))
+	i18nBundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
+
+	if err := parseTranslationFiles(fsys, i18nBundle); err != nil {
+		return err
+	}
+
+	if err := initTGBotLocalizer(settingService); err != nil {
+		return err
+	}
+	return nil
+}
+
 func createTemplateData(params []string, seperator ...string) map[string]any {
 func createTemplateData(params []string, seperator ...string) map[string]any {
 	var sep string = "=="
 	var sep string = "=="
 	if len(seperator) > 0 {
 	if len(seperator) > 0 {
@@ -118,8 +134,8 @@ func LocalizerMiddleware() gin.HandlerFunc {
 	}
 	}
 }
 }
 
 
-func parseTranslationFiles(i18nFS embed.FS, i18nBundle *i18n.Bundle) error {
-	err := fs.WalkDir(i18nFS, "translation",
+func parseTranslationFiles(fsys fs.FS, i18nBundle *i18n.Bundle) error {
+	err := fs.WalkDir(fsys, "translation",
 		func(path string, d fs.DirEntry, err error) error {
 		func(path string, d fs.DirEntry, err error) error {
 			if err != nil {
 			if err != nil {
 				return err
 				return err
@@ -129,7 +145,7 @@ func parseTranslationFiles(i18nFS embed.FS, i18nBundle *i18n.Bundle) error {
 				return nil
 				return nil
 			}
 			}
 
 
-			data, err := i18nFS.ReadFile(path)
+			data, err := fs.ReadFile(fsys, path)
 			if err != nil {
 			if err != nil {
 				return err
 				return err
 			}
 			}

+ 14 - 0
web/translation/translate.ar_EG.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "مفيش بروكسي عكسي مضاف."
 "emptyReverseDesc" = "مفيش بروكسي عكسي مضاف."
 "somethingWentWrong" = "حدث خطأ ما"
 "somethingWentWrong" = "حدث خطأ ما"
 
 
+[subscription]
+"title" = "معلومات الاشتراك"
+"subId" = "معرّف الاشتراك"
+"status" = "الحالة"
+"downloaded" = "التنزيل"
+"uploaded" = "الرفع"
+"expiry" = "تاريخ الانتهاء"
+"totalQuota" = "الحصة الإجمالية"
+"individualLinks" = "روابط فردية"
+"active" = "نشط"
+"inactive" = "غير نشط"
+"unlimited" = "غير محدود"
+"noExpiry" = "بدون انتهاء"
+
 [menu]
 [menu]
 "theme" = "الثيم"
 "theme" = "الثيم"
 "dark" = "داكن"
 "dark" = "داكن"

+ 14 - 0
web/translation/translate.en_US.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "No added reverse proxies."
 "emptyReverseDesc" = "No added reverse proxies."
 "somethingWentWrong" = "Something went wrong"
 "somethingWentWrong" = "Something went wrong"
 
 
+[subscription]
+"title" = "Subscription info"
+"subId" = "Subscription ID"
+"status" = "Status"
+"downloaded" = "Downloaded"
+"uploaded" = "Uploaded"
+"expiry" = "Expiry"
+"totalQuota" = "Total quota"
+"individualLinks" = "Individual links"
+"active" = "Active"
+"inactive" = "Inactive"
+"unlimited" = "Unlimited"
+"noExpiry" = "No expiry"
+
 [menu]
 [menu]
 "theme" = "Theme"
 "theme" = "Theme"
 "dark" = "Dark"
 "dark" = "Dark"

+ 14 - 0
web/translation/translate.es_ES.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "No hay proxies inversos añadidos."
 "emptyReverseDesc" = "No hay proxies inversos añadidos."
 "somethingWentWrong" = "Algo salió mal"
 "somethingWentWrong" = "Algo salió mal"
 
 
+[subscription]
+"title" = "Información de suscripción"
+"subId" = "ID de suscripción"
+"status" = "Estado"
+"downloaded" = "Descargado"
+"uploaded" = "Subido"
+"expiry" = "Caducidad"
+"totalQuota" = "Cuota total"
+"individualLinks" = "Enlaces individuales"
+"active" = "Activo"
+"inactive" = "Inactivo"
+"unlimited" = "Ilimitado"
+"noExpiry" = "Sin caducidad"
+
 [menu]
 [menu]
 "theme" = "Tema"
 "theme" = "Tema"
 "dark" = "Oscuro"
 "dark" = "Oscuro"

+ 14 - 0
web/translation/translate.fa_IR.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "هیچ پروکسی معکوس اضافه نشده است."
 "emptyReverseDesc" = "هیچ پروکسی معکوس اضافه نشده است."
 "somethingWentWrong" = "مشکلی پیش آمد"
 "somethingWentWrong" = "مشکلی پیش آمد"
 
 
+[subscription]
+"title" = "اطلاعات سابسکریپشن"
+"subId" = "شناسه اشتراک"
+"status" = "وضعیت"
+"downloaded" = "دانلود"
+"uploaded" = "آپلود"
+"expiry" = "تاریخ پایان"
+"totalQuota" = "حجم کلی"
+"individualLinks" = "لینک‌های تکی"
+"active" = "فعال"
+"inactive" = "غیرفعال"
+"unlimited" = "نامحدود"
+"noExpiry" = "بدون انقضا"
+
 [menu]
 [menu]
 "theme" = "تم"
 "theme" = "تم"
 "dark" = "تیره"
 "dark" = "تیره"

+ 14 - 0
web/translation/translate.id_ID.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "Tidak ada proxy terbalik yang ditambahkan."
 "emptyReverseDesc" = "Tidak ada proxy terbalik yang ditambahkan."
 "somethingWentWrong" = "Terjadi kesalahan"
 "somethingWentWrong" = "Terjadi kesalahan"
 
 
+[subscription]
+"title" = "Info langganan"
+"subId" = "ID langganan"
+"status" = "Status"
+"downloaded" = "Diunduh"
+"uploaded" = "Diunggah"
+"expiry" = "Kedaluwarsa"
+"totalQuota" = "Kuota total"
+"individualLinks" = "Tautan individual"
+"active" = "Aktif"
+"inactive" = "Nonaktif"
+"unlimited" = "Tanpa batas"
+"noExpiry" = "Tanpa kedaluwarsa"
+
 [menu]
 [menu]
 "theme" = "Tema"
 "theme" = "Tema"
 "dark" = "Gelap"
 "dark" = "Gelap"

+ 14 - 0
web/translation/translate.ja_JP.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "追加されたリバースプロキシはありません。"
 "emptyReverseDesc" = "追加されたリバースプロキシはありません。"
 "somethingWentWrong" = "エラーが発生しました"
 "somethingWentWrong" = "エラーが発生しました"
 
 
+[subscription]
+"title" = "サブスクリプション情報"
+"subId" = "サブスクリプションID"
+"status" = "ステータス"
+"downloaded" = "ダウンロード"
+"uploaded" = "アップロード"
+"expiry" = "有効期限"
+"totalQuota" = "合計クォータ"
+"individualLinks" = "個別リンク"
+"active" = "有効"
+"inactive" = "無効"
+"unlimited" = "無制限"
+"noExpiry" = "期限なし"
+
 [menu]
 [menu]
 "theme" = "テーマ"
 "theme" = "テーマ"
 "dark" = "ダーク"
 "dark" = "ダーク"

+ 14 - 0
web/translation/translate.pt_BR.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "Nenhum proxy reverso adicionado."
 "emptyReverseDesc" = "Nenhum proxy reverso adicionado."
 "somethingWentWrong" = "Algo deu errado"
 "somethingWentWrong" = "Algo deu errado"
 
 
+[subscription]
+"title" = "Informações da assinatura"
+"subId" = "ID da assinatura"
+"status" = "Status"
+"downloaded" = "Baixado"
+"uploaded" = "Enviado"
+"expiry" = "Validade"
+"totalQuota" = "Cota total"
+"individualLinks" = "Links individuais"
+"active" = "Ativo"
+"inactive" = "Inativo"
+"unlimited" = "Ilimitado"
+"noExpiry" = "Sem validade"
+
 [menu]
 [menu]
 "theme" = "Tema"
 "theme" = "Tema"
 "dark" = "Escuro"
 "dark" = "Escuro"

+ 14 - 0
web/translation/translate.ru_RU.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "Нет добавленных реверс-прокси."
 "emptyReverseDesc" = "Нет добавленных реверс-прокси."
 "somethingWentWrong" = "Что-то пошло не так"
 "somethingWentWrong" = "Что-то пошло не так"
 
 
+[subscription]
+"title" = "Информация о подписке"
+"subId" = "ID подписки"
+"status" = "Статус"
+"downloaded" = "Загружено"
+"uploaded" = "Отправлено"
+"expiry" = "Срок действия"
+"totalQuota" = "Общий лимит"
+"individualLinks" = "Индивидуальные ссылки"
+"active" = "Активна"
+"inactive" = "Неактивна"
+"unlimited" = "Безлимит"
+"noExpiry" = "Без срока"
+
 [menu]
 [menu]
 "theme" = "Тема"
 "theme" = "Тема"
 "dark" = "Темная"
 "dark" = "Темная"

+ 14 - 0
web/translation/translate.tr_TR.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "Eklenmiş ters proxy yok."
 "emptyReverseDesc" = "Eklenmiş ters proxy yok."
 "somethingWentWrong" = "Bir şeyler yanlış gitti"
 "somethingWentWrong" = "Bir şeyler yanlış gitti"
 
 
+[subscription]
+"title" = "Abonelik Bilgisi"
+"subId" = "Abonelik Kimliği"
+"status" = "Durum"
+"downloaded" = "İndirilen"
+"uploaded" = "Yüklenen"
+"expiry" = "Son Kullanma"
+"totalQuota" = "Toplam Kota"
+"individualLinks" = "Bireysel Bağlantılar"
+"active" = "Aktif"
+"inactive" = "Pasif"
+"unlimited" = "Sınırsız"
+"noExpiry" = "Süresiz"
+
 [menu]
 [menu]
 "theme" = "Tema"
 "theme" = "Tema"
 "dark" = "Koyu"
 "dark" = "Koyu"

+ 14 - 0
web/translation/translate.uk_UA.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "Немає доданих зворотних проксі."
 "emptyReverseDesc" = "Немає доданих зворотних проксі."
 "somethingWentWrong" = "Щось пішло не так"
 "somethingWentWrong" = "Щось пішло не так"
 
 
+[subscription]
+"title" = "Інформація про підписку"
+"subId" = "ID підписки"
+"status" = "Статус"
+"downloaded" = "Завантажено"
+"uploaded" = "Відвантажено"
+"expiry" = "Термін дії"
+"totalQuota" = "Загальна квота"
+"individualLinks" = "Окремі посилання"
+"active" = "Активна"
+"inactive" = "Неактивна"
+"unlimited" = "Безліміт"
+"noExpiry" = "Без строку"
+
 [menu]
 [menu]
 "theme" = "Тема"
 "theme" = "Тема"
 "dark" = "Темна"
 "dark" = "Темна"

+ 14 - 0
web/translation/translate.vi_VN.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "Không có proxy ngược nào được thêm."
 "emptyReverseDesc" = "Không có proxy ngược nào được thêm."
 "somethingWentWrong" = "Đã xảy ra lỗi"
 "somethingWentWrong" = "Đã xảy ra lỗi"
 
 
+[subscription]
+"title" = "Thông tin đăng ký"
+"subId" = "ID đăng ký"
+"status" = "Trạng thái"
+"downloaded" = "Đã tải xuống"
+"uploaded" = "Đã tải lên"
+"expiry" = "Hết hạn"
+"totalQuota" = "Tổng hạn mức"
+"individualLinks" = "Liên kết riêng lẻ"
+"active" = "Hoạt động"
+"inactive" = "Không hoạt động"
+"unlimited" = "Không giới hạn"
+"noExpiry" = "Không hết hạn"
+
 [menu]
 [menu]
 "theme" = "Chủ đề"
 "theme" = "Chủ đề"
 "dark" = "Tối"
 "dark" = "Tối"

+ 14 - 0
web/translation/translate.zh_CN.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "未添加反向代理。"
 "emptyReverseDesc" = "未添加反向代理。"
 "somethingWentWrong" = "出了点问题"
 "somethingWentWrong" = "出了点问题"
 
 
+[subscription]
+"title" = "订阅信息"
+"subId" = "订阅 ID"
+"status" = "状态"
+"downloaded" = "已下载"
+"uploaded" = "已上传"
+"expiry" = "到期"
+"totalQuota" = "总配额"
+"individualLinks" = "单独链接"
+"active" = "启用"
+"inactive" = "停用"
+"unlimited" = "无限制"
+"noExpiry" = "无到期"
+
 [menu]
 [menu]
 "theme" = "主题"
 "theme" = "主题"
 "dark" = "暗色"
 "dark" = "暗色"

+ 14 - 0
web/translation/translate.zh_TW.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "未添加反向代理。"
 "emptyReverseDesc" = "未添加反向代理。"
 "somethingWentWrong" = "發生錯誤"
 "somethingWentWrong" = "發生錯誤"
 
 
+[subscription]
+"title" = "訂閱資訊"
+"subId" = "訂閱 ID"
+"status" = "狀態"
+"downloaded" = "已下載"
+"uploaded" = "已上傳"
+"expiry" = "到期"
+"totalQuota" = "總配額"
+"individualLinks" = "個別連結"
+"active" = "啟用"
+"inactive" = "停用"
+"unlimited" = "無限制"
+"noExpiry" = "無到期"
+
 [menu]
 [menu]
 "theme" = "主題"
 "theme" = "主題"
 "dark" = "深色"
 "dark" = "深色"

Some files were not shown because too many files changed in this diff