feat: Apple-style action link edit UI — hide JSON, card picker, live preview

- Hide action_config JSON field from the form (internal storage, not user-editable)
- Replace Action dropdown with an Apple-style radio card (💬 发消息到 Discord)
- Rename Message template → 消息内容 with plain-language help text
- Add live message preview that highlights {query} as a chip
- Tests: edit page renders card + Chinese labels, no JSON exposed (109 passed)
This commit is contained in:
OpenClaw Sub-agent
2026-08-01 21:16:00 +10:00
parent aa6a1a25fd
commit c4a4b7a299
3 changed files with 129 additions and 8 deletions
+8 -6
View File
@@ -54,15 +54,17 @@ class LinkForm(forms.ModelForm):
# 动作链接的配置字段(不入模型,clean 时组装进 action_config JSON
action_type = forms.ChoiceField(
required=False,
choices=[('', _('选择动作')), ('discord_send', _('发 Discord 消息'))],
label=_('Action'),
widget=forms.Select(attrs={'class': 'shadow appearance-none rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline'}),
choices=[('discord_send', _('消息到 Discord'))],
label=_('动作'),
initial='discord_send',
widget=forms.RadioSelect(attrs={'class': 'action-type-radio'}),
help_text=_('打开链接时执行的动作'),
)
action_message_template = forms.CharField(
required=False,
label=_('Message template'),
help_text=_('消息模板,{query} 会被链接 URL 中的参数替换。例如:@小黑 {query}'),
widget=forms.Textarea(attrs={'rows': 2, 'placeholder': '@小黑 {query}',
label=_('消息内容'),
help_text=_('链接地址后面加的字会自动放在 {query} 的位置。例如链接 go/ask/今天天气 → 消息会是“@小黑 今天天气”'),
widget=forms.Textarea(attrs={'rows': 2, 'placeholder': '@小黑 帮我看看 {query}',
'class': 'shadow appearance-none rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline'}),
)
+95 -2
View File
@@ -22,6 +22,48 @@
padding: 2px 8px !important;
margin: 3px !important;
}
/* ── Apple-style Action card picker ── */
.action-type-radio { display: none; }
.action-card {
display: flex; align-items: center; gap: 1rem;
border: 2px solid #e5e7eb; border-radius: 14px;
padding: 0.9rem 1.1rem; cursor: pointer;
transition: border-color .2s, background .2s, box-shadow .2s;
background: #fff;
}
.action-card:hover { border-color: #c4b5fd; background: #faf9ff; }
.action-card.selected { border-color: #8b5cf6; background: #f5f3ff; box-shadow: 0 0 0 3px rgba(139,92,246,.12); }
.action-card-icon {
width: 2.6rem; height: 2.6rem; flex-shrink: 0;
border-radius: 12px; background: #f5f3ff; color: #7c3aed;
display: flex; align-items: center; justify-content: center;
font-size: 1.3rem;
}
.action-card-title { font-weight: 600; font-size: .95rem; color: #1f2937; }
.action-card-desc { font-size: .8rem; color: #6b7280; margin-top: 2px; line-height: 1.4; }
.action-card-check {
margin-left: auto; width: 1.5rem; height: 1.5rem; flex-shrink: 0;
border-radius: 50%; border: 2px solid #d1d5db;
display: flex; align-items: center; justify-content: center;
color: transparent; font-size: .8rem; transition: all .2s;
}
.action-card.selected .action-card-check { background: #8b5cf6; border-color: #8b5cf6; color: #fff; }
/* ── Message preview bubble ── */
.msg-preview {
margin-top: .6rem; border-radius: 14px; background: #f9fafb;
border: 1px solid #e5e7eb; padding: .7rem .9rem;
}
.msg-preview-label { font-size: .7rem; font-weight: 600; color: #9ca3af; text-transform: uppercase; letter-spacing: .04em; margin-bottom: .35rem; }
.msg-preview-bubble {
display: inline-block; background: #fff; border: 1px solid #e5e7eb;
border-radius: 12px; padding: .45rem .8rem; font-size: .85rem; color: #111827;
box-shadow: 0 1px 2px rgba(0,0,0,.04);
}
.msg-preview-bubble .qchip {
background: #ede9fe; color: #6d28d9; border-radius: 6px;
padding: 0 .3rem; font-weight: 600;
}
.msg-preview-hint { font-size: .72rem; color: #9ca3af; margin-top: .4rem; }
</style>
{% endblock %}
@@ -89,7 +131,7 @@
{% endif %}
<input type="hidden" name="link_type" id="id_link_type" value="{{ form.link_type.value|default:'LINK' }}">
{% for field in form %}
{% if field.name != 'link_type' %}
{% 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 %}">
<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 %}">
@@ -111,10 +153,32 @@
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 %}>
{% endif %}
{% elif field.name == 'action_type' or field.name == 'action_message_template' %}
{% elif field.name == 'action_type' %}
<!-- Apple-style action card picker -->
{% if is_action or is_new %}
<div class="action-card {% if field.value == 'discord_send' %}selected{% endif %}" id="action-card-discord_send">
<input type="radio" name="{{ field.html_name }}"
id="id_action_type_discord_send"
value="discord_send"
class="action-type-radio"
{% if field.value == 'discord_send' %}checked{% endif %}>
<div class="action-card-icon">💬</div>
<div>
<div class="action-card-title">发消息到 Discord</div>
<div class="action-card-desc">打开链接时,把链接后面的文字发到 Discord 频道,小黑会收到并回复</div>
</div>
<div class="action-card-check"></div>
</div>
{% endif %}
{% elif field.name == 'action_message_template' %}
<!-- Render action config fields only for action links -->
{% if is_action or is_new %}
{{ field }}
<div class="msg-preview" id="msg-preview">
<div class="msg-preview-label">发送效果预览</div>
<div class="msg-preview-bubble" id="msg-preview-bubble">{{ field.value|default:'@小黑 帮我看看 {query}' }}</div>
<div class="msg-preview-hint">打开 go/ask/今天天气 时,<span class="qchip">今天天气</span> 会自动填入消息</div>
</div>
{% endif %}
{% elif field.name == 'alias' and form.instance.pk %}
<!-- Render alias field as read-only when editing -->
@@ -353,6 +417,35 @@
customTab.addEventListener('click', showCustomFields);
actionTab.addEventListener('click', showActionFields);
// ── Apple-style action card: click to select ──
document.querySelectorAll('.action-card').forEach(card => {
card.addEventListener('click', function() {
const radio = this.querySelector('input[type="radio"]');
if (!radio) return;
radio.checked = true;
document.querySelectorAll('.action-card').forEach(c => c.classList.remove('selected'));
this.classList.add('selected');
});
});
// ── Live preview: {query} → highlight chip ──
const previewBubble = document.getElementById('msg-preview-bubble');
const msgInput = document.getElementById('id_action_message_template');
function renderPreview() {
if (!previewBubble || !msgInput) return;
const sample = '今天天气';
const raw = msgInput.value || '@小黑 帮我看看 {query}';
const parts = raw.split('{query}');
let html = '';
parts.forEach((part, i) => {
html += part.replace(/&/g, '&amp;').replace(/</g, '&lt;');
if (i < parts.length - 1) html += '<span class="qchip">' + sample + '</span>';
});
previewBubble.innerHTML = html;
}
if (msgInput) msgInput.addEventListener('input', renderPreview);
renderPreview();
// Initialize form state
if (linkTypeInput.value === 'CUSTOM') {
showCustomFields();
+26
View File
@@ -224,3 +224,29 @@ class TestAskActionLink:
assert resp.status_code == 200
assert '缺少参数' in resp.content.decode()
assert 'ask.html' not in resp.content.decode()
# ── 编辑页 UIApple-style,不暴露 JSON)──────────────────────────────────
class TestActionEditPage:
def test_edit_page_hides_action_config_json(self, db):
link = Link.objects.create(
alias='ask2ui',
link_type=Link.LinkType.ACTION,
action_config={
'action_type': 'discord_send',
'message_template': '<@1467845106831855796> {query}',
},
)
client = Client()
html = client.get(f'/link/{link.pk}/edit/').content.decode()
# JSON 不应暴露给用户(key 形态 `"action_type":`,区别于表单字段名 action_type
assert '"action_type":' not in html
assert 'action_config' not in html
# 应显示苹果式卡片 + 中文标签 + 预览
assert 'action-card' in html
assert '发消息到 Discord' in html
assert '消息内容' in html
assert 'msg-preview' in html
# 旧英文标签不应出现
assert 'Message template' not in html