Files
geo/apps/admin-web/src/components/kol/KolPackageFormModal.vue
T

336 lines
8.7 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { CloseOutlined, DeleteOutlined, PictureOutlined } from '@ant-design/icons-vue'
import type { CreateKolPackageRequest } from '@geo/shared-types'
import { message } from 'ant-design-vue'
import { computed, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import CoverPickerModal from '@/components/CoverPickerModal.vue'
import { imagesApi, resolveApiURL } from '@/lib/api'
import { deriveCoverFileName } from '@/lib/cover-requirements'
const props = defineProps<{
open: boolean
initialValue?: Partial<CreateKolPackageRequest>
}>()
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
(e: 'submit', value: CreateKolPackageRequest): void
}>()
const { t } = useI18n()
const coverPickerOpen = ref(false)
const formState = reactive<CreateKolPackageRequest>({
name: '',
description: '',
industry: '',
tags: [],
price_note: '',
cover_url: '',
})
watch(
() => props.open,
(isOpen) => {
if (isOpen) {
Object.assign(formState, {
name: props.initialValue?.name || '',
description: props.initialValue?.description || '',
industry: props.initialValue?.industry || '',
tags: [...(props.initialValue?.tags || [])],
price_note: props.initialValue?.price_note || '',
cover_url: props.initialValue?.cover_url || '',
})
return
}
coverPickerOpen.value = false
},
)
const coverPreviewUrl = computed(() => resolveApiURL(formState.cover_url))
const coverFileName = computed(() => deriveCoverFileName(formState.cover_url))
const tagOptions = computed(() =>
Array.from(new Set(formState.tags.map((tag) => tag.trim()).filter(Boolean))).map((tag) => ({
label: tag,
value: tag,
title: null,
})),
)
function handleOk() {
if (!formState.name.trim()) {
message.warning(t('kol.manage.packageForm.nameRequired'))
return
}
emit('submit', { ...formState, tags: [...formState.tags] })
emit('update:open', false)
}
function handleCancel() {
emit('update:open', false)
}
function handleRemoveCover() {
formState.cover_url = ''
}
function getModalPopupContainer(triggerNode: HTMLElement): HTMLElement {
return (triggerNode.closest('.ant-modal-content') as HTMLElement | null) ?? document.body
}
function preventTagMouseDown(event: MouseEvent): void {
event.preventDefault()
event.stopPropagation()
}
function handleTagClose(onClose: (event?: MouseEvent) => void, event: MouseEvent): void {
event.preventDefault()
event.stopPropagation()
onClose(event)
}
function handleCoverConfirmed(payload: { url: string }) {
formState.cover_url = resolveApiURL(payload.url)
}
async function uploadCover(file: File) {
const uploaded = await imagesApi.upload(file)
return {
url: uploaded.url,
fileName: uploaded.name,
assetId: uploaded.id,
}
}
</script>
<template>
<a-modal
:open="open"
:title="initialValue ? t('kol.manage.editPackage') : t('kol.manage.createPackage')"
:ok-text="t('common.confirm')"
:cancel-text="t('common.cancel')"
@ok="handleOk"
@cancel="handleCancel"
>
<a-form layout="vertical">
<a-form-item :label="t('common.title')" required>
<a-input v-model:value="formState.name" :placeholder="t('common.inputPlease')" />
</a-form-item>
<a-form-item :label="t('common.description')">
<a-textarea v-model:value="formState.description" :rows="3" />
</a-form-item>
<a-form-item :label="t('kol.marketplace.filter.industry')">
<a-input v-model:value="formState.industry" />
</a-form-item>
<a-form-item :label="t('kol.manage.packageForm.tags')">
<a-select
v-model:value="formState.tags"
mode="tags"
style="width: 100%"
:options="tagOptions"
:placeholder="t('kol.manage.packageForm.tagsPlaceholder')"
:get-popup-container="getModalPopupContainer"
popup-class-name="kol-package-tags-dropdown"
>
<template #tagRender="{ label, closable, onClose }">
<span class="package-tag" @mousedown="preventTagMouseDown">
<span class="package-tag__label">{{ label }}</span>
<button
v-if="closable"
type="button"
class="package-tag__remove"
:aria-label="t('common.delete')"
@click="handleTagClose(onClose, $event)"
>
<CloseOutlined />
</button>
</span>
</template>
<template #option="{ label }">
<span class="package-tag-option">{{ label }}</span>
</template>
</a-select>
</a-form-item>
<a-form-item :label="t('kol.manage.packageForm.priceNote')">
<a-input
v-model:value="formState.price_note"
:placeholder="t('kol.manage.packageForm.priceNotePlaceholder')"
/>
</a-form-item>
<a-form-item :label="t('kol.manage.packageForm.cover')">
<div class="package-cover-field">
<button type="button" class="package-cover-picker" @click="coverPickerOpen = true">
<template v-if="coverPreviewUrl">
<img
:src="coverPreviewUrl"
:alt="t('kol.manage.packageForm.cover')"
class="package-cover-picker__image"
/>
</template>
<template v-else>
<div class="package-cover-picker__empty">
<PictureOutlined class="package-cover-picker__icon" />
<strong>{{ t('kol.manage.packageForm.selectCover') }}</strong>
<span>{{ t('kol.manage.packageForm.coverHint') }}</span>
</div>
</template>
</button>
<div class="package-cover-actions">
<a-button @click="coverPickerOpen = true">
{{
coverPreviewUrl
? t('kol.manage.packageForm.changeCover')
: t('kol.manage.packageForm.selectCover')
}}
</a-button>
<a-button v-if="coverPreviewUrl" danger @click="handleRemoveCover">
<template #icon><DeleteOutlined /></template>
{{ t('kol.manage.packageForm.removeCover') }}
</a-button>
</div>
</div>
</a-form-item>
</a-form>
</a-modal>
<CoverPickerModal
v-model:open="coverPickerOpen"
:article-id="null"
:platform-ids="[]"
:current-url="coverPreviewUrl"
:current-file-name="coverFileName"
:title="t('kol.manage.packageForm.coverPicker.title')"
:local-tab-label="t('kol.manage.packageForm.coverPicker.localTab')"
:empty-upload-title="t('kol.manage.packageForm.coverPicker.emptyTitle')"
:empty-upload-hint="t('kol.manage.packageForm.coverPicker.emptyHint')"
:upload-handler="uploadCover"
@confirmed="handleCoverConfirmed"
/>
</template>
<style scoped>
.package-cover-field {
display: flex;
flex-direction: column;
gap: 12px;
}
.package-cover-picker {
width: 100%;
min-height: 180px;
border: 1px dashed #d0d5dd;
border-radius: 12px;
padding: 0;
overflow: hidden;
background: #f8fafc;
cursor: pointer;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.package-cover-picker:hover {
border-color: #1677ff;
box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.08);
}
.package-cover-picker__image {
display: block;
width: 100%;
height: 180px;
object-fit: cover;
}
.package-cover-picker__empty {
display: flex;
min-height: 180px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 20px;
color: #475467;
text-align: center;
}
.package-cover-picker__empty strong {
color: #111827;
font-size: 15px;
font-weight: 700;
}
.package-cover-picker__empty span {
max-width: 320px;
font-size: 12px;
line-height: 1.6;
}
.package-cover-picker__icon {
font-size: 28px;
color: #1677ff;
}
.package-cover-actions {
display: flex;
gap: 8px;
}
.package-tag {
display: inline-flex;
max-width: 100%;
align-items: center;
gap: 4px;
margin-inline-end: 4px;
padding: 1px 4px 1px 8px;
border: 1px solid #d9d9d9;
border-radius: 4px;
background: #f5f5f5;
color: #1f2937;
line-height: 20px;
}
.package-tag__label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.package-tag__remove {
display: inline-flex;
width: 16px;
height: 16px;
align-items: center;
justify-content: center;
border: 0;
padding: 0;
background: transparent;
color: #667085;
cursor: pointer;
}
.package-tag__remove:hover {
color: #1f2937;
}
:global(.kol-package-tags-dropdown .ant-select-item-option) {
max-width: 100%;
}
:global(.kol-package-tags-dropdown .ant-select-item-option-content),
.package-tag-option {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>