add web access guide component and enhance channel form with dynamic details
This commit is contained in:
@@ -1,9 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { Controller, Resolver, useForm, useWatch } from "react-hook-form"
|
import { Controller, Resolver, useForm, useWatch } from "react-hook-form"
|
||||||
import { z } from "zod/v4"
|
import { z } from "zod/v4"
|
||||||
|
import { CopyIcon, ExternalLinkIcon } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { OptionCombobox } from "@/components/option-combobox"
|
import { OptionCombobox } from "@/components/option-combobox"
|
||||||
import { ProjectDialog } from "@/components/project-dialog"
|
import { ProjectDialog } from "@/components/project-dialog"
|
||||||
@@ -214,6 +216,7 @@ function ChannelFormBody({
|
|||||||
const [wxWorkKFAccounts, setWxWorkKFAccounts] = useState<WxWorkKFAccount[]>([])
|
const [wxWorkKFAccounts, setWxWorkKFAccounts] = useState<WxWorkKFAccount[]>([])
|
||||||
const [wxWorkKFAccountsLoading, setWxWorkKFAccountsLoading] = useState(false)
|
const [wxWorkKFAccountsLoading, setWxWorkKFAccountsLoading] = useState(false)
|
||||||
const [wxWorkKFAccountsError, setWxWorkKFAccountsError] = useState("")
|
const [wxWorkKFAccountsError, setWxWorkKFAccountsError] = useState("")
|
||||||
|
const [channelDetail, setChannelDetail] = useState<AdminChannel | null>(null)
|
||||||
const [currentStatus, setCurrentStatus] = useState(0)
|
const [currentStatus, setCurrentStatus] = useState(0)
|
||||||
const form = useForm<
|
const form = useForm<
|
||||||
z.input<typeof schema>,
|
z.input<typeof schema>,
|
||||||
@@ -249,12 +252,14 @@ function ChannelFormBody({
|
|||||||
async function loadDetail() {
|
async function loadDetail() {
|
||||||
if (!itemId) {
|
if (!itemId) {
|
||||||
setCurrentStatus(0)
|
setCurrentStatus(0)
|
||||||
|
setChannelDetail(null)
|
||||||
reset(emptyForm)
|
reset(emptyForm)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const data = await fetchChannel(itemId)
|
const data = await fetchChannel(itemId)
|
||||||
|
setChannelDetail(data)
|
||||||
setCurrentStatus(data.status)
|
setCurrentStatus(data.status)
|
||||||
reset(buildForm(data))
|
reset(buildForm(data))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -436,71 +441,74 @@ function ChannelFormBody({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{channelType === "web" ? (
|
{channelType === "web" ? (
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<>
|
||||||
<Field data-invalid={!!errors.widgetTitle}>
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<FieldLabel htmlFor="channel-widget-title">窗口标题</FieldLabel>
|
<Field data-invalid={!!errors.widgetTitle}>
|
||||||
<FieldContent>
|
<FieldLabel htmlFor="channel-widget-title">窗口标题</FieldLabel>
|
||||||
<Input id="channel-widget-title" {...register("widgetTitle")} />
|
<FieldContent>
|
||||||
<FieldError errors={[errors.widgetTitle]} />
|
<Input id="channel-widget-title" {...register("widgetTitle")} />
|
||||||
</FieldContent>
|
<FieldError errors={[errors.widgetTitle]} />
|
||||||
</Field>
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<Field data-invalid={!!errors.widgetSubtitle}>
|
<Field data-invalid={!!errors.widgetSubtitle}>
|
||||||
<FieldLabel htmlFor="channel-widget-subtitle">窗口副标题</FieldLabel>
|
<FieldLabel htmlFor="channel-widget-subtitle">窗口副标题</FieldLabel>
|
||||||
<FieldContent>
|
<FieldContent>
|
||||||
<Input
|
<Input
|
||||||
id="channel-widget-subtitle"
|
id="channel-widget-subtitle"
|
||||||
{...register("widgetSubtitle")}
|
{...register("widgetSubtitle")}
|
||||||
/>
|
/>
|
||||||
<FieldError errors={[errors.widgetSubtitle]} />
|
<FieldError errors={[errors.widgetSubtitle]} />
|
||||||
</FieldContent>
|
</FieldContent>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field data-invalid={!!errors.widgetThemeColor}>
|
<Field data-invalid={!!errors.widgetThemeColor}>
|
||||||
<FieldLabel htmlFor="channel-widget-theme-color">主题色</FieldLabel>
|
<FieldLabel htmlFor="channel-widget-theme-color">主题色</FieldLabel>
|
||||||
<FieldContent>
|
<FieldContent>
|
||||||
<Input
|
<Input
|
||||||
id="channel-widget-theme-color"
|
id="channel-widget-theme-color"
|
||||||
placeholder="#2563eb"
|
placeholder="#2563eb"
|
||||||
{...register("widgetThemeColor")}
|
{...register("widgetThemeColor")}
|
||||||
/>
|
/>
|
||||||
<FieldError errors={[errors.widgetThemeColor]} />
|
<FieldError errors={[errors.widgetThemeColor]} />
|
||||||
</FieldContent>
|
</FieldContent>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field data-invalid={!!errors.widgetPosition}>
|
<Field data-invalid={!!errors.widgetPosition}>
|
||||||
<FieldLabel>挂载位置</FieldLabel>
|
<FieldLabel>挂载位置</FieldLabel>
|
||||||
<FieldContent>
|
<FieldContent>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="widgetPosition"
|
name="widgetPosition"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<OptionCombobox
|
<OptionCombobox
|
||||||
value={field.value}
|
value={field.value}
|
||||||
options={[...widgetPositionOptions]}
|
options={[...widgetPositionOptions]}
|
||||||
placeholder="请选择挂载位置"
|
placeholder="请选择挂载位置"
|
||||||
searchPlaceholder="搜索挂载位置"
|
searchPlaceholder="搜索挂载位置"
|
||||||
emptyText="未找到挂载位置"
|
emptyText="未找到挂载位置"
|
||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FieldError errors={[errors.widgetPosition]} />
|
<FieldError errors={[errors.widgetPosition]} />
|
||||||
</FieldContent>
|
</FieldContent>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field data-invalid={!!errors.widgetWidth}>
|
<Field data-invalid={!!errors.widgetWidth}>
|
||||||
<FieldLabel htmlFor="channel-widget-width">窗口宽度</FieldLabel>
|
<FieldLabel htmlFor="channel-widget-width">窗口宽度</FieldLabel>
|
||||||
<FieldContent>
|
<FieldContent>
|
||||||
<Input
|
<Input
|
||||||
id="channel-widget-width"
|
id="channel-widget-width"
|
||||||
placeholder="380px"
|
placeholder="380px"
|
||||||
{...register("widgetWidth")}
|
{...register("widgetWidth")}
|
||||||
/>
|
/>
|
||||||
<FieldError errors={[errors.widgetWidth]} />
|
<FieldError errors={[errors.widgetWidth]} />
|
||||||
</FieldContent>
|
</FieldContent>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
<WebAccessGuide channelId={channelDetail?.channelId || ""} />
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -516,3 +524,140 @@ function ChannelFormBody({
|
|||||||
</ProjectDialog>
|
</ProjectDialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WebAccessGuide({ channelId }: { channelId: string }) {
|
||||||
|
const [origin, setOrigin] = useState("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOrigin(window.location.origin)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const accessUrl = useMemo(() => {
|
||||||
|
if (!origin || !channelId) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
const url = new URL("/kefu/chat/", origin)
|
||||||
|
url.searchParams.set("channelId", channelId)
|
||||||
|
return url.toString()
|
||||||
|
}, [channelId, origin])
|
||||||
|
|
||||||
|
const testUrl = useMemo(() => {
|
||||||
|
if (!origin || !channelId) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
const url = new URL("/kefu", origin)
|
||||||
|
url.searchParams.set("channelId", channelId)
|
||||||
|
return url.toString()
|
||||||
|
}, [channelId, origin])
|
||||||
|
|
||||||
|
const snippet = useMemo(() => {
|
||||||
|
if (!origin || !channelId) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return `<script>
|
||||||
|
window.CSAgentConfig = {
|
||||||
|
channelId: "${channelId}"
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script async src="${origin}/sdk/cs-ai-agent-sdk.min.js"></script>`
|
||||||
|
}, [channelId, origin])
|
||||||
|
|
||||||
|
async function copyText(text: string, successMessage: string) {
|
||||||
|
if (!text) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
toast.success(successMessage)
|
||||||
|
} catch {
|
||||||
|
toast.error("复制失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 border-t pt-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">Web 接入信息</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{channelId
|
||||||
|
? "复制链接或嵌入代码即可接入当前 Web 渠道。"
|
||||||
|
: "保存渠道后生成接入链接和 SDK 代码。"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!channelId ? (
|
||||||
|
<div className="rounded-md bg-muted px-3 py-2 text-sm text-muted-foreground">
|
||||||
|
当前为新建渠道,保存后会生成 channelId。
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">直接访问链接</div>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<Input readOnly value={accessUrl} className="font-mono text-xs" />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
title="复制链接"
|
||||||
|
onClick={() => copyText(accessUrl, "已复制接入链接")}
|
||||||
|
>
|
||||||
|
<CopyIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
title="打开链接"
|
||||||
|
onClick={() => window.open(accessUrl, "_blank", "noopener,noreferrer")}
|
||||||
|
>
|
||||||
|
<ExternalLinkIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">
|
||||||
|
嵌入式接入代码
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => copyText(snippet, "已复制接入代码")}
|
||||||
|
>
|
||||||
|
<CopyIcon className="size-4" />
|
||||||
|
复制代码
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<pre className="max-h-48 overflow-auto rounded-md bg-muted p-3 text-xs leading-5">
|
||||||
|
<code>{snippet}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 rounded-md bg-muted px-3 py-3 text-xs text-muted-foreground">
|
||||||
|
<div className="font-medium text-foreground">接入教程</div>
|
||||||
|
<div>1. 确认该渠道已启用。</div>
|
||||||
|
<div>2. 将嵌入代码粘贴到目标网站 HTML 的 body 结束标签前。</div>
|
||||||
|
<div>3. 发布网站后刷新页面,客服入口会按渠道配置展示。</div>
|
||||||
|
<div>4. 独立页面或二维码场景可直接使用访问链接。</div>
|
||||||
|
<div className="pt-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => window.open(testUrl, "_blank", "noopener,noreferrer")}
|
||||||
|
>
|
||||||
|
<ExternalLinkIcon className="size-4" />
|
||||||
|
打开测试页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user