feat(modules): 帮助中心、工单、工作流与多渠道接入

补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
This commit is contained in:
WangShen007 committed 2026-10-17 22:05:00 +08:00
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,
}
}