Files
replypier/frontend/src/composables/useHelpCenterSettings.ts
T

200 lines
5.9 KiB
TypeScript
Raw Normal View History

/*
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { computed, ref, type Ref } from 'vue'
import { toast } from 'vue-sonner'
import { faqService } from '@/services/faq'
import type { HelpCenterSettings, HelpCenterSettingsUpdate } from '@/types/faq'
export type SaveState = 'idle' | 'saving' | 'saved' | 'error'
const AUTOSAVE_DEBOUNCE_MS = 800
const MAX_LOGO_BYTES = 2 * 1024 * 1024
const MAX_FAVICON_BYTES = 1 * 1024 * 1024
/** Appearance/settings editing with debounced autosave — the design has no
* Save button ("changes apply instantly to your published help center"). */
export function useHelpCenterSettings(settings: Ref<HelpCenterSettings | null>) {
const saveState = ref<SaveState>('idle')
let debounceTimer: ReturnType<typeof setTimeout> | null = null
let saveInFlight = false
let trailingSave: HelpCenterSettingsUpdate | null = null
let debouncedPayload: HelpCenterSettingsUpdate | null = null
const brandColor = computed(() => settings.value?.brand_color || '#4338CA')
function applyResponse(updated: HelpCenterSettings): void {
const pending = { ...(trailingSave || {}), ...(debouncedPayload || {}) }
settings.value = { ...updated, ...pending }
}
async function save(payload: HelpCenterSettingsUpdate): Promise<void> {
if (saveInFlight) {
trailingSave = { ...(trailingSave || {}), ...payload }
return
}
saveInFlight = true
saveState.value = 'saving'
try {
applyResponse(await faqService.updateSettings(payload))
saveState.value = 'saved'
} catch (error: any) {
saveState.value = 'error'
toast.error(error.message)
} finally {
saveInFlight = false
if (trailingSave) {
const queued = trailingSave
trailingSave = null
void save(queued)
}
}
}
/** Debounced path for text inputs (links, CTA, labels). */
function queueSave(payload: HelpCenterSettingsUpdate): void {
debouncedPayload = { ...(debouncedPayload || {}), ...payload }
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
debounceTimer = null
const queued = debouncedPayload
debouncedPayload = null
if (queued) void save(queued)
}, AUTOSAVE_DEBOUNCE_MS)
}
/** Immediate path for discrete actions (swatch click, toggles, selects). */
async function saveNow(payload: HelpCenterSettingsUpdate): Promise<void> {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = null
const combined = { ...(debouncedPayload || {}), ...payload }
debouncedPayload = null
await save(combined)
}
async function uploadLogo(file: File): Promise<void> {
if (file.size > MAX_LOGO_BYTES) {
toast.error('Logo 不能超过 2 MB')
return
}
saveState.value = 'saving'
try {
applyResponse(await faqService.uploadLogo(file))
saveState.value = 'saved'
} catch (error: any) {
saveState.value = 'error'
toast.error(error.message)
}
}
async function removeLogo(): Promise<void> {
saveState.value = 'saving'
try {
applyResponse(await faqService.removeLogo())
saveState.value = 'saved'
} catch (error: any) {
saveState.value = 'error'
toast.error(error.message)
}
}
async function uploadFavicon(file: File): Promise<void> {
if (file.size > MAX_FAVICON_BYTES) {
toast.error('网站图标不能超过 1 MB')
return
}
saveState.value = 'saving'
try {
applyResponse(await faqService.uploadFavicon(file))
saveState.value = 'saved'
} catch (error: any) {
saveState.value = 'error'
toast.error(error.message)
}
}
async function removeFavicon(): Promise<void> {
saveState.value = 'saving'
try {
applyResponse(await faqService.removeFavicon())
saveState.value = 'saved'
} catch (error: any) {
saveState.value = 'error'
toast.error(error.message)
}
}
// Domain lifecycle (explicit Verify button — never autosaved).
const domainBusy = ref(false)
async function setDomain(domain: string): Promise<void> {
domainBusy.value = true
try {
const result = await faqService.setDomain(domain)
if (settings.value) settings.value = { ...settings.value, domain: result }
} catch (error: any) {
toast.error(error.message)
} finally {
domainBusy.value = false
}
}
async function verifyDomain(): Promise<void> {
domainBusy.value = true
try {
const result = await faqService.verifyDomain()
if (settings.value) settings.value = { ...settings.value, domain: result }
if (result.domain_status === 'verified') {
toast.success('域名已验证')
} else {
toast.info('记录尚未生效,DNS 变更可能需要几分钟才能传播')
}
} catch (error: any) {
toast.error(error.message)
} finally {
domainBusy.value = false
}
}
async function removeDomain(): Promise<void> {
domainBusy.value = true
try {
const result = await faqService.removeDomain()
if (settings.value) settings.value = { ...settings.value, domain: result }
} catch (error: any) {
toast.error(error.message)
} finally {
domainBusy.value = false
}
}
return {
saveState,
brandColor,
queueSave,
saveNow,
uploadLogo,
removeLogo,
uploadFavicon,
removeFavicon,
domainBusy,
setDomain,
verifyDomain,
removeDomain,
}
}