import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { useRouter } from 'vue-router' import { fetchFileBlobByURL, uploadFile } from '@/shared/api/files' import { emptyListingPublishAgreements, emptyListingPublishOptions, emptyListingSalePriceConfig, fetchListingPublishAgreements, fetchListingPublishOptions, fetchListingSalePriceConfig, type ChargeMode, type ListingPublishAgreements, type ListingPublishOptions, type PublishSalePriceConfig, type ScreenshotKey, } from '@/features/listings/api/listingOptions' import { createListing } from '@/features/listings/api/listings' import { usePricingCalculator } from '@/shared/composables/usePricingCalculator' import { buildPublishDraft, clearRecord, defaultPublishForm, readPublishDraft, removePublishDraft, writePublishDraft, } from '@/features/seller/composables/usePublishDraft' import type { PublishForm } from '@/types/publish' import { commonOnlineTimes, dailyLossOptions, formatNumber, roundRatio } from '@/utils/pricing' const draftSaveDelay = 400 const multiScreenshotLimit = 3 const multiScreenshotKeys = new Set(['tencentSecurity', 'skin']) interface UsePublishFormOptions { draftKey: string submitSuccessPath: string persistBeforeUnload?: boolean confirmReset?: () => Promise notifySuccess: (message: string) => void notifyWarning: (message: string) => void notifyError: (message: string) => void } export function usePublishForm(options: UsePublishFormOptions) { const router = useRouter() const loading = ref(false) const uploading = ref(false) const suppressDraftSave = ref(false) const draftReady = ref(false) const publishOptions = ref(emptyListingPublishOptions) const salePriceConfig = ref(emptyListingSalePriceConfig) const publishAgreements = ref(emptyListingPublishAgreements) const virtualAssetSaleAgreementChecked = ref(false) const sellerAgreementChecked = ref(false) const fileInput = ref(null) const activeUploadKey = ref('coin') const form = reactive(defaultPublishForm()) const quantityValues = reactive>({}) const quantityModes = reactive>({}) const screenshotFiles = reactive>({}) const screenshotPreviews = reactive>({}) const selectedSkins = ref([]) let draftSaveTimer: number | undefined const pricing = usePricingCalculator({ form, publishOptions, salePriceConfig, quantityValues, quantityModes, screenshotFiles, selectedSkins, }) const uploadedScreenshotCount = computed( () => pricing.screenshotSlots.value.filter(item => getScreenshotCount(item.key) > 0).length ) const requiredScreenshotCount = ref(0) const canPublishAfterAgreements = computed( () => virtualAssetSaleAgreementChecked.value && sellerAgreementChecked.value ) const disabledOnlineStartOptions = computed(() => commonOnlineTimes.filter(time => !canUseOnlineStart(time)) ) const disabledOnlineEndOptions = computed(() => commonOnlineTimes.filter(time => !canUseOnlineEnd(time)) ) onMounted(() => { restoreDraft() draftReady.value = true if (options.persistBeforeUnload) window.addEventListener('beforeunload', handleBeforeUnload) loadPublishOptions() }) onBeforeUnmount(() => { saveDraft({ force: true }) if (options.persistBeforeUnload) window.removeEventListener('beforeunload', handleBeforeUnload) revokeAllScreenshotPreviews() }) watch( [form, quantityValues, quantityModes, screenshotFiles, selectedSkins], () => { saveDraft() }, { deep: true } ) watch( pricing.recommendedDepositAmount, () => { syncRecommendedDeposit() }, { immediate: true } ) watch( [() => form.season_insurance, pricing.quantityItems], () => { clearForbiddenQuantityItems() }, { deep: true } ) watch( [pricing.screenshotSlots, () => form.ban_record], () => { requiredScreenshotCount.value = pricing.screenshotSlots.value.filter(item => isScreenshotRequired(item) ).length }, { immediate: true, deep: true } ) async function loadPublishOptions() { try { const [nextPublishOptions, nextSalePriceConfig, nextPublishAgreements] = await Promise.all([ fetchListingPublishOptions(), fetchListingSalePriceConfig(), fetchListingPublishAgreements(), ]) publishOptions.value = nextPublishOptions salePriceConfig.value = nextSalePriceConfig publishAgreements.value = nextPublishAgreements // 默认数字都是0 for (const item of nextPublishOptions.quantity_items) { if (quantityValues[item.key] === undefined) { quantityValues[item.key] = 0 } } } catch { publishOptions.value = emptyListingPublishOptions salePriceConfig.value = emptyListingSalePriceConfig publishAgreements.value = emptyListingPublishAgreements } } function saveDraft(saveOptions: { force?: boolean } = {}) { if (suppressDraftSave.value) { clearDraftSaveTimer() return } if (!saveOptions.force && !draftReady.value) return if (!saveOptions.force) { scheduleDraftSave() return } clearDraftSaveTimer() writeDraft() } function scheduleDraftSave() { clearDraftSaveTimer() draftSaveTimer = window.setTimeout(() => { draftSaveTimer = undefined writeDraft() }, draftSaveDelay) } function clearDraftSaveTimer() { if (draftSaveTimer === undefined) return window.clearTimeout(draftSaveTimer) draftSaveTimer = undefined } function writeDraft() { writePublishDraft( options.draftKey, buildPublishDraft({ form, quantityValues, quantityModes, screenshotFiles, selectedSkins: selectedSkins.value, }) ) } function handleSaveDraft() { saveDraft({ force: true }) options.notifySuccess('草稿已保存') } function handleBeforeUnload() { saveDraft({ force: true }) } function restoreDraft() { const draft = readPublishDraft(options.draftKey) if (!draft) return Object.assign(form, draft.form) normalizeOnlineTime() clearRecord(quantityValues) clearRecord(quantityModes) clearRecord(screenshotFiles) Object.assign(quantityValues, draft.quantityValues) Object.assign(quantityModes, draft.quantityModes) Object.assign(screenshotFiles, draft.screenshotFiles) selectedSkins.value = draft.selectedSkins hydrateScreenshotPreviews() } function resetDraftState() { Object.assign(form, defaultPublishForm()) clearRecord(quantityValues) clearRecord(quantityModes) clearRecord(screenshotFiles) revokeAllScreenshotPreviews() selectedSkins.value = [] virtualAssetSaleAgreementChecked.value = false sellerAgreementChecked.value = false activeUploadKey.value = 'coin' // Also re-initialize quantityValues to 0 for (const item of publishOptions.value.quantity_items) { quantityValues[item.key] = 0 } } function normalizeOnlineTime() { if (form.online_start === '全天' || form.online_end === '全天') { form.online_start = '00:00' form.online_end = '23:59' } } function isAllDayOnline() { return form.online_start === '00:00' && form.online_end === '23:59' } function isOnlineStartPresetActive(value: string) { return !isAllDayOnline() && form.online_start === value } function isOnlineEndPresetActive(value: string) { return !isAllDayOnline() && form.online_end === value } function selectAllDayOnline() { form.online_start = '00:00' form.online_end = '23:59' } function selectOnlineStart(value: string | number) { const time = String(value) if (!canUseOnlineStart(time)) { options.notifyWarning('开始时间必须早于结束时间') return } form.online_start = time } function selectOnlineEnd(value: string | number) { const time = String(value) if (!canUseOnlineEnd(time)) { options.notifyWarning('结束时间必须晚于开始时间') return } form.online_end = time } function handleOnlineStartChange(value: string | number | null | undefined) { const time = normalizeOnlineTimeValue(value) form.online_start = time const start = parseOnlineTime(time) if (start === null) return const lastTime = parseOnlineTime('23:59') if (lastTime !== null && start >= lastTime) { form.online_start = '' options.notifyWarning('开始时间必须早于结束时间') return } const end = parseOnlineTime(form.online_end) if (end !== null && start >= end) { form.online_end = '' options.notifyWarning('结束时间已清空,请选择晚于开始时间的结束时间') } } function handleOnlineEndChange(value: string | number | null | undefined) { const time = normalizeOnlineTimeValue(value) form.online_end = time const end = parseOnlineTime(time) if (end === null) return const firstTime = parseOnlineTime('00:00') if (firstTime !== null && end <= firstTime) { form.online_end = '' options.notifyWarning('结束时间必须晚于开始时间') return } const start = parseOnlineTime(form.online_start) if (start !== null && end <= start) { form.online_end = '' options.notifyWarning('结束时间必须晚于开始时间') } } function canUseOnlineStart(value: string) { const start = parseOnlineTime(value) const lastTime = parseOnlineTime('23:59') if (start === null || lastTime === null || start >= lastTime) return false const end = parseOnlineTime(form.online_end) return end === null || start < end } function canUseOnlineEnd(value: string) { const end = parseOnlineTime(value) const firstTime = parseOnlineTime('00:00') if (end === null || firstTime === null || end <= firstTime) return false const start = parseOnlineTime(form.online_start) return start === null || end > start } function validateOnlineTime() { const start = parseOnlineTime(form.online_start) const end = parseOnlineTime(form.online_end) if (start === null) return '请选择在线开始时间' if (end === null) return '请选择在线结束时间' if (start === end) return '在线开始和结束时间不能相同' if (end < start) return '在线结束时间必须晚于开始时间' return '' } async function handleResetDraft() { try { await options.confirmReset?.() } catch { return } suppressDraftSave.value = true clearDraftSaveTimer() resetDraftState() removePublishDraft(options.draftKey) options.notifySuccess('已重置') window.setTimeout(() => { suppressDraftSave.value = false }) } function toggleSkin(skin: string) { selectedSkins.value = selectedSkins.value.includes(skin) ? selectedSkins.value.filter(item => item !== skin) : [...selectedSkins.value, skin] } function toggleRegion(region: string) { form.common_regions = form.common_regions.includes(region) ? form.common_regions.filter(item => item !== region) : [...form.common_regions, region] } function triggerUpload(key: ScreenshotKey) { if (!canAddScreenshot(key)) { options.notifyWarning(`${getScreenshotLabel(key)}最多上传${getScreenshotLimit(key)}张`) return } activeUploadKey.value = key fileInput.value?.click() } async function handleScreenshotUpload(event: Event) { const input = event.target as HTMLInputElement const files = Array.from(input.files || []) if (!files.length) return const key = activeUploadKey.value const availableCount = getScreenshotLimit(key) - getScreenshotCount(key) if (availableCount <= 0) { options.notifyWarning(`${getScreenshotLabel(key)}最多上传${getScreenshotLimit(key)}张`) input.value = '' return } const uploadFiles = files.slice(0, availableCount) if (files.length > availableCount) { options.notifyWarning( `${getScreenshotLabel(key)}最多上传${getScreenshotLimit(key)}张,本次仅上传${availableCount}张` ) } uploading.value = true try { for (const file of uploadFiles) { const previewURL = URL.createObjectURL(file) try { const uploaded = await uploadFile(file, 'listing') appendScreenshot(key, uploaded.url, previewURL) } catch (error) { URL.revokeObjectURL(previewURL) throw error } } options.notifySuccess( uploadFiles.length > 1 ? `已上传${uploadFiles.length}张截图` : '截图已上传' ) } catch (error) { options.notifyError(readError(error, '截图上传失败')) } finally { uploading.value = false input.value = '' } } function removeScreenshot(key: ScreenshotKey, index = 0) { revokeScreenshotPreview(key, index) screenshotFiles[key]?.splice(index, 1) if (!screenshotFiles[key]?.length) delete screenshotFiles[key] } function getScreenshotPreviewURL(key: ScreenshotKey, index = 0) { return screenshotPreviews[key]?.[index] || screenshotFiles[key]?.[index] || '' } function appendScreenshot(key: ScreenshotKey, fileURL: string, previewURL: string) { if (!screenshotFiles[key]) screenshotFiles[key] = [] if (!screenshotPreviews[key]) screenshotPreviews[key] = [] screenshotFiles[key].push(fileURL) screenshotPreviews[key].push(previewURL) } function setScreenshotPreview(key: ScreenshotKey, index: number, previewURL: string) { revokeScreenshotPreview(key, index) if (!screenshotPreviews[key]) screenshotPreviews[key] = [] screenshotPreviews[key][index] = previewURL } function revokeScreenshotPreview(key: ScreenshotKey, index: number) { const previewURL = screenshotPreviews[key]?.[index] if (previewURL?.startsWith('blob:')) URL.revokeObjectURL(previewURL) screenshotPreviews[key]?.splice(index, 1) if (!screenshotPreviews[key]?.length) delete screenshotPreviews[key] } function revokeAllScreenshotPreviews() { for (const [key, previews] of Object.entries(screenshotPreviews)) { for (const previewURL of previews) { if (previewURL?.startsWith('blob:')) URL.revokeObjectURL(previewURL) } delete screenshotPreviews[key] } } async function hydrateScreenshotPreviews() { for (const [key, fileURLs] of Object.entries(screenshotFiles)) { for (const [index, fileURL] of fileURLs.entries()) { if ( !fileURL || screenshotPreviews[key]?.[index] || !fileURL.startsWith('/api/files/object') ) continue try { const blob = await fetchFileBlobByURL(fileURL) setScreenshotPreview(key, index, URL.createObjectURL(blob)) } catch { // 草稿预览失败不影响已上传文件地址,提交时仍会带上原 URL。 } } } } function handleFireLevelInput(value: string | number | undefined) { if (value === '' || value === undefined) { form.fire_level = '' return } const level = Number(value) form.fire_level = Number.isFinite(level) ? Math.trunc(level) : '' } function handleAcceleratedSaleRatioInput(value: string | number | undefined) { if (value === '' || value === undefined) { form.accelerated_sale_ratio = '' return } const ratio = Number(value) form.accelerated_sale_ratio = Number.isFinite(ratio) ? ratio : '' } function syncRecommendedDeposit() { const recommended = pricing.recommendedDepositAmount.value if (recommended <= 0) return const current = Number(form.deposit_amount) if (form.deposit_amount === '' || !Number.isFinite(current) || current < recommended) { form.deposit_amount = recommended } } function useRecommendedDeposit() { if (pricing.recommendedDepositAmount.value > 0) { form.deposit_amount = pricing.recommendedDepositAmount.value } } function clearForbiddenQuantityItems() { for (const item of pricing.quantityItems.value) { if (!pricing.isQuantityItemDisabled(item)) continue quantityValues[item.key] = 0 quantityModes[item.key] = '赠送' } } function setQuantityMode(item: { key: string; label: string }, mode: ChargeMode) { if (pricing.isQuantityItemDisabled(item)) return quantityModes[item.key] = mode } function clampAcceleratedSaleRatioInput() { if (!pricing.hasAcceleratedSaleRatioInput() || pricing.calculatedDefaultSaleRatio.value <= 0) return const ratio = Number(form.accelerated_sale_ratio) if (!Number.isFinite(ratio)) { form.accelerated_sale_ratio = '' return } form.accelerated_sale_ratio = roundRatio( Math.min( Math.max(ratio, pricing.calculatedDefaultSaleRatio.value), pricing.maxAcceleratedSaleRatio.value ) ) } function useReferenceSaleRatio() { form.accelerated_sale_ratio = '' } function useMaxAcceleratedSaleRatio() { if (pricing.maxAcceleratedSaleRatio.value <= 0) return form.accelerated_sale_ratio = pricing.maxAcceleratedSaleRatio.value } async function handleSubmit() { normalizeOnlineTime() const error = validateForm() if (error) { options.notifyWarning(error) return } loading.value = true try { const listing = await createListing({ title: pricing.publishTitle.value, description: form.remark, server_region: form.server_region, login_platform: form.login_method, rank_level: form.rank_level, haf_coin_amount: pricing.coinMAmount.value * 1000000, asset_summary: buildAssetSummary(), screenshot_urls: pricing.screenshotUrls.value, price: pricing.calculatedFinalPrice.value, deposit_amount: Number(form.deposit_amount), agreed_virtual_asset_sale: virtualAssetSaleAgreementChecked.value, agreed_seller_agreement: sellerAgreementChecked.value, }) removePublishDraft(options.draftKey) suppressDraftSave.value = true clearDraftSaveTimer() options.notifySuccess( listing.status === 'published' && listing.review_status === 'approved' ? '发布成功,已上架' : '发布成功,等待后台审核' ) await router.push(options.submitSuccessPath) } catch (error) { options.notifyError(readError(error, '发布失败,请确认已登录并完成实名认证')) } finally { loading.value = false } } function validateForm() { if (!form.server_region) return '请选择区服' if (pricing.coinMAmount.value <= 0) return '请填写哈夫币/M' if (!form.rank_level) return '请选择段位' if (!form.fire_level) return '请填写烽火等级' if (Number(form.fire_level) < pricing.fireLevelMin.value) return `烽火等级低于${pricing.fireLevelMin.value}级的号无法发布` if (!form.season_insurance) return '请选择赛季保险' if (!form.stamina_level) return '请选择体力等级' if (!form.load_level) return '请选择负重等级' if (!dailyLossOptions.includes(pricing.dailyLossMAmount.value)) return '请选择每日损耗' const onlineTimeError = validateOnlineTime() if (onlineTimeError) return onlineTimeError if (pricing.hasAcceleratedSaleRatioInput()) { const ratio = Number(form.accelerated_sale_ratio) if (!Number.isFinite(ratio) || ratio <= 0) return '加速出售比例格式不正确' if ( pricing.calculatedDefaultSaleRatio.value > 0 && ratio < pricing.calculatedDefaultSaleRatio.value ) { return `加速出售比例不能低于默认比例 1:${formatNumber(pricing.calculatedDefaultSaleRatio.value)}` } if ( pricing.maxAcceleratedSaleRatio.value > 0 && ratio > pricing.maxAcceleratedSaleRatio.value ) { return `加速出售比例不能超过 1:${formatNumber(pricing.maxAcceleratedSaleRatio.value)}` } } if (pricing.banRecordOptions.value.length && !form.ban_record) return '请选择封禁记录' if (form.deposit_amount === '' || Number(form.deposit_amount) < 0) return '请填写押金' if (!Number.isFinite(Number(form.deposit_amount))) return '押金格式不正确' if ( pricing.recommendedDepositAmount.value > 0 && Number(form.deposit_amount) < pricing.recommendedDepositAmount.value ) { return `押金不能低于智能推荐 ¥${pricing.recommendedDepositAmount.value}` } if ( pricing.calculatedConsumablePrice.value > 0 && Number(form.deposit_amount) <= pricing.calculatedConsumablePrice.value ) { return `押金必须大于额外消耗品总价值 ¥${pricing.calculatedConsumablePrice.value}` } if (!pricing.calculatedFinalPrice.value) return '请完善币数、保险、体力和负重后再发布' if (!Number.isFinite(pricing.calculatedFinalPrice.value)) return '发布价格计算异常,请检查填写内容' if (!canPublishAfterAgreements.value) return '请先阅读并勾选两份发布协议' for (const item of pricing.screenshotSlots.value) { if (isScreenshotRequired(item) && getScreenshotCount(item.key) === 0) return `请上传${item.label}` } for (const item of pricing.quantityItems.value) { if (!pricing.isQuantityItemDisabled(item)) { const val = quantityValues[item.key] if (val === undefined || val === null || (val as unknown) === '') { return `请填写${item.label}的数量` } if (Number(val) < 0) { return `${item.label}的数量不能为负数` } } } for (const item of pricing.quantityItems.value) { if (pricing.isQuantityItemDisabled(item) && Number(quantityValues[item.key] || 0) > 0) { return '赛季保险选择 3*3 时不能填写 9格体验卡' } } return '' } function isScreenshotRequired(item: { key: string; required: boolean }) { return item.required || (item.key === 'tencentSecurity' && shouldRequireBanEvidence()) } function shouldRequireBanEvidence() { return pricing.banEvidenceOptions.value.includes(form.ban_record) } function buildAssetSummary() { return { face_owner: form.face_owner, secret_kd: form.secret_kd, fire_level: Number(form.fire_level), daily_loss_m: pricing.dailyLossMAmount.value, publish_ratio: pricing.calculatedPlatformPricing.value.buyerRatio, price_breakdown: { seller_reference_ratio: pricing.calculatedSellerReferenceRatio.value, seller_ratio: pricing.calculatedRatio.value, seller_coin_base_price: pricing.calculatedCoinBasePrice.value, seller_total_price: pricing.calculatedSellerPrice.value, consumable_price: pricing.calculatedConsumablePrice.value, daily_loss_ratio_adjustment: pricing.dailyLossRatioAdjustment.value, accelerated_sale_ratio: pricing.hasAcceleratedSaleRatioInput() ? Number(form.accelerated_sale_ratio) : pricing.calculatedDefaultSaleRatio.value, buyer_coin_base_price: pricing.calculatedPlatformPricing.value.buyerCoinBasePrice, buyer_total_price: pricing.calculatedFinalPrice.value, buyer_ratio: pricing.calculatedPlatformPricing.value.buyerRatio, platform_markup_amount: pricing.calculatedPlatformPricing.value.platformMarkupAmount, platform_rule_type: pricing.calculatedPlatformPricing.value.ruleType, }, season_insurance: form.season_insurance, stamina_level: form.stamina_level, load_level: form.load_level, resources: pricing.quantityItems.value.map(item => ({ key: item.key, label: item.label, price: item.price, quantity: pricing.isQuantityItemDisabled(item) ? 0 : Number(quantityValues[item.key] || 0), mode: pricing.isQuantityItemDisabled(item) ? '赠送' : quantityModes[item.key] || '收费', })), skin_groups: pricing.skinGroups.value.reduce>((groups, group) => { groups[group.key] = group.options.filter(skin => selectedSkins.value.includes(skin)) return groups }, {}), screenshot_groups: buildScreenshotGroups(), online_time: { start: form.online_start, end: form.online_end, }, ban_record: form.ban_record, common_regions: form.common_regions, remark: form.remark, } } function buildScreenshotGroups() { return pricing.screenshotSlots.value.reduce>((groups, slot) => { groups[slot.key] = getScreenshotURLs(slot.key) return groups }, {}) } function getScreenshotURLs(key: ScreenshotKey) { return (screenshotFiles[key] || []).filter(Boolean) } function getScreenshotCount(key: ScreenshotKey) { return getScreenshotURLs(key).length } function getScreenshotLimit(key: ScreenshotKey) { return multiScreenshotKeys.has(key) ? multiScreenshotLimit : 1 } function isMultiScreenshotSlot(key: ScreenshotKey) { return getScreenshotLimit(key) > 1 } function canAddScreenshot(key: ScreenshotKey) { return !uploading.value && getScreenshotCount(key) < getScreenshotLimit(key) } function getScreenshotLimitHint(key: ScreenshotKey) { const limit = getScreenshotLimit(key) if (limit <= 1) return '' return `最多${limit}张,已上传${getScreenshotCount(key)}张` } function getScreenshotLabel(key: ScreenshotKey) { return pricing.screenshotSlots.value.find(item => item.key === key)?.label || '该截图' } return { ...pricing, dailyLossOptions, commonOnlineTimes, formatNumber, router, loading, uploading, publishAgreements, virtualAssetSaleAgreementChecked, sellerAgreementChecked, canPublishAfterAgreements, fileInput, activeUploadKey, form, quantityValues, quantityModes, screenshotFiles, screenshotPreviews, selectedSkins, uploadedScreenshotCount, requiredScreenshotCount, disabledOnlineStartOptions, disabledOnlineEndOptions, loadPublishOptions, saveDraft, handleSaveDraft, resetDraftState, handleResetDraft, isAllDayOnline, isOnlineStartPresetActive, isOnlineEndPresetActive, selectAllDayOnline, selectOnlineStart, selectOnlineEnd, handleOnlineStartChange, handleOnlineEndChange, toggleSkin, toggleRegion, triggerUpload, handleScreenshotUpload, removeScreenshot, getScreenshotURLs, getScreenshotCount, getScreenshotPreviewURL, getScreenshotLimit, isMultiScreenshotSlot, canAddScreenshot, getScreenshotLimitHint, handleFireLevelInput, handleAcceleratedSaleRatioInput, syncRecommendedDeposit, useRecommendedDeposit, clearForbiddenQuantityItems, setQuantityMode, clampAcceleratedSaleRatioInput, useReferenceSaleRatio, useMaxAcceleratedSaleRatio, handleSubmit, validateForm, isScreenshotRequired, shouldRequireBanEvidence, buildAssetSummary, } } function readError(error: unknown, fallback: string) { if (typeof error === 'object' && error && 'response' in error) { const response = (error as { response?: { data?: { message?: string } } }).response return response?.data?.message || fallback } return fallback } function parseOnlineTime(value: string) { const match = /^(\d{2}):(\d{2})$/.exec(value) if (!match) return null const hour = Number(match[1]) const minute = Number(match[2]) if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null return hour * 60 + minute } function normalizeOnlineTimeValue(value: string | number | null | undefined) { if (value === null || value === undefined) return '' return String(value) }