refactor: merge Template back into Link type (per user decision)

- Revert TEMPLATE as separate LinkType; templates are plain links whose
  URL contains {param} placeholders
- migration 0055: data-migrate any TEMPLATE links back to LINK, roll back
  choices (0054 stays in history)
- link_form: back to 3 tabs (Link/Custom/Action); Link tab owns the URL
  field plus a dashed '🔧 Add template parameters' toggle; the Sketch
  builder auto-shows when the URL contains { } (and stays visible after a
  manual toggle); hidden on Custom/Action tabs
- badges (row/detail/search) detect templates by URL content again;
  detail page shows Original URL for plain links
- views/forms: drop is_template plumbing and TEMPLATE validation
This commit is contained in:
OpenClaw Sub-agent
2026-08-02 18:22:45 +10:00
parent f756ab83e1
commit 5dfb09efff
8 changed files with 77 additions and 54 deletions
-2
View File
@@ -130,8 +130,6 @@ class LinkForm(forms.ModelForm):
if link_type == Link.LinkType.LINK and not original_url:
raise forms.ValidationError(_("Original URL is required for Link type."))
elif link_type == Link.LinkType.TEMPLATE and not original_url:
raise forms.ValidationError(_("Original URL is required for Template type."))
elif link_type == Link.LinkType.CUSTOM and not text:
raise forms.ValidationError(_("Text is required for Custom type."))
elif link_type == Link.LinkType.ACTION:
@@ -0,0 +1,25 @@
# Generated by Django 5.2.12 on 2026-08-02 08:17
from django.db import migrations, models
def template_to_link(apps, schema_editor):
"""Template links were merged back into plain links."""
Link = apps.get_model('links', 'Link')
Link.objects.filter(link_type='TEMPLATE').update(link_type='LINK')
class Migration(migrations.Migration):
dependencies = [
('links', '0054_add_template_link_type'),
]
operations = [
migrations.RunPython(template_to_link, migrations.RunPython.noop),
migrations.AlterField(
model_name='link',
name='link_type',
field=models.CharField(choices=[('LINK', 'Link'), ('CUSTOM', 'Custom'), ('ACTION', 'Action')], default='LINK', max_length=10),
),
]
-1
View File
@@ -19,7 +19,6 @@ logger = logging.getLogger(__name__)
class Link(models.Model):
class LinkType(models.TextChoices):
LINK = 'LINK', _('Link')
TEMPLATE = 'TEMPLATE', _('Template')
CUSTOM = 'CUSTOM', _('Custom')
ACTION = 'ACTION', _('Action')
+1 -1
View File
@@ -7,7 +7,7 @@
<a href="{% url 'redirect_to_original' link.alias %}" target="_blank" rel="noopener noreferrer" class="alias-link">{{ link.alias }}</a>
</td>
<td class="hidden sm:table-cell">
{% if link.link_type == 'TEMPLATE' or '{' in link.original_url|default:'' and '}' in link.original_url|default:'' %}
{% if '{' in link.original_url|default:'' and '}' in link.original_url|default:'' %}
<span class="badge badge-template"><i class="fas fa-magic" aria-hidden="true"></i>{% trans "Template" %}</span>
{% elif link.link_type == 'LINK' %}
<span class="badge badge-link"><i class="fas fa-link" aria-hidden="true"></i>{% trans "Link" %}</span>
+2 -2
View File
@@ -10,7 +10,7 @@
<div class="link-header-row">
<div class="link-type-badge-row">
<h1 class="link-detail-title">{% trans "Link Details" %}</h1>
{% if link.link_type == 'TEMPLATE' or '{' in link.original_url|default:'' and '}' in link.original_url|default:'' %}
{% if '{' in link.original_url|default:'' and '}' in link.original_url|default:'' %}
<span class="badge badge-template"><i class="fas fa-magic" aria-hidden="true"></i>{% trans "Template" %}</span>
{% elif link.link_type == 'LINK' %}
<span class="badge badge-link"><i class="fas fa-link" aria-hidden="true"></i>{% trans "Link" %}</span>
@@ -53,7 +53,7 @@
<dt>{% trans "Updated At" %}</dt>
<dd>{{ link.updated_at|date:"Y-m-d H:i" }}</dd>
</div>
{% if link.link_type == 'LINK' or link.link_type == 'TEMPLATE' %}
{% if link.link_type == 'LINK' %}
<div class="link-meta-item span-2">
<dt>{% trans "Original URL" %}</dt>
<dd>
+49 -45
View File
@@ -79,6 +79,15 @@
padding: .9rem 1rem;
}
.tpl-label { font-size: .7rem; font-weight: 600; color: #7c6ba6; text-transform: uppercase; letter-spacing: .06em; }
.tpl-toggle {
display: inline-flex; align-items: center; gap: .35rem;
margin-top: .6rem; border: 1px dashed #c4b5fd; background: #faf9ff;
color: #6d28d9; border-radius: 10px;
padding: .45rem 1rem; font-size: .85rem; font-weight: 600;
cursor: pointer; transition: all .15s;
}
.tpl-toggle:hover { background: #ede9fe; border-color: #a78bfa; }
.tpl-toggle.open { border-style: solid; background: #ede9fe; }
.tpl-hint { font-size: .72rem; color: #9ca3af; margin-top: .2rem; line-height: 1.4; }
.tpl-chip {
display: inline-flex; align-items: center; gap: .35rem;
@@ -154,12 +163,9 @@
<!-- Link Type Tabs -->
<div class="mb-4">
<div class="flex border-b border-gray-200">
<button type="button" class="py-2 px-4 text-sm font-medium text-center {% if not is_custom and not is_template and not is_action %}text-blue-600 border-b-2 border-blue-600{% else %}text-gray-500 border-b-2 border-transparent{% endif %} hover:text-blue-700 hover:border-blue-700 focus:outline-none" id="link-tab">
<button type="button" class="py-2 px-4 text-sm font-medium text-center {% if not is_custom and not is_action %}text-blue-600 border-b-2 border-blue-600{% else %}text-gray-500 border-b-2 border-transparent{% endif %} hover:text-blue-700 hover:border-blue-700 focus:outline-none" id="link-tab">
{% trans "Link" %}
</button>
<button type="button" class="py-2 px-4 text-sm font-medium text-center {% if is_template %}text-purple-600 border-b-2 border-purple-600{% else %}text-gray-500 border-b-2 border-transparent{% endif %} hover:text-purple-700 hover:border-purple-300 focus:outline-none" id="template-tab">
🔧 {% trans "Template" %}
</button>
<button type="button" class="py-2 px-4 text-sm font-medium text-center {% if is_custom %}text-green-600 border-b-2 border-green-600{% else %}text-gray-500 border-b-2 border-transparent{% endif %} hover:text-green-700 hover:border-green-300 focus:outline-none" id="custom-tab">
{% trans "Custom" %}
</button>
@@ -170,17 +176,12 @@
</div>
<!-- Link Type Description -->
<div id="link-description" class="bg-blue-50 border-l-4 border-blue-400 p-4 mb-4 {% if is_custom or is_template or is_action %}hidden{% endif %}">
<div id="link-description" class="bg-blue-50 border-l-4 border-blue-400 p-4 mb-4 {% if is_custom or is_action %}hidden{% endif %}">
<p class="text-sm text-blue-700">
{% trans "Link type is used for redirecting to an external URL. Use this when you want to create a short alias for a long URL." %}
</p>
</div>
<div id="template-description" class="bg-purple-50 border-l-4 border-purple-400 p-4 mb-4 {% if not is_template %}hidden{% endif %}">
<p class="text-sm text-purple-700">
{% trans "Template type creates dynamic URLs with parameters. Click a parameter chip below to insert it, or type {name,default=value} in the URL." %}
</p>
<p class="text-sm text-purple-700 mt-2">
{% trans "Example: https://google.com/search?q={q, default=hello} — opening go/alias?q=weather fills the parameter." %}
<p class="text-sm text-blue-700 mt-2">
{% trans "For dynamic URLs with parameters, click “Add template parameters” below the URL field." %}
</p>
</div>
<div id="custom-description" class="bg-green-50 border-l-4 border-green-400 p-4 mb-4 {% if not is_custom %}hidden{% endif %}">
@@ -227,8 +228,9 @@
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: Template type only -->
<div id="template-builder" class="template-builder {% if not is_template %}hidden{% endif %}">
<!-- Sketch-style template builder (Link type; auto-shows when the URL has {params}) -->
<button type="button" id="tpl-toggle" class="tpl-toggle">🔧 <span id="tpl-toggle-label">{% trans "Add template parameters" %}</span></button>
<div id="template-builder" class="template-builder hidden">
<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">
@@ -361,7 +363,6 @@
});
document.addEventListener('DOMContentLoaded', function() {
const linkTab = document.getElementById('link-tab');
const templateTab = document.getElementById('template-tab');
const customTab = document.getElementById('custom-tab');
const actionTab = document.getElementById('action-tab');
const linkFields = document.querySelectorAll('.link-field');
@@ -369,15 +370,18 @@
const actionFields = document.querySelectorAll('.action-field');
const linkDescription = document.getElementById('link-description');
const customDescription = document.getElementById('custom-description');
const templateDescription = document.getElementById('template-description');
const actionDescription = document.getElementById('action-description');
const linkTypeInput = document.getElementById('id_link_type');
const textField = document.querySelector('.custom-field');
const tplBuilder = document.getElementById('template-builder');
const tplToggle = document.getElementById('tpl-toggle');
const tplToggleLabel = document.getElementById('tpl-toggle-label');
const urlInput = document.getElementById('id_original_url');
let simplemde = null;
let builderManuallyOpen = false;
function setTabActive(tab, color) {
[linkTab, templateTab, customTab, actionTab].forEach(function (t) {
[linkTab, customTab, actionTab].forEach(function (t) {
t.classList.remove('text-blue-600', 'border-blue-600',
'text-purple-600', 'border-purple-600',
'text-green-600', 'border-green-600');
@@ -393,6 +397,17 @@
if (label) label.classList.toggle('hidden', !visible);
});
}
function syncBuilderVisibility() {
if (!tplBuilder || !tplToggle) return;
const url = urlInput ? urlInput.value : '';
const hasParams = url.indexOf('{') > -1 && url.indexOf('}') > -1;
const show = hasParams || builderManuallyOpen;
tplBuilder.classList.toggle('hidden', !show);
tplToggle.classList.toggle('open', show);
if (tplToggleLabel) {
tplToggleLabel.textContent = show ? '{% trans "Hide template parameters" %}' : '{% trans "Add template parameters" %}';
}
}
function showLinkFields() {
setTabActive(linkTab, 'blue');
@@ -400,26 +415,12 @@
showFieldSet(customFields, false);
showFieldSet(actionFields, false);
linkDescription.classList.remove('hidden');
templateDescription.classList.add('hidden');
customDescription.classList.add('hidden');
actionDescription.classList.add('hidden');
linkTypeInput.value = 'LINK';
textField.classList.add('hidden');
if (tplBuilder) tplBuilder.classList.add('hidden');
}
function showTemplateFields() {
setTabActive(templateTab, 'purple');
showFieldSet(linkFields, true);
showFieldSet(customFields, false);
showFieldSet(actionFields, false);
linkDescription.classList.add('hidden');
templateDescription.classList.remove('hidden');
customDescription.classList.add('hidden');
actionDescription.classList.add('hidden');
linkTypeInput.value = 'TEMPLATE';
textField.classList.add('hidden');
if (tplBuilder) tplBuilder.classList.remove('hidden');
if (tplToggle) tplToggle.classList.remove('hidden');
syncBuilderVisibility();
}
function showCustomFields() {
@@ -428,12 +429,12 @@
showFieldSet(customFields, true);
showFieldSet(actionFields, false);
customDescription.classList.remove('hidden');
templateDescription.classList.add('hidden');
linkDescription.classList.add('hidden');
actionDescription.classList.add('hidden');
linkTypeInput.value = 'CUSTOM';
textField.classList.remove('hidden');
if (tplBuilder) tplBuilder.classList.add('hidden');
if (tplToggle) tplToggle.classList.add('hidden');
initializeSimpleMDE();
}
@@ -443,12 +444,12 @@
showFieldSet(customFields, false);
showFieldSet(actionFields, true);
actionDescription.classList.remove('hidden');
templateDescription.classList.add('hidden');
linkDescription.classList.add('hidden');
customDescription.classList.add('hidden');
linkTypeInput.value = 'ACTION';
textField.classList.add('hidden');
if (tplBuilder) tplBuilder.classList.add('hidden');
if (tplToggle) tplToggle.classList.add('hidden');
}
function initializeSimpleMDE() {
@@ -508,7 +509,6 @@
}
linkTab.addEventListener('click', showLinkFields);
templateTab.addEventListener('click', showTemplateFields);
customTab.addEventListener('click', showCustomFields);
actionTab.addEventListener('click', showActionFields);
@@ -542,8 +542,6 @@
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');
@@ -739,21 +737,27 @@
});
}
// toggle: manually show/hide the builder
if (tplToggle) {
tplToggle.addEventListener('click', function () {
builderManuallyOpen = !builderManuallyOpen;
syncBuilderVisibility();
});
}
// sync on manual URL editing
if (urlInput) urlInput.addEventListener('input', renderTemplateBuilder);
if (urlInput) urlInput.addEventListener('input', function () {
renderTemplateBuilder();
syncBuilderVisibility();
});
renderTemplateBuilder();
syncBuilderVisibility();
// Initialize form state
if (linkTypeInput.value === 'CUSTOM') {
showCustomFields();
} else if (linkTypeInput.value === 'TEMPLATE') {
showTemplateFields();
} else if (linkTypeInput.value === 'ACTION') {
showActionFields();
} else if (urlInput && urlInput.value.includes('{') && urlInput.value.includes('}')) {
// Legacy template links (LINK type + {param} in URL): auto-switch
// to the Template tab so the visual builder is available.
showTemplateFields();
} else {
showLinkFields();
}
-1
View File
@@ -1276,7 +1276,6 @@
}
function badgeClassFor(item) {
if (item.link_type === 'TEMPLATE') return 'badge-template';
if (item.original_url && item.original_url.includes('{') && item.original_url.includes('}')) return 'badge-template';
if (item.link_type === 'ACTION') return 'badge-action';
if (item.link_type === 'LINK') return 'badge-link';
-2
View File
@@ -170,7 +170,6 @@ class LinkCreateView(CreateView):
# 新建表单:根据当前表单值判断(提交失败重渲染时保持选中状态)
form = context.get('form')
context['is_custom'] = bool(form and form['link_type'].value() == Link.LinkType.CUSTOM)
context['is_template'] = bool(form and form['link_type'].value() == Link.LinkType.TEMPLATE)
context['is_action'] = bool(form and form['link_type'].value() == Link.LinkType.ACTION)
return context
@@ -235,7 +234,6 @@ class LinkUpdateView(UpdateView):
context = super().get_context_data(**kwargs)
link = self.get_object()
context['is_custom'] = link.link_type == Link.LinkType.CUSTOM
context['is_template'] = link.link_type == Link.LinkType.TEMPLATE
context['is_action'] = link.link_type == Link.LinkType.ACTION
return context