mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-15 15:50:59 +00:00
feat(sub): add template variables to subscription metadata (#6163)
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { useRef } from 'react';
|
||||
import { Button, Input, Popover, Tooltip } from 'antd';
|
||||
import type { InputRef } from 'antd';
|
||||
import type { TextAreaRef } from 'antd/es/input/TextArea';
|
||||
import { CodeOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { hasRemarkTokens, previewRemark, wrapToken } from '@/lib/remark/remarkVariables';
|
||||
import { hasRemarkTokens, previewRemark, SUBSCRIPTION_METADATA_VARIABLES, wrapToken } from '@/lib/remark/remarkVariables';
|
||||
import RemarkVarPicker from './RemarkVarPicker';
|
||||
|
||||
interface RemarkTemplateFieldProps {
|
||||
@@ -13,19 +14,31 @@ interface RemarkTemplateFieldProps {
|
||||
onChange?: (value: string) => void;
|
||||
maxLength?: number;
|
||||
placeholder?: string;
|
||||
multiline?: boolean;
|
||||
rows?: number;
|
||||
metadataOnly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* RemarkTemplateField is a text input augmented with a {{VAR}} template picker
|
||||
* (insert-at-caret) and a live, sample-based preview of the expanded result.
|
||||
* Used for the global subscription Remark Template.
|
||||
* Used for subscription text fields that support Remark Template variables.
|
||||
*/
|
||||
export default function RemarkTemplateField({ value = '', onChange, maxLength, placeholder }: RemarkTemplateFieldProps) {
|
||||
export default function RemarkTemplateField({ value = '', onChange, maxLength, placeholder, multiline = false, rows, metadataOnly = false }: RemarkTemplateFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
const textAreaRef = useRef<TextAreaRef>(null);
|
||||
const variables = metadataOnly ? SUBSCRIPTION_METADATA_VARIABLES : undefined;
|
||||
|
||||
function getTextElement() {
|
||||
if (multiline) {
|
||||
return textAreaRef.current?.resizableTextArea?.textArea ?? null;
|
||||
}
|
||||
return inputRef.current?.input ?? null;
|
||||
}
|
||||
|
||||
function insertToken(token: string) {
|
||||
const el = inputRef.current?.input;
|
||||
const el = getTextElement();
|
||||
const start = el?.selectionStart ?? value.length;
|
||||
const end = el?.selectionEnd ?? value.length;
|
||||
const insert = wrapToken(token);
|
||||
@@ -39,31 +52,47 @@ export default function RemarkTemplateField({ value = '', onChange, maxLength, p
|
||||
});
|
||||
}
|
||||
|
||||
const pickerButton = (
|
||||
<Popover
|
||||
content={<RemarkVarPicker onPick={insertToken} variables={variables} />}
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
title={t('pages.hosts.remarkVars.title')}
|
||||
>
|
||||
<Tooltip title={t('pages.hosts.remarkVars.title')}>
|
||||
<Button type="text" size="small" icon={<CodeOutlined />} aria-label={t('pages.hosts.remarkVars.title')} style={{ marginInlineEnd: -7 }} />
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
maxLength={maxLength}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
suffix={
|
||||
<Popover
|
||||
content={<RemarkVarPicker onPick={insertToken} />}
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
title={t('pages.hosts.remarkVars.title')}
|
||||
>
|
||||
<Tooltip title={t('pages.hosts.remarkVars.title')}>
|
||||
<Button type="text" size="small" icon={<CodeOutlined />} aria-label={t('pages.hosts.remarkVars.title')} style={{ marginInlineEnd: -7 }} />
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
}
|
||||
/>
|
||||
{multiline ? (
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
|
||||
<Input.TextArea
|
||||
ref={textAreaRef}
|
||||
value={value}
|
||||
maxLength={maxLength}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
/>
|
||||
{pickerButton}
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
maxLength={maxLength}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
suffix={pickerButton}
|
||||
/>
|
||||
)}
|
||||
{hasRemarkTokens(value) && (
|
||||
<div style={{ fontSize: 12, marginTop: 4, opacity: 0.7 }}>
|
||||
{t('pages.hosts.remarkVars.preview')}:{' '}
|
||||
<span style={{ fontFamily: 'monospace' }}>{previewRemark(value) || '—'}</span>
|
||||
<span style={{ fontFamily: 'monospace' }}>{previewRemark(value, variables, metadataOnly) || '—'}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,31 +2,33 @@ import { Tag, Tooltip, Typography } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { REMARK_VARIABLES, REMARK_VAR_GROUPS, wrapToken } from '@/lib/remark/remarkVariables';
|
||||
import type { RemarkVar } from '@/lib/remark/remarkVariables';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
|
||||
interface RemarkVarPickerProps {
|
||||
/** Called with the bare token (e.g. "EMAIL") when a chip is clicked. */
|
||||
onPick: (token: string) => void;
|
||||
variables?: RemarkVar[];
|
||||
}
|
||||
|
||||
/**
|
||||
* RemarkVarPicker is the grouped, tooltipped chip list of {{VAR}} tokens used by
|
||||
* the global remark-template field.
|
||||
*/
|
||||
export default function RemarkVarPicker({ onPick }: RemarkVarPickerProps) {
|
||||
export default function RemarkVarPicker({ onPick, variables = REMARK_VARIABLES }: RemarkVarPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div style={{ maxWidth: 460, maxHeight: 'min(70vh, 640px)', overflowY: 'auto' }}>
|
||||
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginBottom: 8 }}>
|
||||
{t('pages.hosts.remarkVars.intro')}
|
||||
</Typography.Paragraph>
|
||||
{REMARK_VAR_GROUPS.map((group) => (
|
||||
{REMARK_VAR_GROUPS.filter((group) => variables.some((v) => v.group === group)).map((group) => (
|
||||
<div key={group} style={{ marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', opacity: 0.6, marginBottom: 4 }}>
|
||||
{t(`pages.hosts.remarkVars.groups.${group}`)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{REMARK_VARIABLES.filter((v) => v.group === group).map((v) => (
|
||||
{variables.filter((v) => v.group === group).map((v) => (
|
||||
<Tooltip key={v.token} title={t(`pages.hosts.remarkVars.desc${v.token}`)}>
|
||||
<Tag
|
||||
role="button"
|
||||
|
||||
@@ -51,6 +51,14 @@ export const REMARK_VARIABLES: RemarkVar[] = [
|
||||
{ token: 'SECURITY', group: 'connection', sample: 'TLS' },
|
||||
];
|
||||
|
||||
export const SUBSCRIPTION_METADATA_VARIABLES: RemarkVar[] = REMARK_VARIABLES.filter((v) => (
|
||||
v.token === 'EMAIL'
|
||||
|| v.token === 'ID'
|
||||
|| v.token === 'SHORT_ID'
|
||||
|| v.token === 'TELEGRAM_ID'
|
||||
|| v.token === 'SUB_ID'
|
||||
));
|
||||
|
||||
const SAMPLE_BY_TOKEN: Record<string, string> = Object.fromEntries(
|
||||
REMARK_VARIABLES.map((v) => [v.token, v.sample]),
|
||||
);
|
||||
@@ -70,9 +78,14 @@ export function hasRemarkTokens(template: string): boolean {
|
||||
/**
|
||||
* previewRemark renders a template against the sample values, mirroring the
|
||||
* backend substitution closely enough for an at-a-glance preview. Unknown
|
||||
* tokens collapse to empty, just like the server.
|
||||
* tokens collapse to empty by default; metadata fields can keep unsupported
|
||||
* tokens literal because the backend does the same for backwards compatibility.
|
||||
*/
|
||||
export function previewRemark(template: string): string {
|
||||
export function previewRemark(template: string, variables: RemarkVar[] = REMARK_VARIABLES, keepUnknown = false): string {
|
||||
if (!hasRemarkTokens(template)) return template;
|
||||
return template.replace(TOKEN_RE, (_m, tok: string) => SAMPLE_BY_TOKEN[tok] ?? '');
|
||||
const allowed = new Set(variables.map((v) => v.token));
|
||||
return template.replace(TOKEN_RE, (match, tok: string) => {
|
||||
if (!allowed.has(tok)) return keepUnknown ? match : '';
|
||||
return SAMPLE_BY_TOKEN[tok] ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -118,19 +118,36 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
|
||||
children: (
|
||||
<>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subTitle')} description={t('pages.settings.subTitleDesc')}>
|
||||
<Input value={allSetting.subTitle} onChange={(e) => updateSetting({ subTitle: e.target.value })} />
|
||||
<RemarkTemplateField
|
||||
value={allSetting.subTitle}
|
||||
onChange={(v) => updateSetting({ subTitle: v })}
|
||||
metadataOnly
|
||||
/>
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subSupportUrl')} description={t('pages.settings.subSupportUrlDesc')}>
|
||||
<Input value={allSetting.subSupportUrl} placeholder="https://example.com"
|
||||
onChange={(e) => updateSetting({ subSupportUrl: e.target.value })} />
|
||||
<RemarkTemplateField
|
||||
value={allSetting.subSupportUrl}
|
||||
placeholder="https://example.com"
|
||||
onChange={(v) => updateSetting({ subSupportUrl: v })}
|
||||
metadataOnly
|
||||
/>
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subProfileUrl')} description={t('pages.settings.subProfileUrlDesc')}>
|
||||
<Input value={allSetting.subProfileUrl} placeholder="https://example.com"
|
||||
onChange={(e) => updateSetting({ subProfileUrl: e.target.value })} />
|
||||
<RemarkTemplateField
|
||||
value={allSetting.subProfileUrl}
|
||||
placeholder="https://example.com"
|
||||
onChange={(v) => updateSetting({ subProfileUrl: v })}
|
||||
metadataOnly
|
||||
/>
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subAnnounce')} description={t('pages.settings.subAnnounceDesc')}>
|
||||
<Input.TextArea value={allSetting.subAnnounce}
|
||||
onChange={(e) => updateSetting({ subAnnounce: e.target.value })} />
|
||||
<RemarkTemplateField
|
||||
value={allSetting.subAnnounce}
|
||||
onChange={(v) => updateSetting({ subAnnounce: v })}
|
||||
multiline
|
||||
rows={3}
|
||||
metadataOnly
|
||||
/>
|
||||
</SettingListItem>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import RemarkTemplateField from '@/components/form/RemarkTemplateField';
|
||||
import { previewRemark, SUBSCRIPTION_METADATA_VARIABLES } from '@/lib/remark/remarkVariables';
|
||||
|
||||
describe('RemarkTemplateField', () => {
|
||||
it('inserts a {{TOKEN}} when a variable chip is clicked', async () => {
|
||||
@@ -23,4 +24,32 @@ describe('RemarkTemplateField', () => {
|
||||
// Sample expansion of {{EMAIL}} is "john".
|
||||
expect(screen.getByText('john')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('supports token insertion in multiline fields', async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<RemarkTemplateField value="Hello " onChange={onChange} multiline rows={3} />);
|
||||
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement;
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
||||
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
fireEvent.click(await screen.findByText('{{SUB_ID}}'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange.mock.calls[0][0]).toBe('Hello {{SUB_ID}}');
|
||||
});
|
||||
|
||||
it('limits the picker to client identity tokens for metadata fields', async () => {
|
||||
render(<RemarkTemplateField value="" onChange={() => {}} metadataOnly />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
|
||||
expect(await screen.findByText('{{EMAIL}}')).toBeTruthy();
|
||||
expect(screen.queryByText('{{INBOUND}}')).toBeNull();
|
||||
expect(screen.queryByText('{{TRAFFIC_LEFT}}')).toBeNull();
|
||||
});
|
||||
|
||||
it('previews metadata fields with metadata-safe tokens only', () => {
|
||||
expect(previewRemark('{{EMAIL}}/{{TRAFFIC_LEFT}}', SUBSCRIPTION_METADATA_VARIABLES, true)).toBe('john/{{TRAFFIC_LEFT}}');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user