diff --git a/links/templates/links/link_form.html b/links/templates/links/link_form.html
index 9f75741..a4d6999 100644
--- a/links/templates/links/link_form.html
+++ b/links/templates/links/link_form.html
@@ -71,6 +71,74 @@
padding: 0 .3rem; font-weight: 600;
}
.msg-preview-hint { font-size: .72rem; color: #9ca3af; margin-top: .45rem; }
+ /* ── Sketch-style template parameter builder ── */
+ .template-builder {
+ margin-top: .8rem;
+ border: 1px solid #e9e5f5; border-radius: 14px;
+ background: linear-gradient(180deg, #faf9ff 0%, #f7f5fc 100%);
+ padding: .9rem 1rem;
+ }
+ .tpl-label { font-size: .7rem; font-weight: 600; color: #7c6ba6; text-transform: uppercase; letter-spacing: .06em; }
+ .tpl-hint { font-size: .72rem; color: #9ca3af; margin-top: .2rem; line-height: 1.4; }
+ .tpl-chip {
+ display: inline-flex; align-items: center; gap: .35rem;
+ border: 1px solid #ddd6fe; background: #fff;
+ color: #6d28d9; border-radius: 980px;
+ padding: .38rem 1rem; font-size: .88rem; font-weight: 600;
+ cursor: pointer; transition: all .15s;
+ }
+ .tpl-chip:hover { background: #ede9fe; border-color: #a78bfa; box-shadow: 0 1px 4px rgba(109,40,217,.15); }
+ .tpl-chip:active { transform: scale(.96); }
+ .tpl-chip .tpl-chip-sub { font-weight: 400; color: #a78bfa; font-size: .78rem; }
+ .tpl-chip .tpl-chip-name { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
+ .tpl-chip.used { opacity: .45; pointer-events: none; }
+ .tpl-param-list { margin-top: .7rem; display: flex; flex-direction: column; gap: .4rem; }
+ .tpl-param-row { display: flex; align-items: center; gap: .55rem; }
+ .tpl-param-name {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ background: #ede9fe; color: #6d28d9; border-radius: 7px;
+ padding: .2rem .55rem; font-size: .82rem; font-weight: 600;
+ min-width: 4.5rem; text-align: center; flex-shrink: 0;
+ }
+ .tpl-param-default {
+ flex: 1; max-width: 320px;
+ border: 1px solid #d1d5db; border-radius: 8px;
+ padding: .35rem .6rem; font-size: .85rem; color: #374151;
+ background: #fff; outline: none;
+ }
+ .tpl-param-default:focus { border-color: #8b5cf6; box-shadow: 0 0 0 3px rgba(139,92,246,.12); }
+ .tpl-param-label { font-size: .72rem; color: #9ca3af; width: 3.2rem; flex-shrink: 0; }
+ .tpl-param-del {
+ background: none; border: none; cursor: pointer;
+ color: #c4b5fd; font-size: .8rem; padding: .3rem .45rem; border-radius: 6px;
+ }
+ .tpl-param-del:hover { color: #ef4444; background: #fef2f2; }
+ .tpl-add-row { display: flex; align-items: center; gap: .5rem; margin-top: .75rem; flex-wrap: wrap; }
+ .tpl-add-row input {
+ border: 1px solid #d1d5db; border-radius: 8px;
+ padding: .38rem .65rem; font-size: .85rem; outline: none; background: #fff;
+ }
+ .tpl-add-row input:focus { border-color: #8b5cf6; box-shadow: 0 0 0 3px rgba(139,92,246,.12); }
+ .tpl-add-name { width: 8rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
+ .tpl-add-def { width: 10rem; }
+ .tpl-add-btn {
+ border: none; background: #7c3aed; color: #fff;
+ border-radius: 8px; padding: .4rem .9rem; font-size: .82rem; font-weight: 600;
+ cursor: pointer; transition: background .15s;
+ }
+ .tpl-add-btn:hover { background: #6d28d9; }
+ .tpl-preview {
+ margin-top: .75rem; border-radius: 10px;
+ background: #23252b; padding: .6rem .85rem;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: .8rem; color: #d1d5db; word-break: break-all; line-height: 1.5;
+ }
+ .tpl-preview-label { font-size: .7rem; font-weight: 600; color: #9ca3af; text-transform: uppercase; letter-spacing: .06em; margin-bottom: .35rem; }
+ .tpl-preview-url { white-space: pre-wrap; }
+ .tpl-preview-val { color: #34d399; font-weight: 600; }
+ .tpl-preview-req { color: #f87171; font-weight: 600; }
+ .tpl-preview-note { font-size: .7rem; color: #9ca3af; margin-top: .3rem; }
+ .tpl-empty { font-size: .78rem; color: #9ca3af; padding: .3rem 0; }
{% endblock %}
@@ -156,6 +224,29 @@
value="{{ field.value|default_if_none:'' }}"
class="shadow appearance-none rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
{% if field.field.required %}required{% endif %}>
+
+
+
🔧 {% trans "Template parameters" %}
+
{% trans "Click a parameter to insert it into the URL. The text after go/alias/… fills the parameter when the link is opened." %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{% trans "Live preview" %}
+
+
+
{% endif %}
{% elif field.name == 'action_type' %}
@@ -308,6 +399,8 @@
actionDescription.classList.add('hidden');
linkTypeInput.value = 'LINK';
textField.classList.add('hidden');
+ const tplBuilder = document.getElementById('template-builder');
+ if (tplBuilder) tplBuilder.classList.remove('hidden');
}
function showCustomFields() {
@@ -338,6 +431,8 @@
actionDescription.classList.add('hidden');
linkTypeInput.value = 'CUSTOM';
textField.classList.remove('hidden');
+ const tplBuilder = document.getElementById('template-builder');
+ if (tplBuilder) tplBuilder.classList.add('hidden');
initializeSimpleMDE();
}
@@ -369,6 +464,8 @@
customDescription.classList.add('hidden');
linkTypeInput.value = 'ACTION';
textField.classList.add('hidden');
+ const tplBuilder = document.getElementById('template-builder');
+ if (tplBuilder) tplBuilder.classList.add('hidden');
}
function initializeSimpleMDE() {
@@ -460,6 +557,208 @@
if (msgInput) msgInput.addEventListener('input', renderPreview);
renderPreview();
+ /* ── Sketch-style template builder ── */
+ const urlInput = document.getElementById('id_original_url');
+ const builder = document.getElementById('template-builder');
+ const paramListEl = document.getElementById('tpl-param-list');
+ const previewEl = document.getElementById('tpl-preview-url');
+ const newNameInput = document.getElementById('tpl-new-name');
+ const newDefInput = document.getElementById('tpl-new-default');
+ const addParamBtn = document.getElementById('tpl-add-param');
+
+ function parseTemplateParams(url) {
+ const result = [];
+ const re = /\{([^{}]*)\}/g;
+ let m;
+ while ((m = re.exec(url)) !== null) {
+ const inner = m[1].trim();
+ const parts = inner.split(',');
+ const name = parts[0].trim();
+ let def = null;
+ if (parts.length > 1) {
+ const dp = parts.slice(1).join(',').trim();
+ if (dp.startsWith('default=')) {
+ def = dp.slice(8).trim().replace(/^["']|["']$/g, '');
+ } else {
+ def = dp;
+ }
+ }
+ result.push({ name: name, def: def, match: m[0] });
+ }
+ return result;
+ }
+ function buildPlaceholderToken(name, def) {
+ // Pure placeholder: {name} or {name,default=def}
+ return '{' + name + ((def !== null && def !== '') ? ',default=' + def : '') + '}';
+ }
+ function buildQueryToken(name, def) {
+ // Query-style full token: name={name,default=def} — matches the
+ // common ?q={q} usage; pure path params can be typed manually.
+ return name + '=' + buildPlaceholderToken(name, def);
+ }
+ function escapeHtmlTpl(s) {
+ return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+ }
+ function insertAtCursor(input, text) {
+ const start = input.selectionStart != null ? input.selectionStart : input.value.length;
+ const end = input.selectionEnd != null ? input.selectionEnd : input.value.length;
+ input.value = input.value.slice(0, start) + text + input.value.slice(end);
+ const pos = start + text.length;
+ input.setSelectionRange(pos, pos);
+ input.focus();
+ }
+ function appendToken(input, token) {
+ const v = input.value;
+ if (!v) { input.value = token; return; }
+ // Cursor in the middle of the URL: insert raw (user controls position)
+ if (document.activeElement === input && input.selectionStart != null &&
+ input.selectionStart === input.selectionEnd && input.selectionEnd < v.length) {
+ insertAtCursor(input, token);
+ return;
+ }
+ // Append at the end with a smart separator
+ const hasQuery = v.indexOf('?') > -1;
+ const sep = hasQuery ? '&' : '?';
+ const endsSep = /[?&]$/.test(v);
+ input.value = v + (endsSep ? '' : sep) + token;
+ const pos = input.value.length;
+ input.setSelectionRange(pos, pos);
+ input.focus();
+ }
+ function removeTokenFromUrl(url, token) {
+ // token is {name} or {name,default=...}; also drop the `name=` key
+ const m = token.match(/^\{(.+?)(?:,default=.*)?\}$/);
+ const name = m ? m[1] : null;
+ let u = url;
+ if (name) {
+ u = u.replace(new RegExp('(^|[?&])' + name + '=\\{' + name + '(?:,default=[^}]*)?\\}(?=&|$)', 'g'), '$1');
+ }
+ u = u.split(token).join('');
+ u = u.replace(/[?&]{2,}/g, '&').replace(/^&/, '').replace(/[?&]$/, '');
+ return u;
+ }
+
+ function renderTemplateBuilder() {
+ if (!urlInput || !paramListEl) return;
+ const url = urlInput.value || '';
+ const params = parseTemplateParams(url);
+
+ paramListEl.innerHTML = '';
+ if (!params.length) {
+ const empty = document.createElement('div');
+ empty.className = 'tpl-empty';
+ empty.textContent = '{% trans "No parameters yet — click one above or type {name,default=value} in the URL." %}';
+ paramListEl.appendChild(empty);
+ }
+ params.forEach(function (p) {
+ const row = document.createElement('div');
+ row.className = 'tpl-param-row';
+
+ const nameEl = document.createElement('code');
+ nameEl.className = 'tpl-param-name';
+ nameEl.textContent = p.name;
+
+ const labelEl = document.createElement('span');
+ labelEl.className = 'tpl-param-label';
+ labelEl.textContent = '{% trans "default" %}';
+
+ const defInput = document.createElement('input');
+ defInput.className = 'tpl-param-default';
+ defInput.placeholder = '{% trans "default value" %}';
+ defInput.value = p.def || '';
+ defInput.addEventListener('input', function () {
+ const v = defInput.value.trim();
+ const token = buildPlaceholderToken(p.name, v);
+ urlInput.value = urlInput.value.split(p.match).join(token);
+ p.match = token;
+ renderTemplateBuilder();
+ });
+
+ const delBtn = document.createElement('button');
+ delBtn.type = 'button';
+ delBtn.className = 'tpl-param-del';
+ delBtn.textContent = '✕';
+ delBtn.title = '{% trans "Remove parameter" %}';
+ delBtn.setAttribute('aria-label', '{% trans "Remove parameter" %} ' + p.name);
+ delBtn.addEventListener('click', function () {
+ urlInput.value = removeTokenFromUrl(urlInput.value, p.match);
+ renderTemplateBuilder();
+ urlInput.dispatchEvent(new Event('input', { bubbles: true }));
+ });
+
+ row.appendChild(nameEl);
+ row.appendChild(labelEl);
+ row.appendChild(defInput);
+ row.appendChild(delBtn);
+ paramListEl.appendChild(row);
+ });
+
+ // grey out preset chips already in use
+ document.querySelectorAll('#tpl-chips .tpl-chip').forEach(function (chip) {
+ const used = params.some(function (p) { return p.name === chip.dataset.param; });
+ chip.classList.toggle('used', used);
+ });
+
+ // live preview: fill defaults, flag missing ones
+ if (previewEl) {
+ if (!url) {
+ previewEl.innerHTML = '{% trans "Enter an original URL to see the live preview here." %}';
+ } else {
+ let html = escapeHtmlTpl(url);
+ params.forEach(function (p) {
+ const escMatch = escapeHtmlTpl(p.match);
+ const valHtml = (p.def !== null && p.def !== '')
+ ? '' + escapeHtmlTpl(p.def) + ''
+ : '' + escapeHtmlTpl(p.name) + '';
+ html = html.split(escMatch).join(valHtml);
+ });
+ previewEl.innerHTML = html;
+ }
+ }
+ }
+
+ // preset chips: click to insert
+ document.querySelectorAll('#tpl-chips .tpl-chip').forEach(function (chip) {
+ chip.addEventListener('click', function () {
+ const name = chip.dataset.param;
+ const def = chip.dataset.def || '';
+ const token = buildQueryToken(name, def);
+ appendToken(urlInput, token);
+ renderTemplateBuilder();
+ urlInput.dispatchEvent(new Event('input', { bubbles: true }));
+ });
+ });
+
+ // custom param: name + default -> append
+ if (addParamBtn) {
+ addParamBtn.addEventListener('click', function () {
+ const name = (newNameInput.value || '').trim();
+ if (!name || !/^[A-Za-z0-9_]+$/.test(name)) {
+ newNameInput.focus();
+ newNameInput.setAttribute('style', 'border-color:#ef4444');
+ setTimeout(function () { newNameInput.removeAttribute('style'); }, 1200);
+ return;
+ }
+ const def = (newDefInput.value || '').trim();
+ const token = buildQueryToken(name, def);
+ appendToken(urlInput, token);
+ newNameInput.value = '';
+ newDefInput.value = '';
+ renderTemplateBuilder();
+ urlInput.dispatchEvent(new Event('input', { bubbles: true }));
+ });
+ newNameInput.addEventListener('keydown', function (e) {
+ if (e.key === 'Enter') { e.preventDefault(); addParamBtn.click(); }
+ });
+ newDefInput.addEventListener('keydown', function (e) {
+ if (e.key === 'Enter') { e.preventDefault(); addParamBtn.click(); }
+ });
+ }
+
+ // sync on manual URL editing
+ if (urlInput) urlInput.addEventListener('input', renderTemplateBuilder);
+ renderTemplateBuilder();
+
// Initialize form state
if (linkTypeInput.value === 'CUSTOM') {
showCustomFields();
diff --git a/links/views.py b/links/views.py
index 17901a0..f230850 100644
--- a/links/views.py
+++ b/links/views.py
@@ -361,7 +361,24 @@ def redirect_to_original(request, alias, param=None):
'link': link,
})
- # 如果是模板 URL 并且提供了参数
+ # 模板 URL:query 参数按名映射(?q=xxx&page=2),路径参数兼容旧行为
+ # (go/alias/xxx → 所有未提供值的参数都用 xxx),都没有则用默认值
+ template_params = link.get_template_parameters()
+ if template_params:
+ url_kwargs = {k: v for k, v in request.GET.items() if k in template_params}
+ if param:
+ for name in template_params:
+ if name not in url_kwargs:
+ url_kwargs[name] = param
+ if url_kwargs:
+ url = link.get_processed_url(**url_kwargs)
+ else:
+ url = link.get_processed_url()
+ if url is None:
+ url = link.original_url
+ return redirect(url)
+
+ # 非模板链接:仅兼容旧版路径参数行为
if param:
# 从 URL 中提取参数名
pattern = r'\{([^{}]*)\}'
diff --git a/tests/test_template_redirect.py b/tests/test_template_redirect.py
new file mode 100644
index 0000000..314e90d
--- /dev/null
+++ b/tests/test_template_redirect.py
@@ -0,0 +1,52 @@
+"""Tests for template-link redirects: query-param mapping, path-param
+compat, and default-value fallback (the Sketch-style template builder
+produces ?q={q,default=…} style URLs, so the redirect must honor GET)."""
+import pytest
+from django.test import Client
+from django.urls import reverse
+from links.models import Link
+
+
+@pytest.fixture
+def tpl_link(db):
+ return Link.objects.create(
+ alias="tpltest",
+ original_url="https://www.google.com/search?q={q,default=hello}&page={page,default=1}",
+ link_type=Link.LinkType.LINK,
+ )
+
+
+@pytest.mark.django_db
+def test_template_query_param_mapping(client, tpl_link):
+ resp = client.get(reverse("redirect_to_original", args=["tpltest"]), {"q": "weather"})
+ assert resp.status_code == 302
+ assert resp["Location"] == "https://www.google.com/search?q=weather&page=1"
+
+
+@pytest.mark.django_db
+def test_template_multiple_query_params(client, tpl_link):
+ resp = client.get(reverse("redirect_to_original", args=["tpltest"]), {"q": "stocks", "page": "3"})
+ assert resp.status_code == 302
+ assert resp["Location"] == "https://www.google.com/search?q=stocks&page=3"
+
+
+@pytest.mark.django_db
+def test_template_defaults_when_no_params(client, tpl_link):
+ resp = client.get(reverse("redirect_to_original", args=["tpltest"]))
+ assert resp.status_code == 302
+ assert resp["Location"] == "https://www.google.com/search?q=hello&page=1"
+
+
+@pytest.mark.django_db
+def test_template_path_param_backward_compat(client, tpl_link):
+ """Old style go/alias/foo fills every unfilled param with foo."""
+ resp = client.get(reverse("redirect_to_original_with_param", args=["tpltest", "weather"]))
+ assert resp.status_code == 302
+ assert resp["Location"] == "https://www.google.com/search?q=weather&page=weather"
+
+
+@pytest.mark.django_db
+def test_template_path_plus_query_query_wins(client, tpl_link):
+ resp = client.get(reverse("redirect_to_original_with_param", args=["tpltest", "foo"]), {"q": "weather"})
+ assert resp.status_code == 302
+ assert resp["Location"] == "https://www.google.com/search?q=weather&page=foo"