mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-28 00:24:19 +00:00
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.
This commit is contained in:
@@ -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 = '';
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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-Relay-john@example.com" {
|
||||
t.Fatalf("genHostRemark = %q, want %q", got, "DE-Relay-john@example.com")
|
||||
}
|
||||
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-john@example.com" {
|
||||
t.Fatalf("genHostRemark (no host remark) = %q, want %q", got, "DE-john@example.com")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 != "DE-CDN-john@example.com" {
|
||||
t.Fatalf("display host link = %q, want %q", got, "DE-CDN-john@example.com")
|
||||
const want = "DE-john@example.com"
|
||||
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 != "DE-john@example.com" {
|
||||
t.Fatalf("display host link (no host) = %q, want %q", got, "DE-john@example.com")
|
||||
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 != "DE-john@example.com" {
|
||||
t.Fatalf("display genRemark = %q, want %q", got, "DE-john@example.com")
|
||||
if got := s.genRemark(inbound, client.Email, "", ""); got != want {
|
||||
t.Fatalf("display genRemark = %q, want %q", got, want)
|
||||
}
|
||||
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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
Reference in New Issue
Block a user