The project now has exactly one UI library. Every naive-ui component in
packages/client/src is replaced by its Arco equivalent across 24 views and
roughly 80 components, and naive-ui is dropped from package.json along with
its transitive vueuc / @css-render / vdirs / seemly dependencies.
The migration is rendering-layer only. No store logic, no API contract, and
no /chat-run payload was touched.
Three classes of leftover were invisible to `grep naive-ui` and are now
covered by tests/client/no-naive-ui.test.ts, which asserts all three:
- dead .n-* class names in style blocks
- dead --n-* custom properties
- naive-ui in package.json
Notable behavioural traps handled along the way, recorded in
docs/arco-migration-rules.md:
- a-input-password defaults to masked; passing default-visibility="false"
would have exposed API keys in clear text
- a-upload fires a real XHR unless auto-upload is false
- nested Trigger components fight over the first element child, so the
group-chat settings dropdown was flattened
- Arco renders tooltip default-slot text inline, which was silently
collapsing the chat and group-chat toolbars
258 lines
8.7 KiB
Vue
258 lines
8.7 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useMessage } from '@/composables/useMessage'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { useKanbanStore } from '@/stores/hermes/kanban'
|
|
import { withDefaultAssignee } from '@/utils/hermes/kanban-assignees'
|
|
import { fetchSkills } from '@/api/hermes/skills'
|
|
import type { SkillInfo } from '@/api/hermes/skills'
|
|
|
|
const emit = defineEmits<{
|
|
close: []
|
|
created: []
|
|
}>()
|
|
|
|
const { t } = useI18n()
|
|
const message = useMessage()
|
|
const kanbanStore = useKanbanStore()
|
|
|
|
const title = ref('')
|
|
const body = ref('')
|
|
const assignee = ref<string | null>(null)
|
|
const priority = ref<number | null>(null)
|
|
const tenant = ref('')
|
|
const triage = ref(false)
|
|
const workspaceKind = ref<'scratch' | 'dir' | 'worktree'>('scratch')
|
|
const workspacePath = ref('')
|
|
const branch = ref('')
|
|
const skills = ref<string[]>([])
|
|
const maxRuntime = ref('')
|
|
const maxRetries = ref<number | null>(null)
|
|
const goalMode = ref(false)
|
|
const goalMaxTurns = ref<number | null>(null)
|
|
const saving = ref(false)
|
|
const skillsLoading = ref(false)
|
|
const skillOptions = ref<Array<{ label: string; value: string }>>([])
|
|
|
|
// a-form 的 model 是必填 prop;这里的字段本来就各自是独立的 ref,
|
|
// 收集成一个对象只是为了满足 Arco 的类型要求,不改变任何提交逻辑。
|
|
const formModel = computed(() => ({
|
|
title: title.value,
|
|
body: body.value,
|
|
assignee: assignee.value,
|
|
priority: priority.value,
|
|
tenant: tenant.value,
|
|
workspaceKind: workspaceKind.value,
|
|
workspacePath: workspacePath.value,
|
|
branch: branch.value,
|
|
skills: skills.value,
|
|
maxRuntime: maxRuntime.value,
|
|
maxRetries: maxRetries.value,
|
|
goalMaxTurns: goalMaxTurns.value,
|
|
}))
|
|
|
|
const priorityOptions = computed(() => [
|
|
{ label: t('kanban.card.priority.low'), value: 1 },
|
|
{ label: t('kanban.card.priority.medium'), value: 2 },
|
|
{ label: t('kanban.card.priority.high'), value: 3 },
|
|
])
|
|
|
|
const assigneeOptions = computed(() => {
|
|
return withDefaultAssignee(kanbanStore.assignees, kanbanStore.stats?.by_assignee || {})
|
|
.map(a => ({ label: a.name, value: a.name }))
|
|
})
|
|
|
|
const workspaceOptions = computed(() => [
|
|
{ label: t('kanban.form.workspaceScratch'), value: 'scratch' },
|
|
{ label: t('kanban.form.workspaceDir'), value: 'dir' },
|
|
{ label: t('kanban.form.workspaceWorktree'), value: 'worktree' },
|
|
])
|
|
|
|
function workspaceValue(): string | undefined {
|
|
if (workspaceKind.value === 'scratch') return undefined
|
|
const path = workspacePath.value.trim()
|
|
if (!path) return workspaceKind.value
|
|
return `${workspaceKind.value}:${path}`
|
|
}
|
|
|
|
function skillList(): string[] | undefined {
|
|
return skills.value.length ? skills.value : undefined
|
|
}
|
|
|
|
function buildSkillOptions(skills: SkillInfo[]): Array<{ label: string; value: string }> {
|
|
const byName = new Map<string, SkillInfo>()
|
|
for (const skill of skills) {
|
|
if (skill.enabled === false) continue
|
|
if (!byName.has(skill.name)) byName.set(skill.name, skill)
|
|
}
|
|
return [...byName.values()]
|
|
.map(skill => ({ label: skill.name, value: skill.name }))
|
|
.sort((a, b) => a.label.localeCompare(b.label))
|
|
}
|
|
|
|
onMounted(async () => {
|
|
skillsLoading.value = true
|
|
try {
|
|
const data = await fetchSkills()
|
|
skillOptions.value = buildSkillOptions(data.categories.flatMap(category => category.skills || []))
|
|
} catch {
|
|
skillOptions.value = []
|
|
} finally {
|
|
skillsLoading.value = false
|
|
}
|
|
})
|
|
|
|
async function handleSubmit() {
|
|
if (!title.value.trim()) {
|
|
message.warning(t('kanban.form.titleRequired'))
|
|
return
|
|
}
|
|
saving.value = true
|
|
try {
|
|
const payload: Parameters<typeof kanbanStore.createTask>[0] = {
|
|
title: title.value.trim(),
|
|
body: body.value.trim() || undefined,
|
|
assignee: assignee.value || undefined,
|
|
priority: priority.value ?? undefined,
|
|
}
|
|
const tenantValue = tenant.value.trim()
|
|
const selectedWorkspace = workspaceValue()
|
|
const branchValue = branch.value.trim()
|
|
const selectedSkills = skillList()
|
|
const runtimeValue = maxRuntime.value.trim()
|
|
if (tenantValue) payload.tenant = tenantValue
|
|
if (triage.value) payload.triage = true
|
|
if (selectedWorkspace) payload.workspace = selectedWorkspace
|
|
if (workspaceKind.value === 'worktree' && branchValue) payload.branch = branchValue
|
|
if (selectedSkills) payload.skills = selectedSkills
|
|
if (runtimeValue) payload.maxRuntime = runtimeValue
|
|
if (maxRetries.value !== null) payload.maxRetries = maxRetries.value
|
|
if (goalMode.value) {
|
|
payload.goalMode = true
|
|
if (goalMaxTurns.value !== null) payload.goalMaxTurns = goalMaxTurns.value
|
|
}
|
|
await kanbanStore.createTask(payload)
|
|
message.success(t('kanban.message.taskCreated'))
|
|
emit('created')
|
|
emit('close')
|
|
} catch (err: any) {
|
|
message.error(err.message)
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<a-modal
|
|
:visible="true"
|
|
:title="t('kanban.createTask')"
|
|
:style="{ width: '480px' }"
|
|
:mask-closable="false"
|
|
:esc-to-close="false"
|
|
@cancel="emit('close')"
|
|
>
|
|
<a-form :model="formModel" layout="vertical">
|
|
<a-form-item :label="t('kanban.form.title')">
|
|
<a-input v-model="title" :placeholder="t('kanban.form.titlePlaceholder')" />
|
|
</a-form-item>
|
|
<a-form-item :label="t('kanban.form.body')">
|
|
<a-textarea v-model="body" :textarea-attrs="{ rows: 3 }" :placeholder="t('kanban.form.bodyPlaceholder')" />
|
|
</a-form-item>
|
|
<a-form-item :label="t('kanban.form.assignee')">
|
|
<a-select
|
|
:model-value="assignee ?? undefined"
|
|
:options="assigneeOptions"
|
|
:placeholder="t('kanban.form.selectAssignee')"
|
|
allow-clear
|
|
@change="value => assignee = (value as string) ?? null"
|
|
/>
|
|
</a-form-item>
|
|
<a-form-item :label="t('kanban.form.priority')">
|
|
<a-select
|
|
:model-value="priority ?? undefined"
|
|
:options="priorityOptions"
|
|
:placeholder="t('kanban.form.selectPriority')"
|
|
allow-clear
|
|
@change="value => priority = (value as number) ?? null"
|
|
/>
|
|
</a-form-item>
|
|
<a-form-item :label="t('kanban.form.tenant')">
|
|
<a-input v-model="tenant" :placeholder="t('kanban.form.tenantPlaceholder')" />
|
|
</a-form-item>
|
|
<a-form-item :label="t('kanban.form.workspace')">
|
|
<a-select v-model="workspaceKind" :options="workspaceOptions" />
|
|
</a-form-item>
|
|
<a-form-item v-if="workspaceKind !== 'scratch'" :label="t('kanban.form.workspacePath')">
|
|
<a-input v-model="workspacePath" :placeholder="t('kanban.form.workspacePathPlaceholder')" />
|
|
</a-form-item>
|
|
<a-form-item v-if="workspaceKind === 'worktree'" :label="t('kanban.form.branch')">
|
|
<a-input v-model="branch" :placeholder="t('kanban.form.branchPlaceholder')" />
|
|
</a-form-item>
|
|
<a-form-item :label="t('kanban.form.skills')">
|
|
<a-select
|
|
v-model="skills"
|
|
multiple
|
|
allow-search
|
|
allow-clear
|
|
:loading="skillsLoading"
|
|
:options="skillOptions"
|
|
:placeholder="t('kanban.form.skillsPlaceholder')"
|
|
/>
|
|
</a-form-item>
|
|
<div class="advanced-row">
|
|
<a-checkbox v-model="triage">{{ t('kanban.form.triage') }}</a-checkbox>
|
|
<a-checkbox v-model="goalMode">{{ t('kanban.form.goalMode') }}</a-checkbox>
|
|
</div>
|
|
<div class="advanced-grid">
|
|
<a-form-item :label="t('kanban.form.maxRuntime')">
|
|
<a-input v-model="maxRuntime" :placeholder="t('kanban.form.maxRuntimePlaceholder')" />
|
|
</a-form-item>
|
|
<a-form-item :label="t('kanban.form.maxRetries')">
|
|
<a-input-number
|
|
:model-value="maxRetries ?? undefined"
|
|
:min="1"
|
|
:max="100"
|
|
:precision="0"
|
|
:placeholder="t('kanban.form.maxRetriesPlaceholder')"
|
|
allow-clear
|
|
style="width: 100%"
|
|
@change="value => maxRetries = value ?? null"
|
|
/>
|
|
</a-form-item>
|
|
<a-form-item v-if="goalMode" :label="t('kanban.form.goalMaxTurns')">
|
|
<a-input-number
|
|
:model-value="goalMaxTurns ?? undefined"
|
|
:min="1"
|
|
:max="100"
|
|
:precision="0"
|
|
:placeholder="t('kanban.form.goalMaxTurnsPlaceholder')"
|
|
allow-clear
|
|
style="width: 100%"
|
|
@change="value => goalMaxTurns = value ?? null"
|
|
/>
|
|
</a-form-item>
|
|
</div>
|
|
</a-form>
|
|
<template #footer>
|
|
<a-button @click="emit('close')">{{ t('common.cancel') }}</a-button>
|
|
<a-button type="primary" :loading="saving" @click="handleSubmit">{{ t('common.create') }}</a-button>
|
|
</template>
|
|
</a-modal>
|
|
</template>
|
|
|
|
<style scoped lang="scss">
|
|
.advanced-row {
|
|
display: flex;
|
|
gap: 16px;
|
|
margin: 0 0 12px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.advanced-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 10px;
|
|
}
|
|
</style>
|