mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
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:
+8
-6
@@ -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'}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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, '&').replace(/</g, '<');
|
||||
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();
|
||||
|
||||
@@ -224,3 +224,29 @@ class TestAskActionLink:
|
||||
assert resp.status_code == 200
|
||||
assert '缺少参数' in resp.content.decode()
|
||||
assert 'ask.html' not in resp.content.decode()
|
||||
|
||||
|
||||
# ── 编辑页 UI(Apple-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
|
||||
|
||||
Reference in New Issue
Block a user