* H-289: add Captain Skill management UI * H-289: guard Captain Skill assistant switches --------- Co-authored-by: Rogee <rogee@ipao.vip>
436 lines
11 KiB
Vue
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>
|