mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +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',
|
modelGlobalDefault: 'Use global default',
|
||||||
modelHint: 'Override the global default model for this employee. Leave blank to follow Settings → Models.',
|
modelHint: 'Override the global default model for this employee. Leave blank to follow Settings → Models.',
|
||||||
tags: 'Tags',
|
tags: 'Tags',
|
||||||
|
tagsHint: 'Press Enter, space, or comma to add; click the × on a chip to remove.',
|
||||||
|
tagUndo: 'Undo',
|
||||||
enabled: 'Enabled',
|
enabled: 'Enabled',
|
||||||
},
|
},
|
||||||
thinkingLevels: {
|
thinkingLevels: {
|
||||||
@ -1261,7 +1263,7 @@ export default {
|
|||||||
goal: 'e.g. Turn data into actionable insights',
|
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...',
|
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...',
|
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',
|
workspaceBasePath: 'e.g. projects/code-review',
|
||||||
},
|
},
|
||||||
messages: {
|
messages: {
|
||||||
@ -1272,6 +1274,7 @@ export default {
|
|||||||
saveFailed: 'Failed to save employee',
|
saveFailed: 'Failed to save employee',
|
||||||
saveSuccess: 'Employee saved',
|
saveSuccess: 'Employee saved',
|
||||||
deleteFailed: 'Failed to delete employee',
|
deleteFailed: 'Failed to delete employee',
|
||||||
|
tagRemoved: 'Removed tag "{tag}"',
|
||||||
deleteSuccess: 'Employee let go',
|
deleteSuccess: 'Employee let go',
|
||||||
toggleFailed: 'Failed to toggle status',
|
toggleFailed: 'Failed to toggle status',
|
||||||
toggleSuccess: 'Status updated',
|
toggleSuccess: 'Status updated',
|
||||||
|
|||||||
@ -1123,6 +1123,8 @@ export default {
|
|||||||
modelGlobalDefault: '使用全局默认模型',
|
modelGlobalDefault: '使用全局默认模型',
|
||||||
modelHint: '为该员工单独指定模型,留空则跟随「设置 → 模型」中的全局默认。',
|
modelHint: '为该员工单独指定模型,留空则跟随「设置 → 模型」中的全局默认。',
|
||||||
tags: '标签',
|
tags: '标签',
|
||||||
|
tagsHint: '输入后按回车、空格或逗号添加;点芯片上的 × 删除。',
|
||||||
|
tagUndo: '撤销',
|
||||||
enabled: '启用',
|
enabled: '启用',
|
||||||
},
|
},
|
||||||
thinkingLevels: {
|
thinkingLevels: {
|
||||||
@ -1153,7 +1155,7 @@ export default {
|
|||||||
goal: '例:把数据变成可执行洞察',
|
goal: '例:把数据变成可执行洞察',
|
||||||
backstory: '例:在数据里待了十年,相信先问对问题再写 SQL...',
|
backstory: '例:在数据里待了十年,相信先问对问题再写 SQL...',
|
||||||
extraInstructions: '可选:补充输出格式、流程清单或边界规则...',
|
extraInstructions: '可选:补充输出格式、流程清单或边界规则...',
|
||||||
tags: 'tag1,tag2',
|
tags: '回车、空格或逗号添加',
|
||||||
workspaceBasePath: '例如:projects/code-review',
|
workspaceBasePath: '例如:projects/code-review',
|
||||||
},
|
},
|
||||||
messages: {
|
messages: {
|
||||||
@ -1164,6 +1166,7 @@ export default {
|
|||||||
saveFailed: '保存员工失败',
|
saveFailed: '保存员工失败',
|
||||||
saveSuccess: '员工已保存',
|
saveSuccess: '员工已保存',
|
||||||
deleteFailed: '删除员工失败',
|
deleteFailed: '删除员工失败',
|
||||||
|
tagRemoved: '已移除标签「{tag}」',
|
||||||
deleteSuccess: '员工已离职',
|
deleteSuccess: '员工已离职',
|
||||||
toggleFailed: '切换状态失败',
|
toggleFailed: '切换状态失败',
|
||||||
toggleSuccess: '状态已更新',
|
toggleSuccess: '状态已更新',
|
||||||
|
|||||||
@ -349,7 +349,29 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">{{ t('agents.fields.tags') }}</label>
|
<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>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">{{ t('agents.fields.enabled') }}</label>
|
<label class="form-label">{{ t('agents.fields.enabled') }}</label>
|
||||||
@ -637,7 +659,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useRoute, useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { mcToast } from '@/composables/useMcToast'
|
import { mcToast } from '@/composables/useMcToast'
|
||||||
@ -913,6 +935,72 @@ const defaultForm = (): Partial<Agent> & { name: string; defaultThinkingLevel: s
|
|||||||
const form = ref(defaultForm())
|
const form = ref(defaultForm())
|
||||||
const iconPickerVisible = ref(false)
|
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
|
// Identity-triad fields displayed in the basic tab. Kept separate from
|
||||||
// `form.systemPrompt` so the textarea state stays predictable while the
|
// `form.systemPrompt` so the textarea state stays predictable while the
|
||||||
// user types — we only flatten back to a single prompt at save time.
|
// user types — we only flatten back to a single prompt at save time.
|
||||||
@ -1071,6 +1159,8 @@ function openBlankCreateModal() {
|
|||||||
showTemplateSelector.value = false
|
showTemplateSelector.value = false
|
||||||
editingAgent.value = null
|
editingAgent.value = null
|
||||||
form.value = defaultForm()
|
form.value = defaultForm()
|
||||||
|
tagInput.value = ''
|
||||||
|
recentlyRemovedTag.value = null
|
||||||
profileForm.value = emptyProfile()
|
profileForm.value = emptyProfile()
|
||||||
modalTab.value = 'basic'
|
modalTab.value = 'basic'
|
||||||
skillBindingSearch.value = ''
|
skillBindingSearch.value = ''
|
||||||
@ -1152,6 +1242,8 @@ async function openEditModal(agent: Agent) {
|
|||||||
skillsDisabled: agent.skillsDisabled === true,
|
skillsDisabled: agent.skillsDisabled === true,
|
||||||
toolsDisabled: agent.toolsDisabled === true,
|
toolsDisabled: agent.toolsDisabled === true,
|
||||||
}
|
}
|
||||||
|
tagInput.value = ''
|
||||||
|
recentlyRemovedTag.value = null
|
||||||
profileForm.value = parsePrompt(agent.systemPrompt)
|
profileForm.value = parsePrompt(agent.systemPrompt)
|
||||||
modalTab.value = 'basic'
|
modalTab.value = 'basic'
|
||||||
skillBindingSearch.value = ''
|
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; }
|
.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; }
|
.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) */
|
/* 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 { border: 1px dashed var(--mc-border); border-radius: 12px; padding: 0; }
|
||||||
.advanced-tools[open] { padding: 12px 14px; }
|
.advanced-tools[open] { padding: 12px 14px; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user