feat(kol-web): add KolPersonaCard with inline editing

This commit is contained in:
2026-04-18 14:09:20 +08:00
parent 4026aa7dad
commit 4ac7339c2d
@@ -0,0 +1,308 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { useMutation, useQueryClient } from "@tanstack/vue-query";
import { message } from "ant-design-vue";
import { EditOutlined, UserOutlined, LoadingOutlined } from "@ant-design/icons-vue";
import type { KolProfile } from "@geo/shared-types";
import { kolManageApi } from "@/lib/api";
import { formatError } from "@/lib/errors";
const props = defineProps<{
profile: KolProfile;
}>();
const { t } = useI18n();
const queryClient = useQueryClient();
const editing = ref<"display_name" | "bio" | null>(null);
const draftDisplayName = ref(props.profile.display_name);
const draftBio = ref(props.profile.bio ?? "");
const avatarUploading = ref(false);
const fileInput = ref<HTMLInputElement | null>(null);
const avatarURL = computed(() => props.profile.avatar_url ?? "");
const updateMutation = useMutation({
mutationFn: kolManageApi.updateProfile,
onSuccess: async () => {
message.success(t("kol.profile.persona.saveSuccess"));
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["kol", "profile"] }),
queryClient.invalidateQueries({ queryKey: ["workspace", "kol-cards"] }),
queryClient.invalidateQueries({ queryKey: ["kol", "packages"] }),
]);
editing.value = null;
},
onError: (error) => {
message.error(formatError(error) || t("kol.profile.persona.saveError"));
draftDisplayName.value = props.profile.display_name;
draftBio.value = props.profile.bio ?? "";
},
});
function saveFields(patch: { display_name?: string; bio?: string | null; avatar_url?: string | null }) {
updateMutation.mutate({
display_name: patch.display_name ?? props.profile.display_name,
bio: patch.bio !== undefined ? patch.bio : props.profile.bio,
avatar_url: patch.avatar_url !== undefined ? patch.avatar_url : props.profile.avatar_url,
});
}
function commitDisplayName() {
const next = draftDisplayName.value.trim();
if (!next) {
message.error(t("kol.profile.persona.displayNameRequired"));
return;
}
if (next.length > 40) {
message.error(t("kol.profile.persona.displayNameTooLong"));
return;
}
if (next === props.profile.display_name) {
editing.value = null;
return;
}
saveFields({ display_name: next });
}
function commitBio() {
const next = draftBio.value.trim();
if (next.length > 500) {
message.error(t("kol.profile.persona.bioTooLong"));
return;
}
const nextVal = next === "" ? null : next;
if (nextVal === (props.profile.bio ?? null)) {
editing.value = null;
return;
}
saveFields({ bio: nextVal });
}
function cancelEdit() {
draftDisplayName.value = props.profile.display_name;
draftBio.value = props.profile.bio ?? "";
editing.value = null;
}
function startDisplayNameEdit() {
draftDisplayName.value = props.profile.display_name;
editing.value = "display_name";
}
function startBioEdit() {
draftBio.value = props.profile.bio ?? "";
editing.value = "bio";
}
function triggerAvatarPicker() {
fileInput.value?.click();
}
async function handleFilePicked(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (!file) return;
if (!/^image\/(png|jpe?g|gif|webp)$/i.test(file.type)) {
message.error(t("kol.profile.persona.avatarInvalidType"));
return;
}
if (file.size > 2 * 1024 * 1024) {
message.error(t("kol.profile.persona.avatarTooLarge"));
return;
}
avatarUploading.value = true;
try {
const uploaded = await kolManageApi.uploadAvatar(file);
saveFields({ avatar_url: uploaded.url });
} catch (error) {
message.error(formatError(error) || t("kol.profile.persona.saveError"));
} finally {
avatarUploading.value = false;
}
}
</script>
<template>
<section class="persona-card">
<div class="persona-avatar-wrap" :class="{ uploading: avatarUploading }" @click="triggerAvatarPicker">
<template v-if="avatarURL">
<img :src="avatarURL" class="persona-avatar" alt="" />
</template>
<template v-else>
<div class="persona-avatar persona-avatar-fallback">
<UserOutlined />
</div>
</template>
<div class="persona-avatar-mask">
<LoadingOutlined v-if="avatarUploading" />
<span v-else>{{ t("kol.profile.persona.avatarLabel") }}</span>
</div>
<input
ref="fileInput"
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
class="persona-avatar-input"
@change="handleFilePicked"
/>
</div>
<div class="persona-fields">
<div class="persona-field">
<label class="persona-field-label">{{ t("kol.profile.persona.displayNameLabel") }}</label>
<template v-if="editing === 'display_name'">
<a-input
v-model:value="draftDisplayName"
:disabled="updateMutation.isPending.value"
:placeholder="t('kol.profile.persona.displayNamePlaceholder')"
:max-length="40"
allow-clear
@press-enter="commitDisplayName"
@blur="commitDisplayName"
@keyup.esc="cancelEdit"
/>
</template>
<template v-else>
<div class="persona-field-static" @click="startDisplayNameEdit">
<span class="persona-field-value">{{ profile.display_name }}</span>
<EditOutlined class="persona-field-edit-icon" />
</div>
</template>
</div>
<div class="persona-field">
<label class="persona-field-label">{{ t("kol.profile.persona.bioLabel") }}</label>
<template v-if="editing === 'bio'">
<a-textarea
v-model:value="draftBio"
:disabled="updateMutation.isPending.value"
:placeholder="t('kol.profile.persona.bioPlaceholder')"
:auto-size="{ minRows: 2, maxRows: 6 }"
:max-length="500"
show-count
@blur="commitBio"
@keydown.ctrl.enter.prevent="commitBio"
@keydown.meta.enter.prevent="commitBio"
@keyup.esc="cancelEdit"
/>
</template>
<template v-else>
<div class="persona-field-static persona-field-static-multiline" @click="startBioEdit">
<span class="persona-field-value persona-field-value-bio">
{{ profile.bio || t("kol.profile.persona.bioPlaceholder") }}
</span>
<EditOutlined class="persona-field-edit-icon" />
</div>
</template>
</div>
<p class="persona-avatar-hint">{{ t("kol.profile.persona.avatarHint") }}</p>
</div>
</section>
</template>
<style scoped>
.persona-card {
display: flex;
align-items: flex-start;
gap: 32px;
background: #ffffff;
border-radius: 12px;
padding: 24px;
}
.persona-avatar-wrap {
position: relative;
width: 120px;
height: 120px;
border-radius: 50%;
overflow: hidden;
cursor: pointer;
flex-shrink: 0;
background: #f5f5f5;
}
.persona-avatar,
.persona-avatar-fallback {
width: 100%;
height: 100%;
object-fit: cover;
display: flex;
align-items: center;
justify-content: center;
font-size: 40px;
color: #bfbfbf;
}
.persona-avatar-mask {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.45);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
opacity: 0;
transition: opacity 0.15s ease;
}
.persona-avatar-wrap:hover .persona-avatar-mask,
.persona-avatar-wrap.uploading .persona-avatar-mask {
opacity: 1;
}
.persona-avatar-input {
display: none;
}
.persona-fields {
flex: 1;
display: flex;
flex-direction: column;
gap: 20px;
min-width: 0;
}
.persona-field-label {
display: block;
color: #8c8c8c;
font-size: 12px;
margin-bottom: 6px;
}
.persona-field-static {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: text;
padding: 2px 0;
max-width: 100%;
}
.persona-field-static-multiline {
align-items: flex-start;
}
.persona-field-value {
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
word-break: break-word;
}
.persona-field-value-bio {
font-size: 14px;
font-weight: 400;
color: #595959;
white-space: pre-wrap;
}
.persona-field-edit-icon {
color: #bfbfbf;
font-size: 14px;
opacity: 0;
transition: opacity 0.15s ease;
}
.persona-field-static:hover .persona-field-edit-icon {
opacity: 1;
}
.persona-avatar-hint {
margin: 0;
color: #bfbfbf;
font-size: 12px;
}
</style>