2026-10-17 22:05:00 +08:00
|
|
|
/*
|
|
|
|
|
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.
|
|
|
|
|
*/
|
|
|
|
|
|
2026-10-29 09:46:21 +08:00
|
|
|
import { computed, ref, type Ref } from 'vue'
|
2026-10-17 22:05:00 +08:00
|
|
|
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
|
2026-10-29 09:46:21 +08:00
|
|
|
let debouncedPayload: HelpCenterSettingsUpdate | null = null
|
2026-10-17 22:05:00 +08:00
|
|
|
|
|
|
|
|
const brandColor = computed(() => settings.value?.brand_color || '#4338CA')
|
|
|
|
|
|
|
|
|
|
function applyResponse(updated: HelpCenterSettings): void {
|
2026-10-29 09:46:21 +08:00
|
|
|
const pending = { ...(trailingSave || {}), ...(debouncedPayload || {}) }
|
|
|
|
|
settings.value = { ...updated, ...pending }
|
2026-10-17 22:05:00 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-10-29 09:46:21 +08:00
|
|
|
debouncedPayload = { ...(debouncedPayload || {}), ...payload }
|
2026-10-17 22:05:00 +08:00
|
|
|
if (debounceTimer) clearTimeout(debounceTimer)
|
2026-10-29 09:46:21 +08:00
|
|
|
debounceTimer = setTimeout(() => {
|
|
|
|
|
debounceTimer = null
|
|
|
|
|
const queued = debouncedPayload
|
|
|
|
|
debouncedPayload = null
|
|
|
|
|
if (queued) void save(queued)
|
|
|
|
|
}, AUTOSAVE_DEBOUNCE_MS)
|
2026-10-17 22:05:00 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Immediate path for discrete actions (swatch click, toggles, selects). */
|
|
|
|
|
async function saveNow(payload: HelpCenterSettingsUpdate): Promise<void> {
|
|
|
|
|
if (debounceTimer) clearTimeout(debounceTimer)
|
2026-10-29 09:46:21 +08:00
|
|
|
debounceTimer = null
|
|
|
|
|
const combined = { ...(debouncedPayload || {}), ...payload }
|
|
|
|
|
debouncedPayload = null
|
|
|
|
|
await save(combined)
|
2026-10-17 22:05:00 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
}
|