162abdc97c
- Add Prettier 3 with prettier-plugin-organize-imports (sorts/removes unused imports) - Add ESLint 10 flat config with typescript-eslint + eslint-plugin-vue + eslint-config-prettier - Add root scripts: format, format:check, lint, lint:fix - Reformat 257 files across admin-web, ops-web, desktop-client, packages - Remove unused locals/exports flagged by --noUnusedLocals/--noUnusedParameters - Fix duplicate localTabLabel key, surrogate-pair regex u-flag, NBSP literal in regex - Skip server/ (Go) and apps/browser-extension/ (deprecated per ADR) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
317 lines
8.3 KiB
Vue
317 lines
8.3 KiB
Vue
<script setup lang="ts">
|
|
import { EditOutlined, LoadingOutlined, UserOutlined } from '@ant-design/icons-vue'
|
|
import type { KolProfile } from '@geo/shared-types'
|
|
import { useMutation, useQueryClient } from '@tanstack/vue-query'
|
|
import { message } from 'ant-design-vue'
|
|
import { computed, ref } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
import { kolManageApi, resolveApiURL } 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(() => resolveApiURL(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>
|