feat(template links): Sketch-style visual parameter builder

- link_form: template builder under the URL field — preset param chips
  (q/id/page/category/keyword/lang, CN labels) click to insert ?q={q} with
  smart ?/& separators; auto-parsed parameter rows (name + default input +
  remove); custom params (name + default -> append); live dark preview
  filling defaults (green) and flagging required (red); chips grey out
  when used; hidden on Custom/Action tabs; edits existing templates
- redirect_to_original: map GET query params (?q=weather) by name into
  template placeholders, keep path-param backward compat (go/alias/x
  fills every unfilled param), defaults otherwise; query wins over path
- tests: +5 template redirect cases (query map, multi-param, defaults,
  path compat, query-over-path)
This commit is contained in:
OpenClaw Sub-agent
2026-08-02 17:28:32 +10:00
parent 131154e183
commit 54a416c63c
3 changed files with 369 additions and 1 deletions
+299
View File
@@ -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; }
</style>
{% 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 %}>
<!-- Sketch-style template builder: click a chip to insert {param} -->
<div id="template-builder" class="template-builder {% if is_action %}hidden{% endif %}">
<div class="tpl-label">🔧 {% trans "Template parameters" %}</div>
<div class="tpl-hint">{% trans "Click a parameter to insert it into the URL. The text after go/alias/… fills the parameter when the link is opened." %}</div>
<div class="tpl-chips" id="tpl-chips">
<button type="button" class="tpl-chip" data-param="q"><span class="tpl-chip-sub">{</span>q<span class="tpl-chip-sub">}</span><span class="tpl-chip-sub">搜索词</span></button>
<button type="button" class="tpl-chip" data-param="id" data-def=""><span class="tpl-chip-sub">{</span>id<span class="tpl-chip-sub">}</span><span class="tpl-chip-sub">编号</span></button>
<button type="button" class="tpl-chip" data-param="page" data-def="1"><span class="tpl-chip-sub">{</span>page<span class="tpl-chip-sub">}</span><span class="tpl-chip-sub">页码</span></button>
<button type="button" class="tpl-chip" data-param="category"><span class="tpl-chip-sub">{</span>category<span class="tpl-chip-sub">}</span><span class="tpl-chip-sub">分类</span></button>
<button type="button" class="tpl-chip" data-param="keyword"><span class="tpl-chip-sub">{</span>keyword<span class="tpl-chip-sub">}</span><span class="tpl-chip-sub">关键词</span></button>
<button type="button" class="tpl-chip" data-param="lang"><span class="tpl-chip-sub">{</span>lang<span class="tpl-chip-sub">}</span><span class="tpl-chip-sub">语言</span></button>
</div>
<div class="tpl-param-list" id="tpl-param-list"></div>
<div class="tpl-add-row">
<input type="text" id="tpl-new-name" class="tpl-add-name" placeholder="{% trans 'param name' %}" aria-label="{% trans 'New parameter name' %}">
<input type="text" id="tpl-new-default" class="tpl-add-def" placeholder="{% trans 'default value (optional)' %}" aria-label="{% trans 'New parameter default' %}">
<button type="button" class="tpl-add-btn" id="tpl-add-param">+ {% trans "Add" %}</button>
</div>
<div class="tpl-preview">
<div class="tpl-preview-label">{% trans "Live preview" %}</div>
<div class="tpl-preview-url" id="tpl-preview-url"></div>
</div>
</div>
{% endif %}
{% elif field.name == 'action_type' %}
<!-- Apple-style action card picker -->
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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 = '<span class="tpl-preview-note">{% trans "Enter an original URL to see the live preview here." %}</span>';
} else {
let html = escapeHtmlTpl(url);
params.forEach(function (p) {
const escMatch = escapeHtmlTpl(p.match);
const valHtml = (p.def !== null && p.def !== '')
? '<span class="tpl-preview-val">' + escapeHtmlTpl(p.def) + '</span>'
: '<span class="tpl-preview-req">' + escapeHtmlTpl(p.name) + '</span>';
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();
+18 -1
View File
@@ -361,7 +361,24 @@ def redirect_to_original(request, alias, param=None):
'link': link,
})
# 如果是模板 URL 并且提供了参数
# 模板 URLquery 参数按名映射(?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'\{([^{}]*)\}'
+52
View File
@@ -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"