feat(modules): 帮助中心、工单、工作流与多渠道接入
补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
This commit is contained in:
1 parent
69a71aabbb
commit
e0baca15a9
225 files changed
+59337
No files matched your search
@@ -0,0 +1,205 @@
|
||||
/*
|
||||
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, watch, 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 suppressWatch = false
|
||||
|
||||
const brandColor = computed(() => settings.value?.brand_color || '#4338CA')
|
||||
|
||||
function applyResponse(updated: HelpCenterSettings): void {
|
||||
suppressWatch = true
|
||||
settings.value = updated
|
||||
// Release after the watcher has seen (and ignored) this assignment.
|
||||
setTimeout(() => {
|
||||
suppressWatch = false
|
||||
}, 0)
|
||||
}
|
||||
|
||||
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 {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => void save(payload), AUTOSAVE_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
/** Immediate path for discrete actions (swatch click, toggles, selects). */
|
||||
async function saveNow(payload: HelpCenterSettingsUpdate): Promise<void> {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
await save(payload)
|
||||
}
|
||||
|
||||
// Deep-watch branding fields the inputs mutate in place (header_links rows).
|
||||
watch(
|
||||
() => settings.value?.header_links,
|
||||
(links, previous) => {
|
||||
if (suppressWatch || links === undefined || previous === undefined) return
|
||||
queueSave({ header_links: links })
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user