Kaynağa Gözat

fix(sub): drive display remarks from the template and split multi-host subpage links

Unify remark generation around the Remark Template. Display contexts (Clients-page QR/Info modals and the HTML sub info page) now render the template name-only client/identity part instead of a hardcoded fallback; the subscription body keeps the full template on a client first link and name-only thereafter. The default template gains the email token so the client email shows by default again (#5532).

BuildPageData now splits each multi-link entry (one link per host of an inbound) into a separate row, so the sub page no longer collapses several host links onto a single mangled line. QR captions on the Clients QR modal and the sub page reuse the link fragment remark.
MHSanaei 6 saat önce
ebeveyn
işleme
b0c1156dd6

+ 1 - 1
frontend/src/models/setting.ts

@@ -13,7 +13,7 @@ export class AllSetting {
   pageSize = 25;
   expireDiff = 0;
   trafficDiff = 0;
-  remarkTemplate = '{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D';
+  remarkTemplate = '{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D';
   datepicker: 'gregorian' | 'jalalian' = 'gregorian';
   tgBotEnable = false;
   tgBotToken = '';

+ 1 - 1
frontend/src/pages/clients/ClientQrModal.tsx

@@ -106,7 +106,7 @@ export default function ClientQrModal({
         children: (
           <QrPanel
             value={link}
-            remark={`${client?.email || ''} #${idx + 1}`}
+            remark={parts?.remark || `${client?.email || ''} #${idx + 1}`}
             showQr={!isPostQuantumLink(link)}
           />
         ),

+ 1 - 1
frontend/src/pages/sub/SubPage.tsx

@@ -421,7 +421,7 @@ export default function SubPage() {
                         const parts = parseLinkParts(link);
                         const fallback = `Link ${idx + 1}`;
                         const rowTitle = parts?.remark || fallback;
-                        const qrLabel = [parts?.remark, linkEmails[idx]].filter(Boolean).join('-') || rowTitle;
+                        const qrLabel = parts?.remark || rowTitle;
                         const canQr = !isPostQuantumLink(link);
                         return (
                           <div key={link} className="sub-link-row">

+ 37 - 0
internal/sub/page_data_test.go

@@ -0,0 +1,37 @@
+package sub
+
+import (
+	"reflect"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// A single getSubs entry can hold several links (one per host of an inbound)
+// joined by newlines. BuildPageData must split them into one entry per link, with
+// the email replicated, so the subpage renders one row per host instead of
+// collapsing them onto a single mangled line.
+func TestBuildPageData_SplitsMultiHostLinks(t *testing.T) {
+	s := &SubService{}
+	subs := []string{
+		"vless://a@h1:443?type=tcp#DE-john@x\nvless://a@h2:443?type=tcp#DE-john@x\nvless://a@h3:443?type=tcp#DE-john@x",
+		"vless://b@h:443?type=tcp#FR-alice@x",
+	}
+	emails := []string{"john@x", "alice@x"}
+
+	page := s.BuildPageData("s1", "", xray.ClientTraffic{}, 0, subs, emails, "", "", "", "/", "", "")
+
+	if len(page.Result) != 4 {
+		t.Fatalf("Result len = %d, want 4 (3 host links + 1 single link)", len(page.Result))
+	}
+	for i, link := range page.Result {
+		if strings.Contains(link, "\n") {
+			t.Fatalf("Result[%d] still multi-line: %q", i, link)
+		}
+	}
+	wantEmails := []string{"john@x", "john@x", "john@x", "alice@x"}
+	if !reflect.DeepEqual(page.Emails, wantEmails) {
+		t.Fatalf("Emails = %v, want %v", page.Emails, wantEmails)
+	}
+}

+ 14 - 11
internal/sub/remark_vars.go

@@ -491,7 +491,7 @@ func nameOnlyTemplate(template string) string {
 // effectiveTemplate picks which template to expand for one body link: the full
 // template (with the per-client info) for a client's first link, and the
 // name-only template for every link thereafter — so the info shows once. Only
-// called in the subscription-body context (displays bypass the template).
+// called in the subscription-body context (displays render name-only directly).
 func (s *SubService) effectiveTemplate(email string) string {
 	translated := translateUISingleBrackets(s.remarkTemplate)
 	if s.usageShown == nil {
@@ -515,22 +515,25 @@ func (s *SubService) genTemplatedRemark(inbound *model.Inbound, client model.Cli
 		hostRemark: hostRemark,
 		transport:  transport,
 	}
-	tmpl := s.effectiveTemplate(client.Email)
-	// Fall back to the config name when the template is empty or expands to
-	// nothing (e.g. an all-unlimited template whose only segments dropped out).
+	var tmpl string
+	if s.subscriptionBody {
+		tmpl = s.effectiveTemplate(client.Email)
+	} else {
+		tmpl = nameOnlyTemplate(translateUISingleBrackets(s.remarkTemplate))
+	}
 	if out := expandRemarkVars(tmpl, ctx); strings.TrimSpace(out) != "" {
 		return out
 	}
 	return ctx.configName()
 }
 
-// genHostRemark builds one host endpoint's remark for a specific client. In the
-// subscription body the {{HOST}} token carries the host's remark and the rest of
-// the template still applies; displays show the config name, host and email.
+// genHostRemark builds one host endpoint's remark for a specific client. With a
+// remark template set it is template-driven (body shows the full template on the
+// first link and the name-only part thereafter; displays render the name-only
+// part). With no template it falls back to inbound, host and email joined by "-".
 func (s *SubService) genHostRemark(inbound *model.Inbound, client model.Client, hostRemark string, transport string) string {
-	if !s.subscriptionBody {
-		name := remarkContext{inbound: inbound, hostRemark: hostRemark}.configName()
-		return fallbackRemark(name, hostRemark, client.Email)
+	if s.remarkTemplate != "" {
+		return s.genTemplatedRemark(inbound, client, hostRemark, transport)
 	}
-	return s.genTemplatedRemark(inbound, client, hostRemark, transport)
+	return fallbackRemark(inbound.Remark, hostRemark, client.Email)
 }

+ 22 - 16
internal/sub/remark_vars_test.go

@@ -164,15 +164,15 @@ func hostRemarkService(template string) (*SubService, *model.Inbound, model.Clie
 	return s, inbound, client
 }
 
-// The config name is always the inbound's own remark; the host endpoint's remark
-// never substitutes it (it is reachable only through {{HOST}}).
-func TestGenHostRemark_ConfigNameUsesInbound(t *testing.T) {
-	s, inbound, client := hostRemarkService("") // no template → config name only
-	if got := s.genHostRemark(inbound, client, "Relay", ""); got != "DE" {
-		t.Fatalf("genHostRemark = %q, want %q (inbound remark, host ignored)", got, "DE")
+// With no template configured, genHostRemark falls back to the inbound remark,
+// host and email joined by "-".
+func TestGenHostRemark_NoTemplate_Fallback(t *testing.T) {
+	s, inbound, client := hostRemarkService("")
+	if got := s.genHostRemark(inbound, client, "Relay", ""); got != "DE[email protected]" {
+		t.Fatalf("genHostRemark = %q, want %q", got, "DE[email protected]")
 	}
-	if got := s.genHostRemark(inbound, client, "", ""); got != "DE" {
-		t.Fatalf("genHostRemark (no host remark) = %q, want %q", got, "DE")
+	if got := s.genHostRemark(inbound, client, "", ""); got != "DE[email protected]" {
+		t.Fatalf("genHostRemark (no host remark) = %q, want %q", got, "DE[email protected]")
 	}
 }
 
@@ -232,23 +232,29 @@ func TestUsageOnFirstLinkOnly(t *testing.T) {
 }
 
 func TestRemarkInDisplayContext(t *testing.T) {
-	s, inbound, client := hostRemarkService("{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
+	s, inbound, client := hostRemarkService("{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
 	s.subscriptionBody = false
-	if got := s.genHostRemark(inbound, client, "CDN", ""); got != "[email protected]" {
-		t.Fatalf("display host link = %q, want %q", got, "[email protected]")
+	const want = "[email protected]"
+	if got := s.genHostRemark(inbound, client, "CDN", ""); got != want {
+		t.Fatalf("display host link = %q, want %q", got, want)
 	}
-	if got := s.genHostRemark(inbound, client, "", ""); got != "[email protected]" {
-		t.Fatalf("display host link (no host) = %q, want %q", got, "[email protected]")
+	if got := s.genHostRemark(inbound, client, "", ""); got != want {
+		t.Fatalf("display host link (no host) = %q, want %q", got, want)
+	}
+	if got := s.genRemark(inbound, client.Email, "", ""); got != want {
+		t.Fatalf("display genRemark = %q, want %q", got, want)
 	}
-	if got := s.genRemark(inbound, client.Email, "", ""); got != "[email protected]" {
-		t.Fatalf("display genRemark = %q, want %q", got, "[email protected]")
+	s2, inbound2, client2 := hostRemarkService("{{INBOUND}}-{{HOST}}|📊{{TRAFFIC_LEFT}}")
+	s2.subscriptionBody = false
+	if got := s2.genHostRemark(inbound2, client2, "CDN", ""); got != "DE-CDN" {
+		t.Fatalf("display host link with HOST token = %q, want %q", got, "DE-CDN")
 	}
 }
 
 // nameOnlyTemplate drops the info part (and its leading decoration), keeping name.
 func TestNameOnlyTemplate(t *testing.T) {
 	cases := map[string]string{
-		"{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D": "{{INBOUND}}",           // the default → name only
+		"{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D": "{{INBOUND}}",           // usage tail stripped
 		"{{EMAIL}} {{INBOUND}} ⏳{{DAYS_LEFT}}":          "{{EMAIL}} {{INBOUND}}", // multi-token name survives the trim
 		"{{INBOUND}} | {{STATUS}}":                      "{{INBOUND}}",
 		"{{INBOUND}}-{{EMAIL}}":                         "{{INBOUND}}-{{EMAIL}}", // no info tokens → unchanged

+ 20 - 6
internal/sub/service.go

@@ -1634,11 +1634,12 @@ func cloneStringMap(source map[string]string) map[string]string {
 }
 
 // genRemark builds the remark for a non-host link (raw default / legacy
-// externalProxy / synthetic JSON-Clash entry). In the subscription body a set
-// remark template takes over; otherwise (and in every display context) the
-// remark is just the config name (inbound remark, then extra).
+// externalProxy / synthetic JSON-Clash entry). A set remark template drives it
+// in both the body and display contexts (genTemplatedRemark renders the
+// name-only part on displays); with no template it falls back to the inbound
+// remark, extra and email joined by "-".
 func (s *SubService) genRemark(inbound *model.Inbound, email string, extra string, transport string) string {
-	if s.remarkTemplate != "" && s.subscriptionBody {
+	if s.remarkTemplate != "" {
 		return s.genTemplatedRemark(inbound, s.lookupClient(inbound, email), extra, transport)
 	}
 	return fallbackRemark(inbound.Remark, extra, email)
@@ -2336,6 +2337,19 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray
 		datepicker = "gregorian"
 	}
 
+	pageLinks := make([]string, 0, len(subs))
+	pageEmails := make([]string, 0, len(subs))
+	for i, sub := range subs {
+		email := ""
+		if i < len(emails) {
+			email = emails[i]
+		}
+		for _, link := range splitLinkLines(sub) {
+			pageLinks = append(pageLinks, link)
+			pageEmails = append(pageEmails, email)
+		}
+	}
+
 	return PageData{
 		Host:          hostHeader,
 		BasePath:      basePath,
@@ -2357,8 +2371,8 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray
 		SubClashUrl:   subClashURL,
 		SubTitle:      subTitle,
 		SubSupportUrl: subSupportUrl,
-		Result:        subs,
-		Emails:        emails,
+		Result:        pageLinks,
+		Emails:        pageEmails,
 	}
 }
 

+ 1 - 1
internal/web/service/setting.go

@@ -55,7 +55,7 @@ var defaultValueMap = map[string]string{
 	"pageSize":                    "25",
 	"expireDiff":                  "0",
 	"trafficDiff":                 "0",
-	"remarkTemplate":              "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D",
+	"remarkTemplate":              "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D",
 	"timeLocation":                "Local",
 	"tgBotEnable":                 "false",
 	"tgBotToken":                  "",