mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(ui): 数字员工标签改为芯片式编辑器
将编辑弹窗的标签输入从逗号分隔文本框改为芯片编辑器:每个标签独立 展示并带 × 删除,回车/空格/逗号(含全角「,」)添加,自动去重 + trim。 输入法选词态的回车不会误提交半成品;删除后提供 5 秒内联撤销。 form.tags 仍为逗号分隔字符串,后端零改动、API 契约不变。Closes #145.
This commit is contained in:
parent
cd3ae0c001
commit
c7ae406dcc
@ -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',
|
||||
|
||||
@ -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: '状态已更新',
|
||||
|
||||
@ -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; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user