feat(link form): TEMPLATE becomes its own type with dedicated tab/fields

- models: LinkType gains TEMPLATE (migration 0054, choices only)
- link_form: 4 tabs — Link / 🔧 Template / Custom /  Action; each type
  shows only its own fields: Template tab owns the Sketch builder, Link tab
  is a plain URL, Custom owns the markdown editor, Action owns the Discord
  card + message template; per-type descriptions; tab colors blue/purple/
  green/purple
- Editing a legacy LINK with {param} in URL auto-switches to Template tab
  (and saving migrates it to TEMPLATE type)
- badges (row/detail/search cards) recognize TEMPLATE type; detail page
  shows Original URL for TEMPLATE too
- forms: TEMPLATE requires original_url
This commit is contained in:
OpenClaw Sub-agent
2026-08-02 17:48:32 +10:00
parent ce0b83cd4d
commit 539303b8f6
8 changed files with 100 additions and 86 deletions
+2
View File
@@ -130,6 +130,8 @@ 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,18 @@
# Generated by Django 5.2.12 on 2026-08-02 07:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('links', '0053_ask_as_action_link'),
]
operations = [
migrations.AlterField(
model_name='link',
name='link_type',
field=models.CharField(choices=[('LINK', 'Link'), ('TEMPLATE', 'Template'), ('CUSTOM', 'Custom'), ('ACTION', 'Action')], default='LINK', max_length=10),
),
]
+1
View File
@@ -19,6 +19,7 @@ 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 '{' in link.original_url and '}' in link.original_url %}
{% if link.link_type == 'TEMPLATE' or '{' 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 '{' in link.original_url and '}' in link.original_url %}
{% if link.link_type == 'TEMPLATE' or '{' 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' %}
{% if link.link_type == 'LINK' or link.link_type == 'TEMPLATE' %}
<div class="link-meta-item span-2">
<dt>{% trans "Original URL" %}</dt>
<dd>
+73 -83
View File
@@ -154,10 +154,13 @@
<!-- 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 %}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_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">
{% trans "Link" %}
</button>
<button type="button" class="py-2 px-4 text-sm font-medium text-center {% if is_custom %}text-blue-600 border-b-2 border-blue-600{% else %}text-gray-500 border-b-2 border-transparent{% endif %} hover:text-gray-700 hover:border-gray-300 focus:outline-none" id="custom-tab">
<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>
<button type="button" class="py-2 px-4 text-sm font-medium text-center {% if is_action %}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="action-tab">
@@ -167,24 +170,24 @@
</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 %}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_template 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>
</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 %}">
<p class="text-sm text-green-700">
{% trans "Custom type allows you to create a link with custom HTML content. Use this when you want to display a message or custom content instead of redirecting." %}
</p>
</div>
<div id="template-description" class="bg-purple-50 border-l-4 border-purple-400 p-4 mb-4 {% if is_custom or is_action %}hidden{% endif %}">
<p class="text-sm text-purple-700">
{% trans "Template type allows you to create dynamic URLs with parameters. Use {query, default=value} syntax in the URL." %}
</p>
<p class="text-sm text-purple-700 mt-2">
{% trans "Example: https://google.com/search?q={query, default=hello}" %}
</p>
</div>
<div id="action-description" class="bg-purple-50 border-l-4 border-purple-400 p-4 mb-4 {% if not is_action %}hidden{% endif %}">
<p class="text-sm text-purple-700">
⚡ 动作链接:打开链接时自动执行一个动作。链接后面跟的字会变成消息内容发给小黑。
@@ -205,7 +208,7 @@
{% for field in form %}
{% if field.name != 'link_type' and field.name != 'action_config' %}
<!-- Render fields based on link type -->
<div class="space-y-1 {% if field.name == 'original_url' %}link-field{% if is_custom %}hidden{% endif %}{% elif field.name == 'text' %} custom-field {% if not is_custom %}hidden{% endif %}{% elif field.name == 'action_type' or field.name == 'action_message_template' %}action-field{% if not is_action %}hidden{% endif %}{% endif %}">
<div class="space-y-1 {% if field.name == 'original_url' %}link-field{% if is_custom or is_action %}hidden{% endif %}{% elif field.name == 'text' %} custom-field {% if not is_custom %}hidden{% endif %}{% elif field.name == 'action_type' or field.name == 'action_message_template' %}action-field{% if not is_action %}hidden{% endif %}{% endif %}">
<label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-700 {% if field.name == 'original_url' and is_custom or field.name == 'text' and not is_custom or field.name == 'action_type' and not is_action or field.name == 'action_message_template' and not is_action %}hidden{% endif %} {% if field.name != 'description' and field.name != 'action_type' and field.name != 'action_message_template' %}required-field{% endif %}">
{{ field.label }}
</label>
@@ -216,16 +219,16 @@
{{ field }}
{% endif %}
{% elif field.name == 'original_url' %}
<!-- Render original_url field only for default links -->
{% if not is_custom %}
<!-- Render original_url field for Link & Template links -->
{% if not is_custom and not is_action %}
<input type="{{ field.field.widget.input_type }}"
name="{{ field.name }}"
id="{{ field.id_for_label }}"
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 %}">
<!-- Sketch-style template builder: Template type only -->
<div id="template-builder" class="template-builder {% if not is_template %}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">
@@ -358,6 +361,7 @@
});
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,102 +373,81 @@
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');
let simplemde = null;
function setTabActive(tab, color) {
[linkTab, templateTab, 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');
t.classList.add('text-gray-500', 'border-transparent');
});
tab.classList.remove('text-gray-500', 'border-transparent');
tab.classList.add('text-' + color + '-600', 'border-' + color + '-600');
}
function showFieldSet(fields, visible) {
fields.forEach(function (field) {
field.classList.toggle('hidden', !visible);
const label = field.querySelector('label');
if (label) label.classList.toggle('hidden', !visible);
});
}
function showLinkFields() {
linkTab.classList.add('text-blue-600', 'border-blue-600');
linkTab.classList.remove('text-gray-500', 'border-transparent');
customTab.classList.remove('text-blue-600', 'border-blue-600');
customTab.classList.add('text-gray-500', 'border-transparent');
actionTab.classList.remove('text-purple-600', 'border-purple-600');
actionTab.classList.add('text-gray-500', 'border-transparent');
linkFields.forEach(field => {
field.classList.remove('hidden');
const label = field.querySelector('label');
if (label) label.classList.remove('hidden');
});
customFields.forEach(field => {
field.classList.add('hidden');
const label = field.querySelector('label');
if (label) label.classList.add('hidden');
});
actionFields.forEach(field => {
field.classList.add('hidden');
const label = field.querySelector('label');
if (label) label.classList.add('hidden');
});
setTabActive(linkTab, 'blue');
showFieldSet(linkFields, true);
showFieldSet(customFields, false);
showFieldSet(actionFields, false);
linkDescription.classList.remove('hidden');
templateDescription.classList.remove('hidden');
templateDescription.classList.add('hidden');
customDescription.classList.add('hidden');
actionDescription.classList.add('hidden');
linkTypeInput.value = 'LINK';
textField.classList.add('hidden');
const tplBuilder = document.getElementById('template-builder');
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');
}
function showCustomFields() {
customTab.classList.add('text-blue-600', 'border-blue-600');
customTab.classList.remove('text-gray-500', 'border-transparent');
linkTab.classList.remove('text-blue-600', 'border-blue-600');
linkTab.classList.add('text-gray-500', 'border-transparent');
actionTab.classList.remove('text-purple-600', 'border-purple-600');
actionTab.classList.add('text-gray-500', 'border-transparent');
customFields.forEach(field => {
field.classList.remove('hidden');
const label = field.querySelector('label');
if (label) label.classList.remove('hidden');
});
linkFields.forEach(field => {
field.classList.add('hidden');
const label = field.querySelector('label');
if (label) label.classList.add('hidden');
});
actionFields.forEach(field => {
field.classList.add('hidden');
const label = field.querySelector('label');
if (label) label.classList.add('hidden');
});
setTabActive(customTab, 'green');
showFieldSet(linkFields, false);
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');
const tplBuilder = document.getElementById('template-builder');
if (tplBuilder) tplBuilder.classList.add('hidden');
initializeSimpleMDE();
}
function showActionFields() {
actionTab.classList.add('text-purple-600', 'border-purple-600');
actionTab.classList.remove('text-gray-500', 'border-transparent');
linkTab.classList.remove('text-blue-600', 'border-blue-600');
linkTab.classList.add('text-gray-500', 'border-transparent');
customTab.classList.remove('text-blue-600', 'border-blue-600');
customTab.classList.add('text-gray-500', 'border-transparent');
actionFields.forEach(field => {
field.classList.remove('hidden');
const label = field.querySelector('label');
if (label) label.classList.remove('hidden');
});
linkFields.forEach(field => {
field.classList.add('hidden');
const label = field.querySelector('label');
if (label) label.classList.add('hidden');
});
customFields.forEach(field => {
field.classList.add('hidden');
const label = field.querySelector('label');
if (label) label.classList.add('hidden');
});
setTabActive(actionTab, 'purple');
showFieldSet(linkFields, false);
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');
const tplBuilder = document.getElementById('template-builder');
if (tplBuilder) tplBuilder.classList.add('hidden');
}
@@ -525,6 +508,7 @@
}
linkTab.addEventListener('click', showLinkFields);
templateTab.addEventListener('click', showTemplateFields);
customTab.addEventListener('click', showCustomFields);
actionTab.addEventListener('click', showActionFields);
@@ -762,8 +746,14 @@
// 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,6 +1276,7 @@
}
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,6 +170,7 @@ 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
@@ -234,6 +235,7 @@ 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