-
-
+
+
)
}
diff --git a/web/app/dashboard/notifications/page.tsx b/web/app/dashboard/notifications/page.tsx
new file mode 100644
index 0000000..1ca754e
--- /dev/null
+++ b/web/app/dashboard/notifications/page.tsx
@@ -0,0 +1,187 @@
+"use client"
+
+import { useCallback, useEffect, useState } from "react"
+import { useRouter } from "next/navigation"
+import { BellIcon, CheckCheckIcon, RefreshCwIcon } from "lucide-react"
+import { toast } from "sonner"
+
+import { ListPagination } from "@/components/list-pagination"
+import { useNotifications } from "@/components/notification-provider"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import {
+ fetchNotifications,
+ markAllNotificationsRead,
+ markNotificationRead,
+ type NotificationItem,
+ type NotificationReadStatus,
+} from "@/lib/api/notification"
+import type { PageResult } from "@/lib/api/admin"
+import { cn, formatDateTime } from "@/lib/utils"
+
+const readStatusOptions: Array<{ value: NotificationReadStatus; label: string }> = [
+ { value: "all", label: "全部" },
+ { value: "unread", label: "未读" },
+ { value: "read", label: "已读" },
+]
+
+export default function DashboardNotificationsPage() {
+ const router = useRouter()
+ const { refreshUnreadCount } = useNotifications()
+ const [readStatus, setReadStatus] = useState
("all")
+ const [page, setPage] = useState(1)
+ const [limit, setLimit] = useState(20)
+ const [loading, setLoading] = useState(true)
+ const [actionLoading, setActionLoading] = useState(false)
+ const [result, setResult] = useState>({
+ results: [],
+ page: { page: 1, limit: 20, total: 0 },
+ })
+
+ const loadData = useCallback(async () => {
+ setLoading(true)
+ try {
+ const data = await fetchNotifications({
+ page,
+ limit,
+ readStatus,
+ })
+ setResult(data)
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "加载通知失败")
+ } finally {
+ setLoading(false)
+ }
+ }, [limit, page, readStatus])
+
+ useEffect(() => {
+ void loadData()
+ }, [loadData])
+
+ async function openNotification(item: NotificationItem) {
+ try {
+ if (!item.readAt) {
+ await markNotificationRead(item.id)
+ await refreshUnreadCount()
+ }
+ if (item.actionUrl) {
+ router.push(item.actionUrl)
+ }
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "打开通知失败")
+ }
+ }
+
+ async function handleMarkAllRead() {
+ setActionLoading(true)
+ try {
+ await markAllNotificationsRead()
+ await refreshUnreadCount()
+ await loadData()
+ toast.success("已全部标记为已读")
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "全部已读失败")
+ } finally {
+ setActionLoading(false)
+ }
+ }
+
+ function handleStatusChange(nextStatus: NotificationReadStatus) {
+ setReadStatus(nextStatus)
+ setPage(1)
+ }
+
+ function handlePageChange(nextPage: number) {
+ if (nextPage < 1 || nextPage === page) {
+ return
+ }
+ setPage(nextPage)
+ }
+
+ function handleLimitChange(nextLimit: number) {
+ setLimit(nextLimit)
+ setPage(1)
+ }
+
+ return (
+
+
+
+
通知中心
+
+ 查看工单、会话等业务流转提醒
+
+
+
+
+
+
+
+
+
+ {readStatusOptions.map((option) => (
+
+ ))}
+
+
+
+ {result.results.length > 0 ? (
+
+ {result.results.map((item) => {
+ const unread = !item.readAt
+ return (
+
+ )
+ })}
+
+ ) : (
+
+ {loading ? "正在加载通知" : "暂无通知"}
+
+ )}
+
+
+
+
+ )
+}
diff --git a/web/components/kefu/widget-demo.tsx b/web/components/kefu/widget-demo.tsx
index 6e9922d..8ad0ad5 100644
--- a/web/components/kefu/widget-demo.tsx
+++ b/web/components/kefu/widget-demo.tsx
@@ -1,16 +1,28 @@
"use client"
+import { SignJWT } from "jose"
import { useEffect, useMemo, useState } from "react"
import type { KefuWidgetHostConfig } from "@/lib/kefu-widget-config"
const STORAGE_KEY = "cs-agent-web-widget-test-config"
+const DEFAULT_JWT_TTL_MINUTES = "30"
const INITIAL_CONFIG: KefuWidgetHostConfig = {
channelId: "",
baseUrl: "",
apiBaseUrl: "",
}
+type AuthMode = "guest" | "jwt"
+
+type WidgetDemoConfig = KefuWidgetHostConfig & {
+ authMode?: AuthMode
+ jwtSecret?: string
+ jwtUserId?: string
+ jwtName?: string
+ jwtTtlMinutes?: string
+}
+
declare global {
interface Window {
CSAgentWidget?: {
@@ -22,14 +34,14 @@ declare global {
}
}
-function getDefaultConfig(): KefuWidgetHostConfig {
+function getDefaultConfig(): WidgetDemoConfig {
if (typeof window === "undefined") {
return INITIAL_CONFIG
}
const savedText = window.localStorage.getItem(STORAGE_KEY)
const savedConfig = savedText
- ? (JSON.parse(savedText) as Partial)
+ ? (JSON.parse(savedText) as Partial)
: {}
const query = new URLSearchParams(window.location.search)
@@ -37,6 +49,11 @@ function getDefaultConfig(): KefuWidgetHostConfig {
channelId: query.get("channelId") ?? savedConfig.channelId ?? "",
baseUrl: "",
apiBaseUrl: "",
+ authMode: (query.get("authMode") as AuthMode | null) ?? savedConfig.authMode ?? "guest",
+ jwtSecret: savedConfig.jwtSecret ?? "",
+ jwtUserId: query.get("userId") ?? savedConfig.jwtUserId ?? "demo-user-001",
+ jwtName: query.get("name") ?? savedConfig.jwtName ?? "测试用户",
+ jwtTtlMinutes: savedConfig.jwtTtlMinutes ?? DEFAULT_JWT_TTL_MINUTES,
}
}
@@ -69,10 +86,79 @@ function injectWidget(config: KefuWidgetHostConfig) {
document.body.appendChild(script)
}
+function buildWidgetConfig(config: WidgetDemoConfig, userToken: string): WidgetDemoConfig {
+ return {
+ ...config,
+ channelId: config.channelId.trim(),
+ baseUrl: "",
+ apiBaseUrl: "",
+ userToken,
+ }
+}
+
+async function signUserToken(config: WidgetDemoConfig) {
+ const userId = (config.jwtUserId || "").trim()
+ const name = (config.jwtName || "").trim()
+ const secret = (config.jwtSecret || "").trim()
+ const ttl = Number(config.jwtTtlMinutes || DEFAULT_JWT_TTL_MINUTES)
+
+ if (!userId) {
+ throw new Error("请填写 userId")
+ }
+ if (!name) {
+ throw new Error("请填写用户名称")
+ }
+ if (!secret) {
+ throw new Error("请填写 JWT Secret")
+ }
+ if (!Number.isFinite(ttl) || ttl <= 0) {
+ throw new Error("有效期必须大于 0")
+ }
+
+ return new SignJWT({ userId, name })
+ .setProtectedHeader({ alg: "HS256", typ: "JWT" })
+ .setIssuedAt()
+ .setExpirationTime(`${ttl}m`)
+ .sign(new TextEncoder().encode(secret))
+}
+
export function KefuWidgetDemo() {
- const [config, setConfig] = useState(INITIAL_CONFIG)
+ const [config, setConfig] = useState({
+ ...INITIAL_CONFIG,
+ authMode: "guest",
+ jwtSecret: "",
+ jwtUserId: "demo-user-001",
+ jwtName: "测试用户",
+ jwtTtlMinutes: DEFAULT_JWT_TTL_MINUTES,
+ })
const [status, setStatus] = useState("请填写 channelId")
const [origin, setOrigin] = useState("")
+ const [generatedToken, setGeneratedToken] = useState("")
+
+ async function mountWidget(configToMount: WidgetDemoConfig) {
+ let userToken = ""
+ if (configToMount.authMode === "jwt") {
+ userToken = await signUserToken(configToMount)
+ }
+
+ const nextConfig = buildWidgetConfig(configToMount, userToken)
+ setConfig(nextConfig)
+ setGeneratedToken(userToken)
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(nextConfig))
+
+ if (!nextConfig.channelId) {
+ removeMountedWidget()
+ setStatus("请填写 channelId")
+ return
+ }
+
+ injectWidget(nextConfig)
+ setStatus(
+ nextConfig.authMode === "jwt"
+ ? "Widget 已挂载:JWT 用户模式"
+ : "Widget 已挂载:访客模式"
+ )
+ }
useEffect(() => {
const timer = window.setTimeout(() => {
@@ -82,7 +168,11 @@ export function KefuWidgetDemo() {
setStatus(initialConfig.channelId ? "Widget 已挂载" : "请填写 channelId")
if (initialConfig.channelId) {
- injectWidget(initialConfig)
+ void mountWidget(initialConfig).catch((error) => {
+ removeMountedWidget()
+ setGeneratedToken("")
+ setStatus(error instanceof Error ? error.message : "生成 userToken 失败")
+ })
}
}, 0)
@@ -97,40 +187,34 @@ export function KefuWidgetDemo() {
? `${origin}/sdk/cs-ai-agent-sdk.min.js`
: "/sdk/cs-ai-agent-sdk.min.js"
+ const configLines = [` channelId: "${config.channelId || ""}"`]
+ if (config.authMode === "jwt") {
+ configLines.push(` userToken: "${generatedToken || "业务系统后端签发的 JWT"}"`)
+ }
+
return `
`
- }, [config, origin])
+ }, [config, generatedToken, origin])
- function updateField(
+ function updateField(
key: K,
- value: KefuWidgetHostConfig[K]
+ value: WidgetDemoConfig[K]
) {
setConfig((current) => ({ ...current, [key]: value }))
}
- function handleMount() {
- const nextConfig: KefuWidgetHostConfig = {
- ...config,
- channelId: config.channelId.trim(),
- baseUrl: "",
- apiBaseUrl: "",
- }
-
- setConfig(nextConfig)
- window.localStorage.setItem(STORAGE_KEY, JSON.stringify(nextConfig))
-
- if (!nextConfig.channelId) {
+ async function handleMount() {
+ try {
+ await mountWidget(config)
+ } catch (error) {
removeMountedWidget()
- setStatus("请填写 channelId")
- return
+ setGeneratedToken("")
+ setStatus(error instanceof Error ? error.message : "生成 userToken 失败")
}
-
- injectWidget(nextConfig)
- setStatus("Widget 已挂载")
}
return (
@@ -146,12 +230,47 @@ export function KefuWidgetDemo() {
value={config.channelId}
onChange={(value) => updateField("channelId", value)}
/>
+ updateField("authMode", value)}
+ options={[
+ { label: "访客", value: "guest" },
+ { label: "JWT 用户", value: "jwt" },
+ ]}
+ />
+ {config.authMode === "jwt" ? (
+
+ updateField("jwtUserId", value)}
+ />
+ updateField("jwtName", value)}
+ />
+ updateField("jwtSecret", value)}
+ type="password"
+ />
+ updateField("jwtTtlMinutes", value)}
+ type="number"
+ />
+
+ ) : null}