Files
gochat/frontend/app/javascript/dashboard/components-next/captain/pageComponents/skill/SkillDialog.vue
T
Rogeeandrogee f50eacdc92 H-289: add Captain Skill management UI (#47)
* H-289: add Captain Skill management UI

* H-289: guard Captain Skill assistant switches

---------

Co-authored-by: Rogee <rogee@ipao.vip>
2026-08-18 15:34:26 +08:00

436 lines
11 KiB
Vue

<script setup>
import { computed, nextTick, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
skill: {
type: Object,
default: null,
},
assistantId: {
type: Number,
required: true,
},
});
const emit = defineEmits(['close', 'saved']);
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const isSaving = ref(false);
const errorMessage = ref('');
const errors = reactive({});
const forceClose = ref(false);
const initialForm = () => ({
name: props.skill?.name || '',
description: props.skill?.description || '',
instructions_md: props.skill?.instructions_md || '',
references: (props.skill?.references || [])
.slice()
.sort((a, b) => a.position - b.position)
.map(({ reference_key, content_md }) => ({
reference_key,
content_md,
})),
});
const form = reactive(initialForm());
const originalForm = JSON.stringify(initialForm());
const isEditing = computed(() => Boolean(props.skill?.id));
const currentStatus = computed(() => props.skill?.status || 'draft');
const isDirty = computed(() => JSON.stringify(form) !== originalForm);
const byteLength = value => new TextEncoder().encode(value).length;
const clearErrors = () => {
Object.keys(errors).forEach(key => delete errors[key]);
errorMessage.value = '';
};
const validate = () => {
clearErrors();
if (!form.name.trim() || byteLength(form.name.trim()) > 255) {
errors.name = t('CAPTAIN.ASSISTANTS.SKILLS.FORM.ERRORS.NAME');
}
if (
!form.description.trim() ||
byteLength(form.description.trim()) > 1024
) {
errors.description = t(
'CAPTAIN.ASSISTANTS.SKILLS.FORM.ERRORS.DESCRIPTION'
);
}
if (!form.instructions_md || byteLength(form.instructions_md) > 32 * 1024) {
errors.instructions = t(
'CAPTAIN.ASSISTANTS.SKILLS.FORM.ERRORS.INSTRUCTIONS'
);
}
if (form.references.length > 20) {
errors.references = t(
'CAPTAIN.ASSISTANTS.SKILLS.FORM.ERRORS.REFERENCE_LIMIT'
);
}
const seenKeys = new Set();
form.references.forEach((reference, index) => {
const key = reference.reference_key.trim();
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(key) || seenKeys.has(key)) {
errors[`reference-${index}`] = t(
'CAPTAIN.ASSISTANTS.SKILLS.FORM.ERRORS.REFERENCE_KEY'
);
} else if (
!reference.content_md ||
byteLength(reference.content_md) > 64 * 1024
) {
errors[`reference-${index}`] = t(
'CAPTAIN.ASSISTANTS.SKILLS.FORM.ERRORS.REFERENCE_CONTENT'
);
}
seenKeys.add(key);
});
return Object.keys(errors).length === 0;
};
const payloadFor = status => ({
name: form.name.trim(),
description: form.description.trim(),
instructions_md: form.instructions_md,
status,
references: form.references.map(reference => ({
reference_key: reference.reference_key.trim(),
content_md: reference.content_md,
})),
});
const apiErrorMessage = error => {
const status = error?.response?.status;
if (status === 403) return t('CAPTAIN.ASSISTANTS.SKILLS.ERRORS.FORBIDDEN');
if (status === 404) return t('CAPTAIN.ASSISTANTS.SKILLS.ERRORS.NOT_FOUND');
const parsed = parseAPIErrorResponse(error);
if (status === 409) {
return `${parsed} ${t('CAPTAIN.ASSISTANTS.SKILLS.ERRORS.CONFLICT')}`;
}
return parsed || t('CAPTAIN.ASSISTANTS.SKILLS.ERRORS.DEFAULT');
};
const finishSave = () => {
forceClose.value = true;
emit('saved');
dialogRef.value?.close();
};
const submit = async (status, bindAfterSave = false) => {
if (!validate()) return;
if (
currentStatus.value === 'active' &&
!window.confirm(
t('CAPTAIN.ASSISTANTS.SKILLS.CONFIRM.ACTIVE_SAVE', {
count: props.skill?.bound_assistant_count || 0,
})
)
) {
return;
}
const requestedAssistantId = props.assistantId;
isSaving.value = true;
try {
const payload = payloadFor(status);
const saved = isEditing.value
? await store.dispatch('captainSkills/update', {
id: props.skill.id,
...payload,
expected_version: props.skill.version,
})
: await store.dispatch('captainSkills/create', payload);
if (bindAfterSave) {
try {
await store.dispatch('captainSkills/bind', {
assistantId: requestedAssistantId,
skillId: saved.id,
});
} catch (error) {
finishSave();
useAlert(apiErrorMessage(error));
return;
}
}
useAlert(t('CAPTAIN.ASSISTANTS.SKILLS.SUCCESS.SAVED'));
finishSave();
} catch (error) {
errorMessage.value = apiErrorMessage(error);
} finally {
isSaving.value = false;
}
};
const addReference = () => {
if (form.references.length < 20) {
form.references.push({ reference_key: '', content_md: '' });
}
};
const removeReference = index => form.references.splice(index, 1);
const moveReference = (index, offset) => {
const target = index + offset;
if (target < 0 || target >= form.references.length) return;
const [reference] = form.references.splice(index, 1);
form.references.splice(target, 0, reference);
};
const handleClose = () => {
if (
!forceClose.value &&
isDirty.value &&
!window.confirm(t('CAPTAIN.ASSISTANTS.SKILLS.CONFIRM.DISCARD'))
) {
nextTick(() => dialogRef.value?.open());
return;
}
emit('close');
};
defineExpose({
dialogRef,
isDirty,
form,
errorMessage,
submit,
addReference,
removeReference,
moveReference,
});
</script>
<template>
<Dialog
ref="dialogRef"
width="3xl"
position="top"
overflow-y-auto
:title="
isEditing
? t('CAPTAIN.ASSISTANTS.SKILLS.DIALOG.EDIT_TITLE')
: t('CAPTAIN.ASSISTANTS.SKILLS.DIALOG.CREATE_TITLE')
"
:description="t('CAPTAIN.ASSISTANTS.SKILLS.DIALOG.DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
@close="handleClose"
>
<div class="flex max-h-[65vh] flex-col gap-5 overflow-y-auto px-0.5">
<p
v-if="errorMessage"
class="rounded-lg bg-n-ruby-2 px-3 py-2 text-sm text-n-ruby-11"
aria-live="polite"
>
{{ errorMessage }}
</p>
<Input
v-model="form.name"
:label="t('CAPTAIN.ASSISTANTS.SKILLS.FORM.NAME')"
:message="errors.name"
:message-type="errors.name ? 'error' : 'info'"
:disabled="isSaving"
/>
<TextArea
v-model="form.description"
id="skill-description"
:label="t('CAPTAIN.ASSISTANTS.SKILLS.FORM.DESCRIPTION')"
:message="errors.description"
:message-type="errors.description ? 'error' : 'info'"
:disabled="isSaving"
:max-length="1024"
show-character-count
/>
<Editor
v-model="form.instructions_md"
editor-key="captain-skill-instructions"
:label="t('CAPTAIN.ASSISTANTS.SKILLS.FORM.INSTRUCTIONS')"
:message="errors.instructions"
:message-type="errors.instructions ? 'error' : 'info'"
:disabled="isSaving"
:max-length="32768"
/>
<section
class="flex flex-col gap-3"
aria-labelledby="skill-references"
>
<div class="flex items-center justify-between gap-3">
<div>
<h4
id="skill-references"
class="text-sm font-medium text-n-slate-12"
>
{{ t('CAPTAIN.ASSISTANTS.SKILLS.FORM.REFERENCES') }}
</h4>
<p class="mb-0 text-xs text-n-slate-11">
{{
t(
'CAPTAIN.ASSISTANTS.SKILLS.FORM.REFERENCES_HINT'
)
}}
</p>
</div>
<Button
:label="
t('CAPTAIN.ASSISTANTS.SKILLS.FORM.ADD_REFERENCE')
"
icon="i-lucide-plus"
size="sm"
color="slate"
:disabled="isSaving || form.references.length >= 20"
@click="addReference"
/>
</div>
<p v-if="errors.references" class="mb-0 text-sm text-n-ruby-11">
{{ errors.references }}
</p>
<div
v-for="(reference, index) in form.references"
:key="index"
class="flex flex-col gap-3 rounded-xl border border-n-weak p-4"
>
<div class="flex items-start gap-2">
<Input
v-model="reference.reference_key"
class="flex-1"
:label="
t(
'CAPTAIN.ASSISTANTS.SKILLS.FORM.REFERENCE_KEY'
)
"
:message="errors[`reference-${index}`]"
:message-type="
errors[`reference-${index}`] ? 'error' : 'info'
"
:disabled="isSaving"
placeholder="refund-policy"
/>
<div class="flex gap-1 pt-7">
<Button
icon="i-lucide-arrow-up"
color="slate"
size="xs"
:aria-label="
t('CAPTAIN.ASSISTANTS.SKILLS.FORM.MOVE_UP')
"
:disabled="isSaving || index === 0"
@click="moveReference(index, -1)"
/>
<Button
icon="i-lucide-arrow-down"
color="slate"
size="xs"
:aria-label="
t(
'CAPTAIN.ASSISTANTS.SKILLS.FORM.MOVE_DOWN'
)
"
:disabled="
isSaving ||
index === form.references.length - 1
"
@click="moveReference(index, 1)"
/>
<Button
icon="i-lucide-trash"
color="ruby"
size="xs"
:aria-label="
t(
'CAPTAIN.ASSISTANTS.SKILLS.FORM.REMOVE_REFERENCE'
)
"
:disabled="isSaving"
@click="removeReference(index)"
/>
</div>
</div>
<TextArea
v-model="reference.content_md"
:id="`skill-reference-${index}`"
:label="
t(
'CAPTAIN.ASSISTANTS.SKILLS.FORM.REFERENCE_CONTENT'
)
"
:disabled="isSaving"
resize
/>
</div>
</section>
</div>
<template #footer>
<div
class="flex flex-wrap items-center justify-end gap-3 border-t border-n-weak pt-4"
>
<Button
:label="t('DIALOG.BUTTONS.CANCEL')"
variant="faded"
color="slate"
:disabled="isSaving"
@click="dialogRef.close()"
/>
<template v-if="currentStatus === 'active'">
<Button
:label="
t('CAPTAIN.ASSISTANTS.SKILLS.ACTIONS.SAVE_CHANGES')
"
:is-loading="isSaving"
:disabled="isSaving"
@click="submit('active')"
/>
</template>
<template v-else-if="currentStatus === 'archived'">
<Button
:label="
t(
'CAPTAIN.ASSISTANTS.SKILLS.ACTIONS.REACTIVATE_ADD'
)
"
:is-loading="isSaving"
:disabled="isSaving"
@click="submit('active', true)"
/>
</template>
<template v-else>
<Button
:label="
t('CAPTAIN.ASSISTANTS.SKILLS.ACTIONS.SAVE_DRAFT')
"
color="slate"
:disabled="isSaving"
@click="submit('draft')"
/>
<Button
:label="
t('CAPTAIN.ASSISTANTS.SKILLS.ACTIONS.PUBLISH_ADD')
"
:is-loading="isSaving"
:disabled="isSaving"
@click="submit('active', true)"
/>
</template>
</div>
</template>
</Dialog>
</template>