feat(ui): 数字员工标签改为芯片式编辑器

将编辑弹窗的标签输入从逗号分隔文本框改为芯片编辑器:每个标签独立
展示并带 × 删除,回车/空格/逗号(含全角「,」)添加,自动去重 + trim。
输入法选词态的回车不会误提交半成品;删除后提供 5 秒内联撤销。

form.tags 仍为逗号分隔字符串,后端零改动、API 契约不变。Closes #145.
This commit is contained in:
倪程伟 2026-06-08 00:22:07 +08:00 committed by matevip
parent cd3ae0c001
commit c7ae406dcc
3 changed files with 111 additions and 4 deletions

View File

@ -1231,6 +1231,8 @@ export default {
modelGlobalDefault: 'Use global default',
modelHint: 'Override the global default model for this employee. Leave blank to follow Settings → Models.',
tags: 'Tags',
tagsHint: 'Press Enter, space, or comma to add; click the × on a chip to remove.',
tagUndo: 'Undo',
enabled: 'Enabled',
},
thinkingLevels: {
@ -1261,7 +1263,7 @@ export default {
goal: 'e.g. Turn data into actionable insights',
backstory: 'e.g. Spent 10 years in data — believes in asking the right question before writing SQL...',
extraInstructions: 'Optional: output format, process checklist, or boundary rules...',
tags: 'tag1,tag2',
tags: 'Enter, space, or comma to add',
workspaceBasePath: 'e.g. projects/code-review',
},
messages: {
@ -1272,6 +1274,7 @@ export default {
saveFailed: 'Failed to save employee',
saveSuccess: 'Employee saved',
deleteFailed: 'Failed to delete employee',
tagRemoved: 'Removed tag "{tag}"',
deleteSuccess: 'Employee let go',
toggleFailed: 'Failed to toggle status',
toggleSuccess: 'Status updated',

View File

@ -1123,6 +1123,8 @@ export default {
modelGlobalDefault: '使用全局默认模型',
modelHint: '为该员工单独指定模型,留空则跟随「设置 → 模型」中的全局默认。',
tags: '标签',
tagsHint: '输入后按回车、空格或逗号添加;点芯片上的 × 删除。',
tagUndo: '撤销',
enabled: '启用',
},
thinkingLevels: {
@ -1153,7 +1155,7 @@ export default {
goal: '例:把数据变成可执行洞察',
backstory: '例:在数据里待了十年,相信先问对问题再写 SQL...',
extraInstructions: '可选:补充输出格式、流程清单或边界规则...',
tags: 'tag1,tag2',
tags: '回车、空格或逗号添加',
workspaceBasePath: '例如projects/code-review',
},
messages: {
@ -1164,6 +1166,7 @@ export default {
saveFailed: '保存员工失败',
saveSuccess: '员工已保存',
deleteFailed: '删除员工失败',
tagRemoved: '已移除标签「{tag}」',
deleteSuccess: '员工已离职',
toggleFailed: '切换状态失败',
toggleSuccess: '状态已更新',

View File

@ -349,7 +349,29 @@
</div>
<div class="form-group">
<label class="form-label">{{ t('agents.fields.tags') }}</label>
<input v-model="form.tags" class="form-input" :placeholder="t('agents.placeholders.tags')" />
<div class="tags-editor" @click="($refs.tagInputEl as HTMLInputElement)?.focus()">
<span v-for="tag in tagList" :key="tag" class="tag-chip tag-chip--editable">
{{ tag }}
<button type="button" class="tag-chip__remove" @click.stop="removeTag(tag)">×</button>
</span>
<input
ref="tagInputEl"
v-model="tagInput"
class="tags-editor__input"
:placeholder="tagList.length ? '' : t('agents.placeholders.tags')"
@keydown="onTagInputKeydown"
@compositionstart="tagComposing = true"
@compositionend="tagComposing = false; flushTagSeparators()"
@blur="commitTagInput"
/>
</div>
<p class="form-hint tags-hint">
<template v-if="recentlyRemovedTag">
{{ t('agents.messages.tagRemoved', { tag: recentlyRemovedTag }) }}
<button type="button" class="tags-undo" @click="undoRemoveTag">{{ t('agents.fields.tagUndo') }}</button>
</template>
<template v-else>{{ t('agents.fields.tagsHint') }}</template>
</p>
</div>
<div class="form-group">
<label class="form-label">{{ t('agents.fields.enabled') }}</label>
@ -637,7 +659,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { mcToast } from '@/composables/useMcToast'
@ -913,6 +935,72 @@ const defaultForm = (): Partial<Agent> & { name: string; defaultThinkingLevel: s
const form = ref(defaultForm())
const iconPickerVisible = ref(false)
// Chip-style tag editor (#145). `form.tags` stays the comma-separated string
// source of truth for the API; the chips render a derived array. The input
// supports Enter / space / ASCII or full-width comma as separators, is
// IME-safe (won't commit a half-composed Chinese token on the candidate-
// confirming Enter), and offers a transient inline undo after removal.
const tagInput = ref('')
const tagComposing = ref(false)
const recentlyRemovedTag = ref<string | null>(null)
let undoTimer: ReturnType<typeof setTimeout> | null = null
// Stored value is comma-joined, so split on comma only a stored tag may
// legitimately contain spaces, which the input tokenizer (below) would split.
const tagList = computed(() => (form.value.tags || '').split(',').map(s => s.trim()).filter(Boolean))
function setTags(list: string[]) {
form.value.tags = list.join(',')
}
function addTags(tokens: string[]) {
const merged = [...tagList.value]
for (const raw of tokens) {
const tag = raw.trim()
if (tag && !merged.includes(tag)) merged.push(tag)
}
setTags(merged)
}
// Enter / blur: commit everything left in the box.
function commitTagInput() {
addTags(tagInput.value.split(/[,\s]+/))
tagInput.value = ''
}
// Live typing: once a separator (comma / full-width comma / whitespace) lands,
// flush the completed tokens and keep any trailing partial in the box. v-model
// already suppresses updates mid-IME-composition, so this only runs on settled
// text; the guard is belt-and-suspenders.
function flushTagSeparators() {
if (tagComposing.value || !/[,\s]/.test(tagInput.value)) return
const tokens = tagInput.value.split(/[,\s]+/)
const partial = /[,\s]$/.test(tagInput.value) ? '' : (tokens.pop() ?? '')
addTags(tokens)
tagInput.value = partial
}
watch(tagInput, flushTagSeparators)
function onTagInputKeydown(e: KeyboardEvent) {
if (e.isComposing || e.keyCode === 229) return
if (e.key === 'Enter') {
e.preventDefault()
commitTagInput()
} else if (e.key === 'Backspace' && !tagInput.value && tagList.value.length) {
e.preventDefault()
removeTag(tagList.value[tagList.value.length - 1])
}
}
function removeTag(tag: string) {
setTags(tagList.value.filter(t => t !== tag))
recentlyRemovedTag.value = tag
if (undoTimer) clearTimeout(undoTimer)
undoTimer = setTimeout(() => { recentlyRemovedTag.value = null }, 5000)
}
function undoRemoveTag() {
if (!recentlyRemovedTag.value) return
addTags([recentlyRemovedTag.value])
recentlyRemovedTag.value = null
if (undoTimer) clearTimeout(undoTimer)
}
// Identity-triad fields displayed in the basic tab. Kept separate from
// `form.systemPrompt` so the textarea state stays predictable while the
// user types we only flatten back to a single prompt at save time.
@ -1071,6 +1159,8 @@ function openBlankCreateModal() {
showTemplateSelector.value = false
editingAgent.value = null
form.value = defaultForm()
tagInput.value = ''
recentlyRemovedTag.value = null
profileForm.value = emptyProfile()
modalTab.value = 'basic'
skillBindingSearch.value = ''
@ -1152,6 +1242,8 @@ async function openEditModal(agent: Agent) {
skillsDisabled: agent.skillsDisabled === true,
toolsDisabled: agent.toolsDisabled === true,
}
tagInput.value = ''
recentlyRemovedTag.value = null
profileForm.value = parsePrompt(agent.systemPrompt)
modalTab.value = 'basic'
skillBindingSearch.value = ''
@ -1900,6 +1992,15 @@ html.dark .seg-count.warn {
.template-tags { display: flex; flex-wrap: wrap; gap: 4px; align-self: flex-start; margin-top: 2px; }
.tag-chip { font-size: 11px; padding: 2px 8px; background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); border-radius: 999px; white-space: nowrap; }
.tags-editor { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; min-height: 38px; padding: 6px 10px; border: 1px solid var(--mc-border); border-radius: 8px; background: var(--mc-bg-sunken); cursor: text; transition: border-color 0.15s; }
.tags-editor:focus-within { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217,119,87,0.1); }
.tag-chip--editable { display: inline-flex; align-items: center; gap: 2px; font-size: 12px; padding: 3px 4px 3px 10px; background: var(--mc-bg); color: var(--mc-text-secondary); border: 1px solid var(--mc-border); }
.tag-chip__remove { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; padding: 0; border: none; border-radius: 999px; background: var(--mc-bg-sunken); color: var(--mc-text-secondary); font-size: 15px; line-height: 1; cursor: pointer; transition: background 0.15s, color 0.15s; }
.tag-chip__remove:hover { background: var(--mc-danger, #e05656); color: #fff; }
.tags-editor__input { flex: 1; min-width: 80px; border: none; outline: none; background: transparent; font-size: 14px; color: var(--mc-text-primary); padding: 2px 0; }
.tags-hint { margin-top: 6px; }
.tags-undo { border: none; background: transparent; padding: 0 2px; color: var(--mc-primary); font-size: inherit; cursor: pointer; text-decoration: underline; }
/* RFC-090 §9.2 调整 B — Advanced Tools picker (collapsed by default) */
.advanced-tools { border: 1px dashed var(--mc-border); border-radius: 12px; padding: 0; }
.advanced-tools[open] { padding: 12px 14px; }