mirror of
https://github.com/2930134478/AI-CS.git
synced 2026-06-15 00:44:30 +08:00
多语言+移动端适配
This commit is contained in:
@@ -8,6 +8,8 @@ import {
|
||||
} from "@/features/agent/services/analyticsApi";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import type { I18nKey } from "@/lib/i18n/dict";
|
||||
import { useI18n } from "@/lib/i18n/provider";
|
||||
|
||||
function formatPercent(n: number) {
|
||||
if (Number.isNaN(n)) return "—";
|
||||
@@ -24,7 +26,7 @@ function StatCard({
|
||||
sub?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border/60 bg-card p-4 shadow-sm">
|
||||
<div className="rounded-xl border border-border/60 bg-card p-3 shadow-sm sm:p-4">
|
||||
<div className="text-xs font-medium text-muted-foreground">{title}</div>
|
||||
<div className="mt-1 text-2xl font-semibold tabular-nums">{value}</div>
|
||||
{sub ? <div className="mt-1 text-xs text-muted-foreground">{sub}</div> : null}
|
||||
@@ -37,6 +39,7 @@ function DailyBars({
|
||||
field,
|
||||
label,
|
||||
color,
|
||||
emptyLabel,
|
||||
}: {
|
||||
daily: AnalyticsDailyRow[];
|
||||
field: keyof Pick<
|
||||
@@ -45,6 +48,7 @@ function DailyBars({
|
||||
>;
|
||||
label: string;
|
||||
color: string;
|
||||
emptyLabel: string;
|
||||
}) {
|
||||
const max = useMemo(() => {
|
||||
let m = 1;
|
||||
@@ -56,13 +60,14 @@ function DailyBars({
|
||||
}, [daily, field]);
|
||||
|
||||
if (daily.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">暂无数据</p>;
|
||||
return <p className="text-sm text-muted-foreground">{emptyLabel}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<div className="mb-2 text-sm font-medium text-foreground">{label}</div>
|
||||
<div className="flex h-36 items-end gap-1 border-b border-border/40 pb-1">
|
||||
<div className="-mx-1 overflow-x-auto px-1 pb-1">
|
||||
<div className="flex h-36 min-w-max items-end gap-1 border-b border-border/40 pb-1">
|
||||
{daily.map((row) => {
|
||||
const v = Number(row[field]) || 0;
|
||||
const h = Math.round((v / max) * 100);
|
||||
@@ -86,12 +91,24 @@ function DailyBars({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AnalyticsPage(_props: { embedded?: boolean }) {
|
||||
const { t } = useI18n();
|
||||
|
||||
const tr = (key: I18nKey, vars?: Record<string, string>) => {
|
||||
let s = t(key);
|
||||
if (!vars) return s;
|
||||
for (const k of Object.keys(vars)) {
|
||||
s = s.replaceAll(`{{${k}}}`, vars[k] ?? "");
|
||||
}
|
||||
return s;
|
||||
};
|
||||
|
||||
const [from, setFrom] = useState(() => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - 6);
|
||||
@@ -118,40 +135,36 @@ export default function AnalyticsPage(_props: { embedded?: boolean }) {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const t = data?.totals;
|
||||
const totals = data?.totals;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col min-h-0 overflow-auto p-4 max-w-6xl mx-auto w-full"
|
||||
>
|
||||
<div className="mb-6 flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">数据报表</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
访客小窗与 AI 客服统计(按上海时区自然日,不含「知识库测试」内部会话)
|
||||
</p>
|
||||
<div className="mx-auto flex min-h-0 w-full max-w-6xl flex-col overflow-auto p-3 sm:p-4 md:p-6">
|
||||
<div className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-xl font-semibold tracking-tight">{t("agent.analytics.title")}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("agent.analytics.subtitle")}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 ml-auto">
|
||||
<label className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
从
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center lg:ml-auto">
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground sm:gap-1">
|
||||
<span className="shrink-0">{t("agent.analytics.from")}</span>
|
||||
<input
|
||||
type="date"
|
||||
value={from}
|
||||
onChange={(e) => setFrom(e.target.value)}
|
||||
className="rounded-md border border-input bg-background px-2 py-1 text-sm"
|
||||
className="min-w-0 flex-1 rounded-md border border-input bg-background px-2 py-1.5 text-sm sm:flex-initial"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
到
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground sm:gap-1">
|
||||
<span className="shrink-0">{t("agent.analytics.to")}</span>
|
||||
<input
|
||||
type="date"
|
||||
value={to}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
className="rounded-md border border-input bg-background px-2 py-1 text-sm"
|
||||
className="min-w-0 flex-1 rounded-md border border-input bg-background px-2 py-1.5 text-sm sm:flex-initial"
|
||||
/>
|
||||
</label>
|
||||
<Button size="sm" onClick={() => void load()} disabled={loading}>
|
||||
{loading ? "加载中…" : "查询"}
|
||||
<Button className="w-full sm:w-auto" size="sm" onClick={() => void load()} disabled={loading}>
|
||||
{loading ? t("agent.analytics.loading") : t("agent.analytics.query")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -160,54 +173,92 @@ export default function AnalyticsPage(_props: { embedded?: boolean }) {
|
||||
<p className="text-xs text-muted-foreground mb-4">{data.note}</p>
|
||||
)}
|
||||
|
||||
{t && (
|
||||
{totals && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3 mb-6">
|
||||
<StatCard title="小窗打开次数" value={t.widget_opens} sub="需前端埋点,历史数据可能为 0" />
|
||||
<StatCard title="新建会话数" value={t.sessions} />
|
||||
<StatCard title="消息数" value={t.messages} />
|
||||
<StatCard title="AI 回复次数" value={t.ai_replies} />
|
||||
<StatCard title="AI 失败次数" value={t.ai_failed} />
|
||||
<StatCard title="AI 失败率" value={formatPercent(t.ai_failure_rate_percent)} sub="占 AI 回复条数" />
|
||||
<StatCard title="知识库命中次数" value={t.kb_hits} />
|
||||
<StatCard title="知识库命中率" value={formatPercent(t.kb_hit_rate_percent)} sub="占成功 AI 回复" />
|
||||
<StatCard title="最大 AI 对话轮数" value={t.max_ai_rounds} sub="单会话内用户+AI 一轮" />
|
||||
<StatCard title="AI 参与会话" value={t.sessions_with_ai} sub={`占新建会话 ${formatPercent(t.ai_participation_rate_percent)}`} />
|
||||
<StatCard title="AI→人工(会话数)" value={t.ai_to_human_sessions} sub={`占有过 AI 发言的会话 ${formatPercent(t.ai_to_human_rate_percent)}`} />
|
||||
<StatCard title="人工→AI(会话数)" value={t.human_to_ai_sessions} sub={`占有过人工发言的会话 ${formatPercent(t.human_to_ai_rate_percent)}`} />
|
||||
<StatCard
|
||||
title={t("agent.analytics.stat.widgetOpens")}
|
||||
value={totals.widget_opens}
|
||||
sub={t("agent.analytics.stat.widgetOpensSub")}
|
||||
/>
|
||||
<StatCard title={t("agent.analytics.stat.sessions")} value={totals.sessions} />
|
||||
<StatCard title={t("agent.analytics.stat.messages")} value={totals.messages} />
|
||||
<StatCard title={t("agent.analytics.stat.aiReplies")} value={totals.ai_replies} />
|
||||
<StatCard title={t("agent.analytics.stat.aiFailed")} value={totals.ai_failed} />
|
||||
<StatCard
|
||||
title={t("agent.analytics.stat.aiFailureRate")}
|
||||
value={formatPercent(totals.ai_failure_rate_percent)}
|
||||
sub={t("agent.analytics.stat.aiFailureRateSub")}
|
||||
/>
|
||||
<StatCard title={t("agent.analytics.stat.kbHits")} value={totals.kb_hits} />
|
||||
<StatCard
|
||||
title={t("agent.analytics.stat.kbHitRate")}
|
||||
value={formatPercent(totals.kb_hit_rate_percent)}
|
||||
sub={t("agent.analytics.stat.kbHitRateSub")}
|
||||
/>
|
||||
<StatCard
|
||||
title={t("agent.analytics.stat.maxAiRounds")}
|
||||
value={totals.max_ai_rounds}
|
||||
sub={t("agent.analytics.stat.maxAiRoundsSub")}
|
||||
/>
|
||||
<StatCard
|
||||
title={t("agent.analytics.stat.sessionsWithAi")}
|
||||
value={totals.sessions_with_ai}
|
||||
sub={tr("agent.analytics.stat.sessionsWithAiSub", {
|
||||
pct: formatPercent(totals.ai_participation_rate_percent),
|
||||
})}
|
||||
/>
|
||||
<StatCard
|
||||
title={t("agent.analytics.stat.aiToHuman")}
|
||||
value={totals.ai_to_human_sessions}
|
||||
sub={tr("agent.analytics.stat.aiToHumanSub", {
|
||||
pct: formatPercent(totals.ai_to_human_rate_percent),
|
||||
})}
|
||||
/>
|
||||
<StatCard
|
||||
title={t("agent.analytics.stat.humanToAi")}
|
||||
value={totals.human_to_ai_sessions}
|
||||
sub={tr("agent.analytics.stat.humanToAiSub", {
|
||||
pct: formatPercent(totals.human_to_ai_rate_percent),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 rounded-xl border border-border/60 bg-card p-4">
|
||||
<div className="grid grid-cols-1 gap-6 rounded-xl border border-border/60 bg-card p-3 sm:p-4 md:gap-8 lg:grid-cols-2">
|
||||
<DailyBars
|
||||
daily={data!.daily}
|
||||
field="widget_opens"
|
||||
label="每日小窗打开"
|
||||
label={t("agent.analytics.chart.widgetOpens")}
|
||||
color="rgb(34 197 94)"
|
||||
emptyLabel={t("agent.analytics.empty")}
|
||||
/>
|
||||
<DailyBars
|
||||
daily={data!.daily}
|
||||
field="sessions"
|
||||
label="每日新建会话"
|
||||
label={t("agent.analytics.chart.sessions")}
|
||||
color="rgb(59 130 246)"
|
||||
emptyLabel={t("agent.analytics.empty")}
|
||||
/>
|
||||
<DailyBars
|
||||
daily={data!.daily}
|
||||
field="messages"
|
||||
label="每日消息数"
|
||||
label={t("agent.analytics.chart.messages")}
|
||||
color="rgb(168 85 247)"
|
||||
emptyLabel={t("agent.analytics.empty")}
|
||||
/>
|
||||
<DailyBars
|
||||
daily={data!.daily}
|
||||
field="ai_replies"
|
||||
label="每日 AI 回复"
|
||||
label={t("agent.analytics.chart.aiReplies")}
|
||||
color="rgb(249 115 22)"
|
||||
emptyLabel={t("agent.analytics.empty")}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && !t && (
|
||||
<p className="text-sm text-muted-foreground">暂无数据或加载失败</p>
|
||||
{!loading && !totals && (
|
||||
<p className="text-sm text-muted-foreground">{t("agent.analytics.emptyOrFailed")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -25,10 +25,12 @@ import {
|
||||
import type { WSMessage } from "@/lib/websocket";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { getAgentWSToken } from "@/utils/storage";
|
||||
import { useI18n } from "@/lib/i18n/provider";
|
||||
|
||||
export default function AgentChatPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
const { agent, loading: authLoading } = useAuth();
|
||||
|
||||
@@ -152,6 +154,49 @@ export default function AgentChatPage() {
|
||||
loadMessages();
|
||||
}, [conversationId, agent, loadConversationDetail, loadMessages]);
|
||||
|
||||
// 与 `useMessages` 一致:默认不拉取 AI 分段消息时,访客在 AI 模式下的未读不会出现在列表中,
|
||||
// 仅靠滚动无法 mark,会导致未读数长期残留。
|
||||
useEffect(() => {
|
||||
if (!conversationId || !agent) {
|
||||
return;
|
||||
}
|
||||
if (loadingMessages) {
|
||||
return;
|
||||
}
|
||||
if (conversationDetail && conversationDetail.id !== conversationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const serverUnread = Number(conversationDetail?.unread_count ?? 0);
|
||||
if (serverUnread <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messagesBelongToConv =
|
||||
messages.length === 0 ||
|
||||
messages.every((m) => m.conversation_id === conversationId);
|
||||
if (!messagesBelongToConv) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleVisitorUnread = messages.filter(
|
||||
(msg) => !msg.sender_is_agent && !msg.is_read
|
||||
).length;
|
||||
if (visibleVisitorUnread > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
void handleMarkMessagesRead(conversationId, true);
|
||||
}, [
|
||||
conversationId,
|
||||
agent,
|
||||
loadingMessages,
|
||||
messages,
|
||||
conversationDetail?.id,
|
||||
conversationDetail?.unread_count,
|
||||
handleMarkMessagesRead,
|
||||
]);
|
||||
|
||||
const handleNewMessage = useCallback(
|
||||
(message: MessageItem) => {
|
||||
setMessages((prev) => {
|
||||
@@ -360,7 +405,7 @@ export default function AgentChatPage() {
|
||||
if (authLoading || !agent) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 text-gray-600">
|
||||
加载中...
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -378,7 +423,7 @@ export default function AgentChatPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
← 返回
|
||||
← {t("agent.settings.backDashboard")}
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<ChatHeader
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiUrl } from "@/lib/config";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { I18nKey } from "@/lib/i18n/dict";
|
||||
import { useI18n } from "@/lib/i18n/provider";
|
||||
|
||||
// 对话类型定义
|
||||
interface Conversation {
|
||||
@@ -15,12 +17,24 @@ interface Conversation {
|
||||
}
|
||||
|
||||
export default function ConversationsPage() {
|
||||
const { t, lang } = useI18n();
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [username, setUsername] = useState<string>("");
|
||||
const [role, setRole] = useState<string>("");
|
||||
const router = useRouter();
|
||||
|
||||
const tr = (key: I18nKey, vars?: Record<string, string>) => {
|
||||
let s = t(key);
|
||||
if (!vars) return s;
|
||||
for (const k of Object.keys(vars)) {
|
||||
s = s.replaceAll(`{{${k}}}`, vars[k] ?? "");
|
||||
}
|
||||
return s;
|
||||
};
|
||||
|
||||
const locale = lang === "en" ? "en-US" : "zh-CN";
|
||||
|
||||
// 检查是否已登录
|
||||
useEffect(() => {
|
||||
const userId = localStorage.getItem("agent_user_id");
|
||||
@@ -90,13 +104,13 @@ export default function ConversationsPage() {
|
||||
|
||||
// 今天:只显示时间
|
||||
if (diff < 24 * 3600 * 1000 && date.getDate() === now.getDate()) {
|
||||
return date.toLocaleTimeString("zh-CN", {
|
||||
return date.toLocaleTimeString(locale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
// 更早:显示日期+时间
|
||||
return date.toLocaleString("zh-CN", {
|
||||
return date.toLocaleString(locale, {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
@@ -104,6 +118,12 @@ export default function ConversationsPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const statusLabel = (status: string) => {
|
||||
if (status === "open") return t("agent.conversations.status.open");
|
||||
if (status === "closed") return t("agent.conversations.status.closed");
|
||||
return status;
|
||||
};
|
||||
|
||||
// 点击对话,跳转到聊天页面
|
||||
const handleConversationClick = (conversationId: number) => {
|
||||
router.push(`/agent/chat/${conversationId}`);
|
||||
@@ -123,9 +143,11 @@ export default function ConversationsPage() {
|
||||
<div className="bg-card border-b p-4 shadow-sm">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-foreground">对话列表</h1>
|
||||
<h1 className="text-xl font-bold text-foreground">{t("agent.conversationsPage.title")}</h1>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
{username} ({role === "admin" ? "管理员" : "客服"})
|
||||
{username} (
|
||||
{role === "admin" ? t("agent.users.role.admin") : t("agent.users.role.agent")}
|
||||
)
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
@@ -133,7 +155,7 @@ export default function ConversationsPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
退出登录
|
||||
{t("agent.logout")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -142,7 +164,7 @@ export default function ConversationsPage() {
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{conversations.length === 0 ? (
|
||||
<div className="text-center text-gray-400 mt-8">
|
||||
暂无对话
|
||||
{t("agent.conversationsPage.empty")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
@@ -156,7 +178,7 @@ export default function ConversationsPage() {
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-gray-800">
|
||||
对话 #{conv.id}
|
||||
{tr("agent.conversationsPage.convLabel", { id: String(conv.id) })}
|
||||
</span>
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs ${
|
||||
@@ -165,18 +187,24 @@ export default function ConversationsPage() {
|
||||
: "bg-gray-100 text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{conv.status === "open" ? "进行中" : conv.status}
|
||||
{statusLabel(conv.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
访客ID: {conv.visitor_id}
|
||||
{tr("agent.conversationsPage.visitorLabel", {
|
||||
id: String(conv.visitor_id),
|
||||
})}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
创建时间: {formatTime(conv.created_at)}
|
||||
{tr("agent.conversationsPage.createdAt", {
|
||||
time: formatTime(conv.created_at),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
最后更新: {formatTime(conv.updated_at)}
|
||||
{tr("agent.conversationsPage.updatedAt", {
|
||||
time: formatTime(conv.updated_at),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Suspense } from "react";
|
||||
import { DashboardShell } from "@/components/dashboard/DashboardShell";
|
||||
import { DashboardSuspenseFallback } from "@/components/dashboard/DashboardSuspenseFallback";
|
||||
|
||||
export default function AgentDashboardPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex justify-center items-center min-h-screen bg-background"><div className="text-muted-foreground">加载中...</div></div>}>
|
||||
<Suspense fallback={<DashboardSuspenseFallback />}>
|
||||
<DashboardShell />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -35,11 +35,23 @@ import {
|
||||
} from "lucide-react";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { I18nKey } from "@/lib/i18n/dict";
|
||||
import { useI18n } from "@/lib/i18n/provider";
|
||||
|
||||
export default function FAQsPage(props: any = {}) {
|
||||
const { embedded = false } = props;
|
||||
const router = useRouter();
|
||||
const { agent } = useAuth();
|
||||
const { t, lang } = useI18n();
|
||||
|
||||
const tr = (key: I18nKey, vars?: Record<string, string>) => {
|
||||
let s = t(key);
|
||||
if (!vars) return s;
|
||||
for (const k of Object.keys(vars)) {
|
||||
s = s.replaceAll(`{{${k}}}`, vars[k] ?? "");
|
||||
}
|
||||
return s;
|
||||
};
|
||||
const [faqs, setFaqs] = useState<FAQSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
@@ -73,7 +85,7 @@ export default function FAQsPage(props: any = {}) {
|
||||
setFaqs(data);
|
||||
} catch (error) {
|
||||
console.error("加载 FAQ 列表失败:", error);
|
||||
toast.error((error as Error).message || "加载 FAQ 列表失败");
|
||||
toast.error((error as Error).message || t("agent.faqs.toast.loadFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -102,7 +114,7 @@ export default function FAQsPage(props: any = {}) {
|
||||
// 创建 FAQ
|
||||
const handleCreate = async () => {
|
||||
if (!createForm.question.trim() || !createForm.answer.trim()) {
|
||||
toast.error("问题和答案不能为空");
|
||||
toast.error(t("agent.faqs.toast.emptyRequired"));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
@@ -111,9 +123,9 @@ export default function FAQsPage(props: any = {}) {
|
||||
setCreateDialogOpen(false);
|
||||
setCreateForm({ question: "", answer: "", keywords: "" });
|
||||
await loadFAQs();
|
||||
toast.success("创建成功");
|
||||
toast.success(t("agent.faqs.toast.createSuccess"));
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || "创建 FAQ 失败");
|
||||
toast.error((error as Error).message || t("agent.faqs.toast.createFailed"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -136,7 +148,7 @@ export default function FAQsPage(props: any = {}) {
|
||||
return;
|
||||
}
|
||||
if (!editForm.question?.trim() || !editForm.answer?.trim()) {
|
||||
toast.error("问题和答案不能为空");
|
||||
toast.error(t("agent.faqs.toast.emptyRequired"));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
@@ -145,9 +157,9 @@ export default function FAQsPage(props: any = {}) {
|
||||
setEditDialogOpen(false);
|
||||
setSelectedFAQ(null);
|
||||
await loadFAQs();
|
||||
toast.success("更新成功");
|
||||
toast.success(t("agent.faqs.toast.updateSuccess"));
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || "更新 FAQ 失败");
|
||||
toast.error((error as Error).message || t("agent.faqs.toast.updateFailed"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -170,9 +182,9 @@ export default function FAQsPage(props: any = {}) {
|
||||
setDeleteDialogOpen(false);
|
||||
setSelectedFAQ(null);
|
||||
await loadFAQs();
|
||||
toast.success("删除成功");
|
||||
toast.success(t("agent.faqs.toast.deleteSuccess"));
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || "删除 FAQ 失败");
|
||||
toast.error((error as Error).message || t("agent.faqs.toast.deleteFailed"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -181,7 +193,7 @@ export default function FAQsPage(props: any = {}) {
|
||||
// 格式化时间
|
||||
const formatTime = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString("zh-CN", {
|
||||
return date.toLocaleString(lang === "en" ? "en-US" : "zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
@@ -192,16 +204,16 @@ export default function FAQsPage(props: any = {}) {
|
||||
|
||||
// 构建头部内容
|
||||
const headerContent = (
|
||||
<div className="bg-card border-b p-4 shadow-sm">
|
||||
<div className="border-b bg-card p-3 shadow-sm sm:p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-xl font-bold text-foreground">事件管理(FAQ)</h1>
|
||||
<h1 className="text-xl font-bold text-foreground">{t("agent.faqs.title")}</h1>
|
||||
{!embedded && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push("/agent/dashboard")}
|
||||
>
|
||||
返回
|
||||
{t("agent.common.back")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -212,7 +224,7 @@ export default function FAQsPage(props: any = {}) {
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="关键词搜索(用 % 分隔,例如:openai%api%调用)..."
|
||||
placeholder={t("agent.faqs.search.placeholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
@@ -223,7 +235,7 @@ export default function FAQsPage(props: any = {}) {
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
创建事件
|
||||
{t("agent.faqs.createButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -231,15 +243,15 @@ export default function FAQsPage(props: any = {}) {
|
||||
|
||||
// 构建主内容区
|
||||
const mainContent = (
|
||||
<div className="flex-1 overflow-y-auto p-4 scrollbar-auto">
|
||||
<div className="scrollbar-auto flex-1 overflow-y-auto p-3 sm:p-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<span className="text-muted-foreground">加载中...</span>
|
||||
<span className="text-muted-foreground">{t("common.loading")}</span>
|
||||
</div>
|
||||
) : faqs.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<span className="text-muted-foreground">
|
||||
{searchQuery ? "没有找到匹配的事件" : "暂无事件"}
|
||||
{searchQuery ? t("agent.faqs.empty.filtered") : t("agent.faqs.empty")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
@@ -258,11 +270,11 @@ export default function FAQsPage(props: any = {}) {
|
||||
</div>
|
||||
{faq.keywords && (
|
||||
<div className="text-xs text-muted-foreground mb-2">
|
||||
关键词: {faq.keywords}
|
||||
{t("agent.faqs.card.keywords")}: {faq.keywords}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
创建时间: {formatTime(faq.created_at)}
|
||||
{t("agent.faqs.card.createdAt")}: {formatTime(faq.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -274,7 +286,7 @@ export default function FAQsPage(props: any = {}) {
|
||||
className="flex-1"
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-1" />
|
||||
编辑
|
||||
{t("agent.faqs.card.edit")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@@ -291,63 +303,53 @@ export default function FAQsPage(props: any = {}) {
|
||||
</div>
|
||||
);
|
||||
|
||||
// 如果是嵌入模式,只返回内容,不包含 ResponsiveLayout
|
||||
if (embedded) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
{headerContent}
|
||||
{mainContent}
|
||||
</div>
|
||||
{/* 对话框 */}
|
||||
{/* 创建 FAQ 对话框 */}
|
||||
const faqDialogs = (
|
||||
<>
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>创建新事件</DialogTitle>
|
||||
<DialogDescription>
|
||||
填写问题和答案,可以添加关键词以便搜索
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t("agent.faqs.dialog.createTitle2")}</DialogTitle>
|
||||
<DialogDescription>{t("agent.faqs.dialog.createDesc")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="create-question">问题 *</Label>
|
||||
<Label htmlFor="create-question">{t("agent.faqs.form.question")} *</Label>
|
||||
<Textarea
|
||||
id="create-question"
|
||||
value={createForm.question}
|
||||
onChange={(e) =>
|
||||
setCreateForm({ ...createForm, question: e.target.value })
|
||||
}
|
||||
placeholder="请输入问题"
|
||||
placeholder={t("agent.faqs.form.placeholder.question")}
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="create-answer">答案 *</Label>
|
||||
<Label htmlFor="create-answer">{t("agent.faqs.form.answer")} *</Label>
|
||||
<Textarea
|
||||
id="create-answer"
|
||||
value={createForm.answer}
|
||||
onChange={(e) =>
|
||||
setCreateForm({ ...createForm, answer: e.target.value })
|
||||
}
|
||||
placeholder="请输入答案"
|
||||
placeholder={t("agent.faqs.form.placeholder.answer")}
|
||||
rows={6}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="create-keywords">关键词(可选)</Label>
|
||||
<Label htmlFor="create-keywords">{t("agent.faqs.form.keywordsOptional")}</Label>
|
||||
<Input
|
||||
id="create-keywords"
|
||||
value={createForm.keywords}
|
||||
onChange={(e) =>
|
||||
setCreateForm({ ...createForm, keywords: e.target.value })
|
||||
}
|
||||
placeholder="例如:API、错误、配置(用逗号或空格分隔)"
|
||||
placeholder={t("agent.faqs.form.placeholder.keywords")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
提示:即使不填写关键词,系统也会自动搜索问题和答案中的内容。关键词字段用于添加额外的搜索索引,帮助用户更快找到相关内容。
|
||||
{t("agent.faqs.form.keywordsTip")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
@@ -356,65 +358,62 @@ export default function FAQsPage(props: any = {}) {
|
||||
onClick={() => setCreateDialogOpen(false)}
|
||||
disabled={submitting}
|
||||
>
|
||||
取消
|
||||
{t("agent.common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={submitting}>
|
||||
{submitting ? "创建中..." : "创建"}
|
||||
{submitting ? t("agent.faqs.submit.creating") : t("agent.common.create")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 编辑 FAQ 对话框 */}
|
||||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑事件</DialogTitle>
|
||||
<DialogDescription>
|
||||
修改问题和答案,可以更新关键词以便搜索
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t("agent.faqs.dialog.editTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("agent.faqs.dialog.editDesc")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selectedFAQ && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="edit-question">问题 *</Label>
|
||||
<Label htmlFor="edit-question">{t("agent.faqs.form.question")} *</Label>
|
||||
<Textarea
|
||||
id="edit-question"
|
||||
value={editForm.question || ""}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, question: e.target.value })
|
||||
}
|
||||
placeholder="请输入问题"
|
||||
placeholder={t("agent.faqs.form.placeholder.question")}
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-answer">答案 *</Label>
|
||||
<Label htmlFor="edit-answer">{t("agent.faqs.form.answer")} *</Label>
|
||||
<Textarea
|
||||
id="edit-answer"
|
||||
value={editForm.answer || ""}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, answer: e.target.value })
|
||||
}
|
||||
placeholder="请输入答案"
|
||||
placeholder={t("agent.faqs.form.placeholder.answer")}
|
||||
rows={6}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-keywords">关键词(可选)</Label>
|
||||
<Label htmlFor="edit-keywords">{t("agent.faqs.form.keywordsOptional")}</Label>
|
||||
<Input
|
||||
id="edit-keywords"
|
||||
value={editForm.keywords || ""}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, keywords: e.target.value })
|
||||
}
|
||||
placeholder="例如:API、错误、配置(用逗号或空格分隔)"
|
||||
placeholder={t("agent.faqs.form.placeholder.keywords")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
提示:即使不填写关键词,系统也会自动搜索问题和答案中的内容。关键词字段用于添加额外的搜索索引,帮助用户更快找到相关内容。
|
||||
{t("agent.faqs.form.keywordsTip")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
@@ -423,10 +422,10 @@ export default function FAQsPage(props: any = {}) {
|
||||
onClick={() => setEditDialogOpen(false)}
|
||||
disabled={submitting}
|
||||
>
|
||||
取消
|
||||
{t("agent.common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleUpdate} disabled={submitting}>
|
||||
{submitting ? "更新中..." : "更新"}
|
||||
{submitting ? t("common.saving") : t("agent.common.update")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -434,19 +433,18 @@ export default function FAQsPage(props: any = {}) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>删除事件</DialogTitle>
|
||||
<DialogTitle>{t("agent.faqs.dialog.deleteTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedFAQ && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-foreground">
|
||||
确定要删除事件 <strong>"{selectedFAQ.question}"</strong> 吗?
|
||||
{tr("agent.faqs.dialog.deleteConfirm", { name: selectedFAQ.question })}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
此操作不可恢复,请谨慎操作。
|
||||
{t("common.irreversibleHint")}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
@@ -454,14 +452,14 @@ export default function FAQsPage(props: any = {}) {
|
||||
onClick={() => setDeleteDialogOpen(false)}
|
||||
disabled={submitting}
|
||||
>
|
||||
取消
|
||||
{t("agent.common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? "删除中..." : "删除"}
|
||||
{submitting ? t("agent.faqs.submit.deleting") : t("agent.common.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -470,13 +468,24 @@ export default function FAQsPage(props: any = {}) {
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
|
||||
if (embedded) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
{headerContent}
|
||||
{mainContent}
|
||||
</div>
|
||||
{faqDialogs}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveLayout
|
||||
main={mainContent}
|
||||
header={headerContent}
|
||||
/>
|
||||
<>
|
||||
<ResponsiveLayout main={mainContent} header={headerContent} />
|
||||
{faqDialogs}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,8 +5,10 @@ import { apiUrl } from "@/lib/config";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { setAgentWSToken } from "@/utils/storage";
|
||||
import { useI18n } from "@/lib/i18n/provider";
|
||||
|
||||
export default function AgentLoginPage() {
|
||||
const { t } = useI18n();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -18,7 +20,7 @@ export default function AgentLoginPage() {
|
||||
e.preventDefault(); // 阻止默认行为
|
||||
|
||||
if (!username || !password) {
|
||||
setError("用户名和密码不能为空");
|
||||
setError(t("agent.login.error.empty"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -51,11 +53,11 @@ export default function AgentLoginPage() {
|
||||
router.push("/agent/dashboard");
|
||||
} else {
|
||||
// 登录失败,显示错误信息
|
||||
setError(data.error || data.message || "登录失败");
|
||||
setError(data.error || data.message || t("agent.login.error.failed"));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("登录失败:", error);
|
||||
setError("登录失败,请检查网络连接");
|
||||
setError(t("agent.login.error.network"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -65,16 +67,16 @@ export default function AgentLoginPage() {
|
||||
<div className="flex justify-center items-center min-h-screen bg-background">
|
||||
<div className="bg-card p-8 rounded-lg border shadow-lg w-full sm:w-96">
|
||||
<h1 className="text-center text-2xl font-bold mb-2 text-gray-800">
|
||||
客服登录
|
||||
{t("agent.login.title")}
|
||||
</h1>
|
||||
<p className="text-center text-sm text-gray-500 mb-6">
|
||||
管理员和客服请在此登录
|
||||
{t("agent.login.subtitle")}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleLogin}>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="用户名"
|
||||
placeholder={t("agent.login.username")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full mb-4"
|
||||
@@ -82,7 +84,7 @@ export default function AgentLoginPage() {
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
placeholder={t("agent.login.password")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full mb-4"
|
||||
@@ -102,12 +104,12 @@ export default function AgentLoginPage() {
|
||||
size="default"
|
||||
className="w-full"
|
||||
>
|
||||
{loading ? "登录中..." : "登录"}
|
||||
{loading ? t("agent.login.submitting") : t("agent.login.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center text-xs text-gray-400">
|
||||
<p>默认管理员账号:admin / admin123</p>
|
||||
<p>{t("agent.login.demoHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Copy } from "lucide-react";
|
||||
import type { I18nKey } from "@/lib/i18n/dict";
|
||||
import { useI18n } from "@/lib/i18n/provider";
|
||||
|
||||
function tryFormatJSON(raw?: string | null): string {
|
||||
if (!raw) return "";
|
||||
@@ -36,6 +38,18 @@ function levelColor(level: string): string {
|
||||
}
|
||||
|
||||
export default function LogsPage({ embedded = false }: { embedded?: boolean }) {
|
||||
const { t, lang } = useI18n();
|
||||
|
||||
const tr = (key: I18nKey, vars?: Record<string, string>) => {
|
||||
let s = t(key);
|
||||
if (!vars) return s;
|
||||
for (const k of Object.keys(vars)) {
|
||||
s = s.replaceAll(`{{${k}}}`, vars[k] ?? "");
|
||||
}
|
||||
return s;
|
||||
};
|
||||
|
||||
const locale = lang === "en" ? "en-US" : "zh-CN";
|
||||
const [from, setFrom] = useState(() => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - 6);
|
||||
@@ -66,12 +80,12 @@ export default function LogsPage({ embedded = false }: { embedded?: boolean }) {
|
||||
setPolicy(p);
|
||||
setPolicyDraft(p.effective_min_level);
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message || "加载落库策略失败");
|
||||
toast.error((e as Error).message || t("agent.logs.toast.loadPolicyFailed"));
|
||||
setPolicy(null);
|
||||
} finally {
|
||||
setPolicyLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPolicy();
|
||||
@@ -95,12 +109,12 @@ export default function LogsPage({ embedded = false }: { embedded?: boolean }) {
|
||||
});
|
||||
setData(res);
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message || "加载日志失败");
|
||||
toast.error((e as Error).message || t("agent.logs.toast.loadLogsFailed"));
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [from, to, level, category, source, event, keyword, conversationId, page]);
|
||||
}, [from, to, level, category, source, event, keyword, conversationId, page, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
@@ -112,28 +126,26 @@ export default function LogsPage({ embedded = false }: { embedded?: boolean }) {
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col min-h-0 overflow-auto ${embedded ? "p-4" : "p-6 max-w-6xl mx-auto w-full"}`}>
|
||||
<div
|
||||
className={`flex min-h-0 flex-col overflow-auto ${embedded ? "p-3 sm:p-4" : "w-full max-w-6xl p-4 sm:p-6 mx-auto"}`}
|
||||
>
|
||||
<div className="mb-4">
|
||||
<h1 className="text-xl font-semibold">日志中心</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">按分类查看 AI / RAG / 系统 / 前端日志,用于排障定位。</p>
|
||||
<h1 className="text-xl font-semibold">{t("agent.logs.title")}</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">{t("agent.logs.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border/60 bg-card p-4 mb-4 space-y-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">落库级别(性能)</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 max-w-xl">
|
||||
仅将不低于所选级别的记录写入数据库。设为 <code className="text-foreground">warn</code> 可大幅减少成功类{" "}
|
||||
<code className="text-foreground">info</code> 写入。也可在根目录{" "}
|
||||
<code className="text-foreground">SYSTEM_LOG_MIN_LEVEL</code> 配置默认值;此处保存后会写入数据库并覆盖环境变量,直至点击「恢复环境变量」。
|
||||
</p>
|
||||
<h2 className="text-sm font-semibold">{t("agent.logs.policy.title")}</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 max-w-xl">{t("agent.logs.policy.desc")}</p>
|
||||
{policy ? (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
当前生效:<span className="font-medium text-foreground">{policy.effective_min_level}</span>
|
||||
{t("agent.logs.policy.current")}<span className="font-medium text-foreground">{policy.effective_min_level}</span>
|
||||
{" · "}
|
||||
环境变量默认:<span className="font-medium text-foreground">{policy.env_min_level}</span>
|
||||
{t("agent.logs.policy.env")}<span className="font-medium text-foreground">{policy.env_min_level}</span>
|
||||
{policy.persisted_in_database ? (
|
||||
<span className="text-amber-700 dark:text-amber-500">(已由控制台覆盖)</span>
|
||||
<span className="text-amber-700 dark:text-amber-500">{t("agent.logs.policy.overridden")}</span>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -149,7 +161,7 @@ export default function LogsPage({ embedded = false }: { embedded?: boolean }) {
|
||||
<option value="info">info</option>
|
||||
<option value="warn">warn</option>
|
||||
<option value="error">error</option>
|
||||
<option value="none">none(关闭落库)</option>
|
||||
<option value="none">none</option>
|
||||
</select>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -158,16 +170,16 @@ export default function LogsPage({ embedded = false }: { embedded?: boolean }) {
|
||||
setPolicyLoading(true);
|
||||
try {
|
||||
await putLogMinLevelPolicy(policyDraft);
|
||||
toast.success("已保存并生效");
|
||||
toast.success("OK");
|
||||
await loadPolicy();
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message || "保存失败");
|
||||
toast.error((e as Error).message || t("agent.logs.toast.savePolicyFailed"));
|
||||
} finally {
|
||||
setPolicyLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
保存到服务器
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -177,33 +189,33 @@ export default function LogsPage({ embedded = false }: { embedded?: boolean }) {
|
||||
setPolicyLoading(true);
|
||||
try {
|
||||
await deleteLogMinLevelPolicy();
|
||||
toast.success("已恢复为环境变量默认值");
|
||||
toast.success(t("agent.logs.toast.policyRestored"));
|
||||
await loadPolicy();
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message || "恢复失败");
|
||||
toast.error((e as Error).message || t("agent.logs.toast.restorePolicyFailed"));
|
||||
} finally {
|
||||
setPolicyLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
恢复环境变量
|
||||
{t("common.restoreEnv")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border/60 bg-card p-3 mb-4 flex flex-wrap gap-2 items-center">
|
||||
<div className="mb-4 flex flex-col gap-2 rounded-xl border border-border/60 bg-card p-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="rounded-md border px-2 py-1 text-sm" />
|
||||
<span className="text-xs text-muted-foreground">到</span>
|
||||
<span className="text-xs text-muted-foreground">{t("common.to")}</span>
|
||||
<input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="rounded-md border px-2 py-1 text-sm" />
|
||||
<select value={level} onChange={(e) => setLevel(e.target.value)} className="rounded-md border px-2 py-1 text-sm">
|
||||
<option value="">全部级别</option>
|
||||
<option value="">{t("agent.logs.level.all")}</option>
|
||||
<option value="info">info</option>
|
||||
<option value="warn">warn</option>
|
||||
<option value="error">error</option>
|
||||
</select>
|
||||
<select value={category} onChange={(e) => setCategory(e.target.value)} className="rounded-md border px-2 py-1 text-sm">
|
||||
<option value="">全部分类</option>
|
||||
<option value="">{t("agent.logs.category.all")}</option>
|
||||
<option value="ai">ai</option>
|
||||
<option value="rag">rag</option>
|
||||
<option value="frontend">frontend</option>
|
||||
@@ -213,48 +225,52 @@ export default function LogsPage({ embedded = false }: { embedded?: boolean }) {
|
||||
<option value="vector">vector</option>
|
||||
</select>
|
||||
<select value={source} onChange={(e) => setSource(e.target.value)} className="rounded-md border px-2 py-1 text-sm">
|
||||
<option value="">全部来源</option>
|
||||
<option value="">{t("agent.logs.source.all")}</option>
|
||||
<option value="backend">backend</option>
|
||||
<option value="frontend">frontend</option>
|
||||
</select>
|
||||
<input
|
||||
placeholder="事件名(event)"
|
||||
placeholder={t("agent.logs.event.placeholder")}
|
||||
value={event}
|
||||
onChange={(e) => setEvent(e.target.value)}
|
||||
className="rounded-md border px-2 py-1 text-sm min-w-[180px]"
|
||||
/>
|
||||
<input
|
||||
placeholder="会话ID"
|
||||
placeholder={t("agent.logs.conversationId.placeholder")}
|
||||
value={conversationId}
|
||||
onChange={(e) => setConversationId(e.target.value)}
|
||||
className="rounded-md border px-2 py-1 text-sm w-24"
|
||||
/>
|
||||
<input
|
||||
placeholder="关键词(message/meta)"
|
||||
placeholder={t("agent.logs.keyword.placeholder")}
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
className="rounded-md border px-2 py-1 text-sm min-w-[220px]"
|
||||
/>
|
||||
<Button size="sm" disabled={loading} onClick={() => { setPage(1); void load(); }}>
|
||||
{loading ? "加载中..." : "查询"}
|
||||
{loading ? t("common.loading") : t("common.search")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border/60 bg-card overflow-hidden">
|
||||
<div className="px-3 py-2 border-b text-xs text-muted-foreground">
|
||||
共 {data?.total ?? 0} 条,当前第 {data?.page ?? page}/{totalPages} 页
|
||||
<div className="border-b px-3 py-2 text-xs text-muted-foreground">
|
||||
{tr("agent.logs.paginationSummary", {
|
||||
total: String(data?.total ?? 0),
|
||||
page: String(data?.page ?? page),
|
||||
pages: String(totalPages),
|
||||
})}
|
||||
</div>
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[720px] text-sm">
|
||||
<thead className="bg-muted/40 text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2">时间</th>
|
||||
<th className="text-left px-3 py-2">级别</th>
|
||||
<th className="text-left px-3 py-2">分类</th>
|
||||
<th className="text-left px-3 py-2">事件</th>
|
||||
<th className="text-left px-3 py-2">会话</th>
|
||||
<th className="text-left px-3 py-2">来源</th>
|
||||
<th className="text-left px-3 py-2">消息</th>
|
||||
<th className="text-left px-3 py-2">{t("agent.logs.table.time")}</th>
|
||||
<th className="text-left px-3 py-2">{t("agent.logs.table.level")}</th>
|
||||
<th className="text-left px-3 py-2">{t("agent.logs.table.category")}</th>
|
||||
<th className="text-left px-3 py-2">{t("agent.logs.table.event")}</th>
|
||||
<th className="text-left px-3 py-2">{t("agent.logs.table.conversation")}</th>
|
||||
<th className="text-left px-3 py-2">{t("agent.logs.table.source")}</th>
|
||||
<th className="text-left px-3 py-2">{t("agent.logs.table.message")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -264,7 +280,9 @@ export default function LogsPage({ embedded = false }: { embedded?: boolean }) {
|
||||
className="border-t cursor-pointer hover:bg-muted/30"
|
||||
onClick={() => setSelected(item)}
|
||||
>
|
||||
<td className="px-3 py-2 whitespace-nowrap text-xs">{new Date(item.timestamp).toLocaleString()}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap text-xs">
|
||||
{new Date(item.timestamp).toLocaleString(locale)}
|
||||
</td>
|
||||
<td className={`px-3 py-2 font-medium ${levelColor(item.level)}`}>{item.level}</td>
|
||||
<td className="px-3 py-2">{item.category}</td>
|
||||
<td className="px-3 py-2">{item.event}</td>
|
||||
@@ -275,7 +293,9 @@ export default function LogsPage({ embedded = false }: { embedded?: boolean }) {
|
||||
))}
|
||||
{(data?.items ?? []).length === 0 && !loading && (
|
||||
<tr>
|
||||
<td className="px-3 py-8 text-center text-muted-foreground" colSpan={7}>暂无日志</td>
|
||||
<td className="px-3 py-8 text-center text-muted-foreground" colSpan={7}>
|
||||
{t("agent.logs.empty")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
@@ -288,7 +308,7 @@ export default function LogsPage({ embedded = false }: { embedded?: boolean }) {
|
||||
disabled={loading || page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
上一页
|
||||
{t("common.prevPage")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -296,7 +316,7 @@ export default function LogsPage({ embedded = false }: { embedded?: boolean }) {
|
||||
disabled={loading || page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
下一页
|
||||
{t("common.nextPage")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -310,7 +330,7 @@ export default function LogsPage({ embedded = false }: { embedded?: boolean }) {
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<span>日志详情</span>
|
||||
<span>{t("agent.logs.detail.title")}</span>
|
||||
{selected ? (
|
||||
<span className={`text-xs px-2 py-0.5 rounded border ${selected.level === "error" ? "border-red-200 text-red-700" : selected.level === "warn" ? "border-amber-200 text-amber-700" : "border-emerald-200 text-emerald-700"}`}>
|
||||
{selected.level}
|
||||
@@ -323,29 +343,31 @@ export default function LogsPage({ embedded = false }: { embedded?: boolean }) {
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
|
||||
<div className="rounded-lg border p-2">
|
||||
<div className="text-xs text-muted-foreground">时间</div>
|
||||
<div className="font-medium">{new Date(selected.timestamp).toLocaleString()}</div>
|
||||
<div className="text-xs text-muted-foreground">{t("agent.logs.detail.time")}</div>
|
||||
<div className="font-medium">
|
||||
{new Date(selected.timestamp).toLocaleString(locale)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-2">
|
||||
<div className="text-xs text-muted-foreground">source / event</div>
|
||||
<div className="text-xs text-muted-foreground">{t("agent.logs.detail.sourceEvent")}</div>
|
||||
<div className="font-medium">
|
||||
{selected.source} / {selected.event}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-2">
|
||||
<div className="text-xs text-muted-foreground">category</div>
|
||||
<div className="text-xs text-muted-foreground">{t("agent.logs.detail.category")}</div>
|
||||
<div className="font-medium">{selected.category}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-2">
|
||||
<div className="text-xs text-muted-foreground">trace_id</div>
|
||||
<div className="text-xs text-muted-foreground">{t("agent.logs.detail.traceId")}</div>
|
||||
<div className="font-medium break-all">{selected.trace_id || "-"}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-2">
|
||||
<div className="text-xs text-muted-foreground">conversation_id</div>
|
||||
<div className="text-xs text-muted-foreground">{t("agent.logs.detail.conversationId")}</div>
|
||||
<div className="font-medium">{selected.conversation_id ?? "-"}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-2">
|
||||
<div className="text-xs text-muted-foreground">user_id / visitor_id</div>
|
||||
<div className="text-xs text-muted-foreground">{t("agent.logs.detail.userVisitor")}</div>
|
||||
<div className="font-medium">
|
||||
{selected.user_id ?? "-"} / {selected.visitor_id ?? "-"}
|
||||
</div>
|
||||
@@ -354,30 +376,30 @@ export default function LogsPage({ embedded = false }: { embedded?: boolean }) {
|
||||
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<div className="text-sm font-medium">message</div>
|
||||
<div className="text-sm font-medium">{t("agent.logs.detail.message")}</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(selected.message);
|
||||
toast.success("已复制 message");
|
||||
toast.success(t("agent.logs.toast.messageCopied"));
|
||||
} catch {
|
||||
toast.error("复制失败");
|
||||
toast.error(t("agent.logs.toast.copyFailed"));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-1" />
|
||||
复制
|
||||
{t("common.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap text-sm bg-muted/30 rounded p-2 max-h-48 overflow-auto">{selected.message}</pre>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="text-sm font-medium mb-2">meta_json</div>
|
||||
<div className="text-sm font-medium mb-2">{t("agent.logs.detail.metaJson")}</div>
|
||||
<pre className="whitespace-pre-wrap text-xs bg-muted/30 rounded p-2 max-h-80 overflow-auto">
|
||||
{selectedMeta || "(无 meta_json)"}
|
||||
{selectedMeta || t("agent.logs.detail.noMeta")}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,43 +7,26 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { fetchPrompts, updatePrompt, type PromptItem } from "@/features/agent/services/promptsApi";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import type { I18nKey } from "@/lib/i18n/dict";
|
||||
import { useI18n } from "@/lib/i18n/provider";
|
||||
|
||||
function getPlaceholderHint(key: string): string {
|
||||
switch (key) {
|
||||
case "rag_prompt":
|
||||
case "rag_prompt_with_web_optional":
|
||||
return "占位符:{{rag_context}} 为知识库检索内容,{{user_message}} 为用户问题。";
|
||||
case "no_kb_prompt":
|
||||
return "占位符:{{user_message}} 为用户问题。";
|
||||
case "web_search_result_prompt":
|
||||
return "占位符:{{web_context}} 为联网搜索结果,{{user_message}} 为用户问题。(当前流程未使用此模板)";
|
||||
case "no_source_reply":
|
||||
case "ai_fail_reply":
|
||||
return "无占位符,内容将作为完整回复语直接展示给用户。";
|
||||
default:
|
||||
return "请勿删除占位符,保存后由系统替换为实际内容。";
|
||||
}
|
||||
}
|
||||
const PROMPT_HINT_KEYS: Partial<Record<string, I18nKey>> = {
|
||||
rag_prompt: "agent.prompts.hint.rag_prompt",
|
||||
rag_prompt_with_web_optional: "agent.prompts.hint.rag_prompt_with_web_optional",
|
||||
no_kb_prompt: "agent.prompts.hint.no_kb_prompt",
|
||||
web_search_result_prompt: "agent.prompts.hint.web_search_result_prompt",
|
||||
no_source_reply: "agent.prompts.hint.no_source_reply",
|
||||
ai_fail_reply: "agent.prompts.hint.ai_fail_reply",
|
||||
};
|
||||
|
||||
/** 各提示词的使用场景说明(展示在卡片中) */
|
||||
function getUsageScenario(key: string): string {
|
||||
switch (key) {
|
||||
case "rag_prompt":
|
||||
return "有知识库检索结果,且本回合未勾选「联网搜索」时,用此模板拼成 prompt 发给模型。";
|
||||
case "rag_prompt_with_web_optional":
|
||||
return "有知识库检索结果且本回合勾选「联网搜索」时,用此模板并传入联网工具,由模型决定是否调用联网。";
|
||||
case "no_kb_prompt":
|
||||
return "没有知识库检索结果且本回合未走联网时,用此模板让模型仅凭自身知识回答。";
|
||||
case "web_search_result_prompt":
|
||||
return "预留:若将来有「先联网搜再拼成一段 prompt」的流程,会使用此模板。当前未使用。";
|
||||
case "no_source_reply":
|
||||
return "既未命中知识库、也未使用大模型或联网时(如用户关闭了所有数据源),直接向用户展示这句话。";
|
||||
case "ai_fail_reply":
|
||||
return "调用 AI 接口失败(超时、报错等)时,向用户展示这句话。";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
const PROMPT_USAGE_KEYS: Partial<Record<string, I18nKey>> = {
|
||||
rag_prompt: "agent.prompts.usage.rag_prompt",
|
||||
rag_prompt_with_web_optional: "agent.prompts.usage.rag_prompt_with_web_optional",
|
||||
no_kb_prompt: "agent.prompts.usage.no_kb_prompt",
|
||||
web_search_result_prompt: "agent.prompts.usage.web_search_result_prompt",
|
||||
no_source_reply: "agent.prompts.usage.no_source_reply",
|
||||
ai_fail_reply: "agent.prompts.usage.ai_fail_reply",
|
||||
};
|
||||
|
||||
function getTextareaMinHeight(key: string): string {
|
||||
return key === "no_source_reply" || key === "ai_fail_reply" ? "min-h-[80px]" : "min-h-[200px]";
|
||||
@@ -51,6 +34,7 @@ function getTextareaMinHeight(key: string): string {
|
||||
|
||||
export default function PromptsPage({ embedded = false }: { embedded?: boolean }) {
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const [userId, setUserId] = useState<number | null>(null);
|
||||
const [prompts, setPrompts] = useState<PromptItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -75,7 +59,7 @@ export default function PromptsPage({ embedded = false }: { embedded?: boolean }
|
||||
setPrompts(data);
|
||||
} catch (e) {
|
||||
console.error("加载提示词失败:", e);
|
||||
setError((e as Error).message || "加载提示词失败");
|
||||
setError((e as Error).message || t("agent.prompts.loadFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -90,10 +74,10 @@ export default function PromptsPage({ embedded = false }: { embedded?: boolean }
|
||||
setSavingKey(key);
|
||||
try {
|
||||
await updatePrompt(userId, key, content);
|
||||
toast.success("保存成功,将立即生效。");
|
||||
toast.success(t("agent.prompts.saveSuccess"));
|
||||
await loadPrompts();
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message || "保存失败");
|
||||
toast.error((e as Error).message || t("agent.prompts.saveFailed"));
|
||||
} finally {
|
||||
setSavingKey(null);
|
||||
}
|
||||
@@ -108,12 +92,12 @@ export default function PromptsPage({ embedded = false }: { embedded?: boolean }
|
||||
if (!userId) return null;
|
||||
|
||||
const headerContent = (
|
||||
<div className="bg-card border-b p-4 shadow-sm">
|
||||
<div className="border-b bg-card p-3 shadow-sm sm:p-4">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-foreground">提示词</h1>
|
||||
<h1 className="text-xl font-bold text-foreground">{t("agent.prompts.title")}</h1>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
配置系统中使用的提示词模板,用于 RAG、联网等场景。仅管理员可修改。占位符说明见下方各卡片。
|
||||
{t("agent.prompts.subtitle")}
|
||||
</div>
|
||||
</div>
|
||||
{!embedded && (
|
||||
@@ -122,7 +106,7 @@ export default function PromptsPage({ embedded = false }: { embedded?: boolean }
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
返回工作台
|
||||
{t("agent.settings.backDashboard")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -130,7 +114,7 @@ export default function PromptsPage({ embedded = false }: { embedded?: boolean }
|
||||
);
|
||||
|
||||
const mainContent = (
|
||||
<div className="flex-1 overflow-auto p-4 md:p-6">
|
||||
<div className="flex-1 overflow-auto p-3 sm:p-4 md:p-6">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-md text-red-600 text-sm">
|
||||
@@ -138,38 +122,46 @@ export default function PromptsPage({ embedded = false }: { embedded?: boolean }
|
||||
</div>
|
||||
)}
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-muted-foreground">加载中...</div>
|
||||
<div className="text-center py-12 text-muted-foreground">{t("common.loading")}</div>
|
||||
) : (
|
||||
prompts.map((item) => (
|
||||
<Card key={item.key}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{item.name}</CardTitle>
|
||||
{getUsageScenario(item.key) && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
<span className="font-medium">使用场景:</span>
|
||||
{getUsageScenario(item.key)}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">{getPlaceholderHint(item.key)}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<textarea
|
||||
className={`w-full ${getTextareaMinHeight(item.key)} px-3 py-2 border border-input rounded-md text-sm bg-background font-mono resize-y`}
|
||||
value={item.content}
|
||||
onChange={(e) => handleContentChange(item.key, e.target.value)}
|
||||
placeholder={item.key === "no_source_reply" || item.key === "ai_fail_reply" ? "请输入一句完整回复语" : "请输入提示词内容,保留占位符"}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSave(item.key, item.content)}
|
||||
disabled={savingKey === item.key}
|
||||
>
|
||||
{savingKey === item.key ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
prompts.map((item) => {
|
||||
const usageKey = PROMPT_USAGE_KEYS[item.key];
|
||||
const hintKey = PROMPT_HINT_KEYS[item.key] ?? "agent.prompts.hint.default";
|
||||
return (
|
||||
<Card key={item.key}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{item.name}</CardTitle>
|
||||
{usageKey && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
<span className="font-medium">{t("agent.prompts.usageLabel")}</span>
|
||||
{t(usageKey)}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">{t(hintKey)}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<textarea
|
||||
className={`w-full ${getTextareaMinHeight(item.key)} px-3 py-2 border border-input rounded-md text-sm bg-background font-mono resize-y`}
|
||||
value={item.content}
|
||||
onChange={(e) => handleContentChange(item.key, e.target.value)}
|
||||
placeholder={
|
||||
item.key === "no_source_reply" || item.key === "ai_fail_reply"
|
||||
? t("agent.prompts.ph.shortReply")
|
||||
: t("agent.prompts.ph.withPlaceholders")
|
||||
}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSave(item.key, item.content)}
|
||||
disabled={savingKey === item.key}
|
||||
>
|
||||
{savingKey === item.key ? t("agent.prompts.saving") : t("agent.prompts.save")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,10 +26,24 @@ import { apiUrl } from "@/lib/config";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import type { I18nKey } from "@/lib/i18n/dict";
|
||||
import { useI18n } from "@/lib/i18n/provider";
|
||||
|
||||
export default function SettingsPage(props: any = {}) {
|
||||
const { embedded = false } = props;
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
const modelTypeLabel = (mt: string) => {
|
||||
const map: Record<string, I18nKey> = {
|
||||
text: "agent.settings.modelType.text",
|
||||
image: "agent.settings.modelType.image",
|
||||
audio: "agent.settings.modelType.audio",
|
||||
video: "agent.settings.modelType.video",
|
||||
};
|
||||
const k = map[mt];
|
||||
return k ? t(k) : mt;
|
||||
};
|
||||
const [userId, setUserId] = useState<number | null>(null);
|
||||
const [configs, setConfigs] = useState<AIConfig[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -91,7 +105,7 @@ export default function SettingsPage(props: any = {}) {
|
||||
setConfigs(data);
|
||||
} catch (error) {
|
||||
console.error("加载配置失败:", error);
|
||||
setError("加载配置失败");
|
||||
setError(t("agent.settings.error.loadConfigs"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -121,7 +135,7 @@ export default function SettingsPage(props: any = {}) {
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("加载知识库向量配置失败:", e);
|
||||
setEmbeddingError("加载失败");
|
||||
setEmbeddingError(t("agent.settings.error.loadEmbedding"));
|
||||
} finally {
|
||||
setEmbeddingLoading(false);
|
||||
}
|
||||
@@ -153,7 +167,7 @@ export default function SettingsPage(props: any = {}) {
|
||||
}
|
||||
await updateEmbeddingConfig(userId, data);
|
||||
await loadEmbeddingConfig();
|
||||
toast.success("保存成功,配置已立即生效。");
|
||||
toast.success(t("agent.settings.toast.embeddingSaved"));
|
||||
} catch (err) {
|
||||
setEmbeddingError((err as Error).message);
|
||||
} finally {
|
||||
@@ -224,7 +238,7 @@ export default function SettingsPage(props: any = {}) {
|
||||
resetForm();
|
||||
await loadConfigs();
|
||||
} catch (error) {
|
||||
setError((error as Error).message || "操作失败");
|
||||
setError((error as Error).message || t("agent.settings.error.operation"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -233,13 +247,13 @@ export default function SettingsPage(props: any = {}) {
|
||||
// 删除配置
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!userId) return;
|
||||
if (!confirm("确定要删除这个配置吗?")) return;
|
||||
if (!confirm(t("agent.settings.confirmDeleteConfig"))) return;
|
||||
|
||||
try {
|
||||
await deleteAIConfig(userId, id);
|
||||
await loadConfigs();
|
||||
} catch (error) {
|
||||
setError((error as Error).message || "删除失败");
|
||||
setError((error as Error).message || t("agent.settings.error.delete"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -263,11 +277,11 @@ export default function SettingsPage(props: any = {}) {
|
||||
|
||||
// 构建头部内容
|
||||
const headerContent = (
|
||||
<div className="bg-card border-b p-4 shadow-sm">
|
||||
<div className="border-b bg-card p-3 shadow-sm sm:p-4">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-foreground">AI 配置管理</h1>
|
||||
<div className="text-sm text-muted-foreground mt-1">管理 AI 服务商配置</div>
|
||||
<h1 className="text-xl font-bold text-foreground">{t("agent.settings.title")}</h1>
|
||||
<div className="text-sm text-muted-foreground mt-1">{t("agent.settings.subtitle")}</div>
|
||||
</div>
|
||||
{!embedded && (
|
||||
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
|
||||
@@ -277,7 +291,7 @@ export default function SettingsPage(props: any = {}) {
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
返回工作台
|
||||
{t("agent.settings.backDashboard")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleLogout}
|
||||
@@ -285,7 +299,7 @@ export default function SettingsPage(props: any = {}) {
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
退出登录
|
||||
{t("agent.logout")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -295,12 +309,12 @@ export default function SettingsPage(props: any = {}) {
|
||||
|
||||
// 构建主内容区
|
||||
const mainContent = (
|
||||
<div className="flex-1 overflow-auto p-4 md:p-6">
|
||||
<div className="flex-1 overflow-auto p-3 sm:p-4 md:p-6">
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
{/* 全局设置 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>全局设置</CardTitle>
|
||||
<CardTitle>{t("agent.settings.section.global")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center space-x-2">
|
||||
@@ -315,7 +329,7 @@ export default function SettingsPage(props: any = {}) {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("更新设置失败:", error);
|
||||
toast.error("更新设置失败,请重试");
|
||||
toast.error(t("agent.settings.toast.profileUpdateFailed"));
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -325,12 +339,11 @@ export default function SettingsPage(props: any = {}) {
|
||||
htmlFor="receive_ai_conversations"
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
客服不接收 AI 对话
|
||||
{t("agent.settings.global.noReceiveAi")}
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
开启后,AI 对话将不会显示在对话列表中,也不会收到 AI 消息通知。
|
||||
但您仍可以在会话页面手动开启"显示 AI 消息"来查看 AI 对话历史。
|
||||
{t("agent.settings.global.noReceiveAiHint")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -338,14 +351,14 @@ export default function SettingsPage(props: any = {}) {
|
||||
{/* 知识库向量模型(平台级,仅管理员可修改;保存后立即生效) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>知识库向量模型</CardTitle>
|
||||
<CardTitle>{t("agent.settings.embedding.title")}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
用于知识库文档向量化与 RAG 检索。仅管理员可修改;保存后立即生效,无需重启。
|
||||
{t("agent.settings.embedding.lead")}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{embeddingLoading ? (
|
||||
<div className="text-center py-6 text-muted-foreground">加载中...</div>
|
||||
<div className="text-center py-6 text-muted-foreground">{t("common.loading")}</div>
|
||||
) : (
|
||||
<form onSubmit={handleSaveEmbeddingConfig} className="space-y-4">
|
||||
{embeddingError && (
|
||||
@@ -355,7 +368,7 @@ export default function SettingsPage(props: any = {}) {
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="block text-sm font-medium mb-1">类型</Label>
|
||||
<Label className="block text-sm font-medium mb-1">{t("agent.settings.embedding.type")}</Label>
|
||||
<select
|
||||
value={embeddingForm.embedding_type}
|
||||
onChange={(e) =>
|
||||
@@ -363,39 +376,43 @@ export default function SettingsPage(props: any = {}) {
|
||||
}
|
||||
className="w-full px-3 py-2 border border-input rounded-md text-sm bg-background"
|
||||
>
|
||||
<option value="openai">OpenAI / 兼容 API</option>
|
||||
<option value="bge">BGE 本地</option>
|
||||
<option value="openai">{t("agent.settings.embedding.openaiCompatible")}</option>
|
||||
<option value="bge">{t("agent.settings.embedding.bgeLocal")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="block text-sm font-medium mb-1">API 地址</Label>
|
||||
<Label className="block text-sm font-medium mb-1">{t("agent.settings.embedding.apiUrl")}</Label>
|
||||
<Input
|
||||
value={embeddingForm.api_url}
|
||||
onChange={(e) =>
|
||||
setEmbeddingForm({ ...embeddingForm, api_url: e.target.value })
|
||||
}
|
||||
placeholder="https://api.openai.com/v1 或兼容地址"
|
||||
placeholder={t("agent.settings.embedding.apiUrlPh")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="block text-sm font-medium mb-1">API Key</Label>
|
||||
<Label className="block text-sm font-medium mb-1">{t("agent.settings.embedding.apiKey")}</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={embeddingForm.api_key}
|
||||
onChange={(e) =>
|
||||
setEmbeddingForm({ ...embeddingForm, api_key: e.target.value })
|
||||
}
|
||||
placeholder={embeddingConfig?.api_key_masked ? "留空则不更新" : "输入 API Key"}
|
||||
placeholder={
|
||||
embeddingConfig?.api_key_masked
|
||||
? t("agent.settings.embedding.apiKeyKeepEmpty")
|
||||
: t("agent.settings.embedding.apiKeyInput")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="block text-sm font-medium mb-1">模型</Label>
|
||||
<Label className="block text-sm font-medium mb-1">{t("agent.settings.embedding.model")}</Label>
|
||||
<Input
|
||||
value={embeddingForm.model}
|
||||
onChange={(e) =>
|
||||
setEmbeddingForm({ ...embeddingForm, model: e.target.value })
|
||||
}
|
||||
placeholder="text-embedding-3-small"
|
||||
placeholder={t("agent.settings.embedding.modelPh")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -411,11 +428,13 @@ export default function SettingsPage(props: any = {}) {
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="customer_can_use_kb" className="text-sm cursor-pointer">
|
||||
开放知识库给客服使用(允许创建知识库、上传文档、对话中引用)
|
||||
{t("agent.settings.embedding.customerKb")}
|
||||
</Label>
|
||||
</div>
|
||||
<Button type="submit" disabled={embeddingSubmitting}>
|
||||
{embeddingSubmitting ? "保存中..." : "保存配置"}
|
||||
{embeddingSubmitting
|
||||
? t("common.saving")
|
||||
: t("agent.settings.embedding.save")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
@@ -425,14 +444,14 @@ export default function SettingsPage(props: any = {}) {
|
||||
{/* 联网搜索设置(与知识库向量模型独立;实际仍写入同一配置,仅 UI 分离) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>联网搜索设置</CardTitle>
|
||||
<CardTitle>{t("agent.settings.webSearch.title")}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
控制对话中的联网搜索方式与访客端是否显示联网选项。与上方「知识库向量模型」无关,仅影响 AI 对话时的联网行为。
|
||||
{t("agent.settings.webSearch.lead")}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{embeddingLoading ? (
|
||||
<div className="text-center py-6 text-muted-foreground">加载中...</div>
|
||||
<div className="text-center py-6 text-muted-foreground">{t("common.loading")}</div>
|
||||
) : (
|
||||
<form onSubmit={handleSaveEmbeddingConfig} className="space-y-4">
|
||||
{embeddingError && (
|
||||
@@ -441,7 +460,7 @@ export default function SettingsPage(props: any = {}) {
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label className="block text-sm font-medium mb-1">联网方式</Label>
|
||||
<Label className="block text-sm font-medium mb-1">{t("agent.settings.webSearch.mode")}</Label>
|
||||
<select
|
||||
value={embeddingForm.web_search_source}
|
||||
onChange={(e) =>
|
||||
@@ -452,11 +471,11 @@ export default function SettingsPage(props: any = {}) {
|
||||
}
|
||||
className="w-full max-w-xs px-3 py-2 border border-input rounded-md text-sm bg-background"
|
||||
>
|
||||
<option value="custom">自建(Serper)</option>
|
||||
<option value="vendor">厂商内置</option>
|
||||
<option value="custom">{t("agent.settings.webSearch.modeCustom")}</option>
|
||||
<option value="vendor">{t("agent.settings.webSearch.modeVendor")}</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
自建:由后端通过 Serper(MCP 或 HTTP)执行;厂商内置:使用当前对话所用 AI 厂商自带的 web search,不占用 Serper。
|
||||
{t("agent.settings.webSearch.modeHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -471,11 +490,11 @@ export default function SettingsPage(props: any = {}) {
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="visitor_web_search_enabled_standalone" className="text-sm cursor-pointer">
|
||||
访客小窗显示「本回合联网搜索」选项
|
||||
{t("agent.settings.webSearch.visitorToggle")}
|
||||
</Label>
|
||||
</div>
|
||||
<Button type="submit" disabled={embeddingSubmitting}>
|
||||
{embeddingSubmitting ? "保存中..." : "保存联网设置"}
|
||||
{embeddingSubmitting ? t("common.saving") : t("agent.settings.webSearch.save")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
@@ -486,7 +505,9 @@ export default function SettingsPage(props: any = {}) {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{editingId ? "编辑 AI 配置" : "添加 AI 配置"}
|
||||
{editingId
|
||||
? t("agent.settings.aiCard.titleEdit")
|
||||
: t("agent.settings.aiCard.titleAdd")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -500,35 +521,38 @@ export default function SettingsPage(props: any = {}) {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
服务商名称 <span className="text-red-500">*</span>
|
||||
{t("agent.settings.aiForm.provider")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={formData.provider}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, provider: e.target.value })
|
||||
}
|
||||
placeholder="例如:OpenAI、Claude、自定义"
|
||||
placeholder={t("agent.settings.aiForm.providerPh")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
API 地址 <span className="text-red-500">*</span>
|
||||
{t("agent.settings.aiForm.apiUrl")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={formData.api_url}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, api_url: e.target.value })
|
||||
}
|
||||
placeholder="https://api.openai.com/v1/chat/completions"
|
||||
placeholder={t("agent.settings.aiForm.apiUrlPh")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
API Key <span className="text-red-500">*</span>
|
||||
{t("agent.settings.aiForm.apiKey")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
@@ -536,28 +560,33 @@ export default function SettingsPage(props: any = {}) {
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, api_key: e.target.value })
|
||||
}
|
||||
placeholder={editingId ? "留空则不更新" : "输入 API Key"}
|
||||
placeholder={
|
||||
editingId
|
||||
? t("agent.settings.embedding.apiKeyKeepEmpty")
|
||||
: t("agent.settings.embedding.apiKeyInput")
|
||||
}
|
||||
required={!editingId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
模型名称 <span className="text-red-500">*</span>
|
||||
{t("agent.settings.aiForm.model")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={formData.model}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, model: e.target.value })
|
||||
}
|
||||
placeholder="例如:gpt-3.5-turbo、gpt-4"
|
||||
placeholder={t("agent.settings.aiForm.modelPh")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
模型类型
|
||||
{t("agent.settings.aiForm.modelType")}
|
||||
</label>
|
||||
<select
|
||||
value={formData.model_type}
|
||||
@@ -566,24 +595,24 @@ export default function SettingsPage(props: any = {}) {
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="text">文本</option>
|
||||
<option value="image">图片</option>
|
||||
<option value="audio">语音</option>
|
||||
<option value="video">视频</option>
|
||||
<option value="text">{t("agent.settings.modelType.text")}</option>
|
||||
<option value="image">{t("agent.settings.modelType.image")}</option>
|
||||
<option value="audio">{t("agent.settings.modelType.audio")}</option>
|
||||
<option value="video">{t("agent.settings.modelType.video")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
配置描述
|
||||
{t("agent.settings.aiForm.description")}
|
||||
</label>
|
||||
<Input
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, description: e.target.value })
|
||||
}
|
||||
placeholder="例如:OpenAI GPT-3.5 Turbo 模型"
|
||||
placeholder={t("agent.settings.aiForm.descPh")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -597,7 +626,7 @@ export default function SettingsPage(props: any = {}) {
|
||||
}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm">启用配置</span>
|
||||
<span className="text-sm">{t("agent.settings.aiForm.active")}</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
@@ -609,17 +638,17 @@ export default function SettingsPage(props: any = {}) {
|
||||
}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm">开放给访客使用</span>
|
||||
<span className="text-sm">{t("agent.settings.aiForm.public")}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting
|
||||
? "提交中..."
|
||||
? t("agent.settings.aiForm.submitting")
|
||||
: editingId
|
||||
? "更新配置"
|
||||
: "创建配置"}
|
||||
? t("agent.settings.aiForm.submitUpdate")
|
||||
: t("agent.settings.aiForm.submitCreate")}
|
||||
</Button>
|
||||
{editingId && (
|
||||
<Button
|
||||
@@ -627,7 +656,7 @@ export default function SettingsPage(props: any = {}) {
|
||||
variant="outline"
|
||||
onClick={resetForm}
|
||||
>
|
||||
取消
|
||||
{t("agent.common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -638,16 +667,16 @@ export default function SettingsPage(props: any = {}) {
|
||||
{/* 配置列表 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>已配置的 AI 服务</CardTitle>
|
||||
<CardTitle>{t("agent.settings.list.title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
加载中...
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
) : configs.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
暂无配置,请添加
|
||||
{t("agent.settings.list.empty")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
@@ -664,27 +693,27 @@ export default function SettingsPage(props: any = {}) {
|
||||
</h3>
|
||||
{config.is_active && (
|
||||
<span className="px-2 py-1 text-xs bg-green-100 text-green-800 rounded">
|
||||
启用
|
||||
{t("agent.settings.badge.active")}
|
||||
</span>
|
||||
)}
|
||||
{config.is_public && (
|
||||
<span className="px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded">
|
||||
开放
|
||||
{t("agent.settings.badge.public")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 space-y-1">
|
||||
<p>
|
||||
<span className="font-medium">API 地址:</span>
|
||||
<span className="font-medium">{t("agent.settings.list.apiUrlLabel")}</span>
|
||||
{config.api_url}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">模型类型:</span>
|
||||
{config.model_type}
|
||||
<span className="font-medium">{t("agent.settings.list.modelTypeLabel")}</span>
|
||||
{modelTypeLabel(config.model_type)}
|
||||
</p>
|
||||
{config.description && (
|
||||
<p>
|
||||
<span className="font-medium">描述:</span>
|
||||
<span className="font-medium">{t("agent.settings.list.descLabel")}</span>
|
||||
{config.description}
|
||||
</p>
|
||||
)}
|
||||
@@ -696,14 +725,14 @@ export default function SettingsPage(props: any = {}) {
|
||||
variant="outline"
|
||||
onClick={() => handleEdit(config)}
|
||||
>
|
||||
编辑
|
||||
{t("agent.common.edit")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleDelete(config.id)}
|
||||
>
|
||||
删除
|
||||
{t("agent.common.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,21 +32,42 @@ import {
|
||||
defaultAgentPermissions,
|
||||
type PermissionKey,
|
||||
} from "@/lib/constants/agent-permissions";
|
||||
import type { I18nKey } from "@/lib/i18n/dict";
|
||||
import { useI18n } from "@/lib/i18n/provider";
|
||||
import {
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Lock,
|
||||
Search,
|
||||
UserPlus,
|
||||
Save,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
const PERM_LABEL: Record<PermissionKey, I18nKey> = {
|
||||
chat: "agent.perm.chat",
|
||||
kb_test: "agent.perm.kb_test",
|
||||
knowledge: "agent.perm.knowledge",
|
||||
faqs: "agent.perm.faqs",
|
||||
analytics: "agent.perm.analytics",
|
||||
logs: "agent.perm.logs",
|
||||
prompts: "agent.perm.prompts",
|
||||
settings: "agent.perm.settings",
|
||||
users: "agent.perm.users",
|
||||
};
|
||||
|
||||
export default function UsersPage(props: any = {}) {
|
||||
const { embedded = false } = props;
|
||||
const router = useRouter();
|
||||
const { agent } = useAuth();
|
||||
const { t, lang } = useI18n();
|
||||
|
||||
const tr = (key: I18nKey, vars?: Record<string, string>) => {
|
||||
let s = t(key);
|
||||
if (!vars) return s;
|
||||
for (const k of Object.keys(vars)) {
|
||||
s = s.replaceAll(`{{${k}}}`, vars[k] ?? "");
|
||||
}
|
||||
return s;
|
||||
};
|
||||
const [users, setUsers] = useState<UserSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
@@ -100,11 +121,11 @@ export default function UsersPage(props: any = {}) {
|
||||
setUsers(data);
|
||||
} catch (error) {
|
||||
console.error("加载用户列表失败:", error);
|
||||
toast.error((error as Error).message || "加载用户列表失败");
|
||||
toast.error((error as Error).message || t("agent.users.toast.loadFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [agent?.id]);
|
||||
}, [agent?.id, t]);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
@@ -143,7 +164,7 @@ export default function UsersPage(props: any = {}) {
|
||||
return;
|
||||
}
|
||||
if (!createForm.username.trim() || !createForm.password.trim()) {
|
||||
toast.error("用户名和密码不能为空");
|
||||
toast.error(t("agent.users.toast.usernamePasswordRequired"));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
@@ -151,9 +172,9 @@ export default function UsersPage(props: any = {}) {
|
||||
await createUser(createForm, agent.id);
|
||||
setCreateDialogOpen(false);
|
||||
await loadUsers();
|
||||
toast.success("创建成功");
|
||||
toast.success(t("agent.users.toast.createSuccess"));
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || "创建用户失败");
|
||||
toast.error((error as Error).message || t("agent.users.toast.createFailed"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -187,9 +208,9 @@ export default function UsersPage(props: any = {}) {
|
||||
setEditDialogOpen(false);
|
||||
setSelectedUser(null);
|
||||
await loadUsers();
|
||||
toast.success("更新成功");
|
||||
toast.success(t("agent.users.toast.updateSuccess"));
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || "更新用户失败");
|
||||
toast.error((error as Error).message || t("agent.users.toast.updateFailed"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -198,7 +219,7 @@ export default function UsersPage(props: any = {}) {
|
||||
// 打开修改密码对话框
|
||||
const handleOpenPassword = (user: UserSummary) => {
|
||||
if (user.role === "admin") {
|
||||
toast.error("管理员密码仅支持数据库修改,前端已禁用");
|
||||
toast.error(t("agent.users.toast.adminPasswordDisabled"));
|
||||
return;
|
||||
}
|
||||
setSelectedUser(user);
|
||||
@@ -215,13 +236,13 @@ export default function UsersPage(props: any = {}) {
|
||||
return;
|
||||
}
|
||||
if (!passwordForm.new_password.trim()) {
|
||||
toast.error("新密码不能为空");
|
||||
toast.error(t("agent.users.toast.newPasswordRequired"));
|
||||
return;
|
||||
}
|
||||
// 如果修改的是当前用户,需要旧密码;如果是其他用户,不需要旧密码
|
||||
const isCurrentUser = selectedUser.id === agent.id;
|
||||
if (isCurrentUser && !passwordForm.old_password?.trim()) {
|
||||
toast.error("修改自己的密码需要提供旧密码");
|
||||
toast.error(t("agent.users.toast.oldPasswordRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -235,9 +256,9 @@ export default function UsersPage(props: any = {}) {
|
||||
setPasswordDialogOpen(false);
|
||||
setSelectedUser(null);
|
||||
setPasswordForm({ old_password: "", new_password: "" });
|
||||
toast.success("密码更新成功");
|
||||
toast.success(t("agent.users.toast.passwordSuccess"));
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || "更新密码失败");
|
||||
toast.error((error as Error).message || t("agent.users.toast.passwordFailed"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -246,7 +267,7 @@ export default function UsersPage(props: any = {}) {
|
||||
// 打开删除对话框
|
||||
const handleOpenDelete = (user: UserSummary) => {
|
||||
if (user.role === "admin") {
|
||||
toast.error("管理员账号仅支持数据库删除,前端已禁用");
|
||||
toast.error(t("agent.users.toast.adminDeleteDisabled"));
|
||||
return;
|
||||
}
|
||||
setSelectedUser(user);
|
||||
@@ -266,13 +287,15 @@ export default function UsersPage(props: any = {}) {
|
||||
await loadUsers();
|
||||
if (result.transferredAIConfigs > 0) {
|
||||
toast.success(
|
||||
`删除成功,已自动转移 ${result.transferredAIConfigs} 条 AI 配置到当前管理员`
|
||||
tr("agent.users.toast.deleteTransferred", {
|
||||
count: String(result.transferredAIConfigs),
|
||||
})
|
||||
);
|
||||
} else {
|
||||
toast.success("删除成功");
|
||||
toast.success(t("agent.users.toast.deleteSuccess"));
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || "删除用户失败");
|
||||
toast.error((error as Error).message || t("agent.users.toast.deleteFailed"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -281,7 +304,7 @@ export default function UsersPage(props: any = {}) {
|
||||
// 格式化时间
|
||||
const formatTime = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString("zh-CN", {
|
||||
return date.toLocaleString(lang === "en" ? "en-US" : "zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
@@ -296,16 +319,16 @@ export default function UsersPage(props: any = {}) {
|
||||
|
||||
// 构建头部内容
|
||||
const headerContent = (
|
||||
<div className="bg-card border-b p-4 shadow-sm">
|
||||
<div className="border-b bg-card p-3 shadow-sm sm:p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-xl font-bold text-foreground">用户管理</h1>
|
||||
<h1 className="text-xl font-bold text-foreground">{t("agent.users.title")}</h1>
|
||||
{!embedded && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push("/agent/dashboard")}
|
||||
>
|
||||
返回
|
||||
{t("agent.common.back")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -316,7 +339,7 @@ export default function UsersPage(props: any = {}) {
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="搜索用户(用户名、昵称、邮箱)..."
|
||||
placeholder={t("agent.users.search.placeholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
@@ -327,7 +350,7 @@ export default function UsersPage(props: any = {}) {
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<UserPlus className="w-4 h-4 mr-2" />
|
||||
创建用户
|
||||
{t("agent.users.createButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -335,15 +358,15 @@ export default function UsersPage(props: any = {}) {
|
||||
|
||||
// 构建主内容区
|
||||
const mainContent = (
|
||||
<div className="flex-1 overflow-y-auto p-4 scrollbar-auto">
|
||||
<div className="scrollbar-auto flex-1 overflow-y-auto p-3 sm:p-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<span className="text-muted-foreground">加载中...</span>
|
||||
<span className="text-muted-foreground">{t("common.loading")}</span>
|
||||
</div>
|
||||
) : filteredUsers.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<span className="text-muted-foreground">
|
||||
{searchQuery ? "没有找到匹配的用户" : "暂无用户"}
|
||||
{searchQuery ? t("agent.users.empty.filtered") : t("agent.users.empty")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
@@ -358,15 +381,23 @@ export default function UsersPage(props: any = {}) {
|
||||
<Badge
|
||||
variant={user.role === "admin" ? "default" : "secondary"}
|
||||
>
|
||||
{user.role === "admin" ? "管理员" : "客服"}
|
||||
{user.role === "admin"
|
||||
? t("agent.users.role.admin")
|
||||
: t("agent.users.role.agent")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground space-y-1 mb-2">
|
||||
<div>用户名: {user.username}</div>
|
||||
{user.email && <div>邮箱: {user.email}</div>}
|
||||
<div>
|
||||
{t("agent.users.field.username")}: {user.username}
|
||||
</div>
|
||||
{user.email && (
|
||||
<div>
|
||||
{t("agent.users.field.email")}: {user.email}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
创建时间: {formatTime(user.created_at)}
|
||||
{t("agent.users.field.createdAt")}: {formatTime(user.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -378,7 +409,7 @@ export default function UsersPage(props: any = {}) {
|
||||
className="flex-1"
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-1" />
|
||||
编辑
|
||||
{t("agent.users.card.edit")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -386,10 +417,14 @@ export default function UsersPage(props: any = {}) {
|
||||
onClick={() => handleOpenPassword(user)}
|
||||
className="flex-1"
|
||||
disabled={user.role === "admin"}
|
||||
title={user.role === "admin" ? "管理员密码仅支持数据库修改" : ""}
|
||||
title={
|
||||
user.role === "admin"
|
||||
? t("agent.users.tooltip.adminPasswordDbOnly")
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<Lock className="w-4 h-4 mr-1" />
|
||||
密码
|
||||
{t("agent.users.card.password")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@@ -398,9 +433,9 @@ export default function UsersPage(props: any = {}) {
|
||||
disabled={user.id === agent.id || user.role === "admin"}
|
||||
title={
|
||||
user.role === "admin"
|
||||
? "管理员账号仅支持数据库删除"
|
||||
? t("agent.users.tooltip.adminDeleteDbOnly")
|
||||
: user.id === agent.id
|
||||
? "不能删除当前登录用户"
|
||||
? t("agent.users.tooltip.cannotDeleteSelf")
|
||||
: ""
|
||||
}
|
||||
>
|
||||
@@ -414,36 +449,28 @@ export default function UsersPage(props: any = {}) {
|
||||
</div>
|
||||
);
|
||||
|
||||
// 如果是嵌入模式,只返回内容,不包含 ResponsiveLayout
|
||||
if (embedded) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
{headerContent}
|
||||
{mainContent}
|
||||
</div>
|
||||
{/* 对话框 */}
|
||||
|
||||
const userDialogs = (
|
||||
<>
|
||||
{/* 创建用户对话框 */}
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>创建新用户</DialogTitle>
|
||||
<DialogTitle>{t("agent.users.dialog.createTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="create-username">用户名 *</Label>
|
||||
<Label htmlFor="create-username">{t("agent.users.form.username")} *</Label>
|
||||
<Input
|
||||
id="create-username"
|
||||
value={createForm.username}
|
||||
onChange={(e) =>
|
||||
setCreateForm({ ...createForm, username: e.target.value })
|
||||
}
|
||||
placeholder="请输入用户名"
|
||||
placeholder={t("agent.users.placeholder.username")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="create-password">密码 *</Label>
|
||||
<Label htmlFor="create-password">{t("agent.users.form.password")} *</Label>
|
||||
<Input
|
||||
id="create-password"
|
||||
type="password"
|
||||
@@ -451,11 +478,11 @@ export default function UsersPage(props: any = {}) {
|
||||
onChange={(e) =>
|
||||
setCreateForm({ ...createForm, password: e.target.value })
|
||||
}
|
||||
placeholder="请输入密码"
|
||||
placeholder={t("agent.users.placeholder.password")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="create-role">角色 *</Label>
|
||||
<Label htmlFor="create-role">{t("agent.users.form.role")} *</Label>
|
||||
<select
|
||||
id="create-role"
|
||||
value={createForm.role}
|
||||
@@ -471,15 +498,15 @@ export default function UsersPage(props: any = {}) {
|
||||
}
|
||||
className="w-full px-3 py-2 border border-border rounded-md bg-background"
|
||||
>
|
||||
<option value="agent">客服</option>
|
||||
<option value="admin">管理员</option>
|
||||
<option value="agent">{t("agent.users.role.agent")}</option>
|
||||
<option value="admin">{t("agent.users.role.admin")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 功能权限(开/关一级;role=admin 默认全开) */}
|
||||
{createForm.role !== "admin" && (
|
||||
<div>
|
||||
<Label>功能权限</Label>
|
||||
<Label>{t("agent.users.form.permissions")}</Label>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
{PERMISSION_OPTIONS.map((p) => {
|
||||
const checked = (createForm.permissions ?? []).includes(p.key);
|
||||
@@ -498,29 +525,29 @@ export default function UsersPage(props: any = {}) {
|
||||
setCreateForm({ ...createForm, permissions: Array.from(next) });
|
||||
}}
|
||||
/>
|
||||
<span>{p.label}</span>
|
||||
<span>{t(PERM_LABEL[p.key])}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
默认仅开启“对话”。关闭后对应菜单不可见且后端接口会返回 403。
|
||||
{t("agent.users.form.permissionsHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label htmlFor="create-nickname">昵称</Label>
|
||||
<Label htmlFor="create-nickname">{t("agent.users.form.nickname")}</Label>
|
||||
<Input
|
||||
id="create-nickname"
|
||||
value={createForm.nickname}
|
||||
onChange={(e) =>
|
||||
setCreateForm({ ...createForm, nickname: e.target.value })
|
||||
}
|
||||
placeholder="请输入昵称(可选)"
|
||||
placeholder={t("agent.users.placeholder.nicknameOptional")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="create-email">邮箱</Label>
|
||||
<Label htmlFor="create-email">{t("agent.users.form.email")}</Label>
|
||||
<Input
|
||||
id="create-email"
|
||||
type="email"
|
||||
@@ -528,7 +555,7 @@ export default function UsersPage(props: any = {}) {
|
||||
onChange={(e) =>
|
||||
setCreateForm({ ...createForm, email: e.target.value })
|
||||
}
|
||||
placeholder="请输入邮箱(可选)"
|
||||
placeholder={t("agent.users.placeholder.emailOptional")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
@@ -537,10 +564,10 @@ export default function UsersPage(props: any = {}) {
|
||||
onClick={() => setCreateDialogOpen(false)}
|
||||
disabled={submitting}
|
||||
>
|
||||
取消
|
||||
{t("agent.common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={submitting}>
|
||||
{submitting ? "创建中..." : "创建"}
|
||||
{submitting ? t("agent.users.submit.creating") : t("agent.common.create")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -551,19 +578,19 @@ export default function UsersPage(props: any = {}) {
|
||||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑用户</DialogTitle>
|
||||
<DialogTitle>{t("agent.users.dialog.editTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedUser && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>用户名</Label>
|
||||
<Label>{t("agent.users.field.username")}</Label>
|
||||
<Input value={selectedUser.username} disabled />
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
用户名不能修改
|
||||
{t("agent.users.usernameImmutableHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-role">角色 *</Label>
|
||||
<Label htmlFor="edit-role">{t("agent.users.form.role")} *</Label>
|
||||
<select
|
||||
id="edit-role"
|
||||
value={editForm.role}
|
||||
@@ -579,14 +606,14 @@ export default function UsersPage(props: any = {}) {
|
||||
}
|
||||
className="w-full px-3 py-2 border border-border rounded-md bg-background"
|
||||
>
|
||||
<option value="agent">客服</option>
|
||||
<option value="admin">管理员</option>
|
||||
<option value="agent">{t("agent.users.role.agent")}</option>
|
||||
<option value="admin">{t("agent.users.role.admin")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{editForm.role !== "admin" && (
|
||||
<div>
|
||||
<Label>功能权限</Label>
|
||||
<Label>{t("agent.users.form.permissions")}</Label>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
{PERMISSION_OPTIONS.map((p) => {
|
||||
const checked = (editForm.permissions ?? []).includes(p.key);
|
||||
@@ -605,7 +632,7 @@ export default function UsersPage(props: any = {}) {
|
||||
setEditForm({ ...editForm, permissions: Array.from(next) });
|
||||
}}
|
||||
/>
|
||||
<span>{p.label}</span>
|
||||
<span>{t(PERM_LABEL[p.key])}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
@@ -613,18 +640,18 @@ export default function UsersPage(props: any = {}) {
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label htmlFor="edit-nickname">昵称</Label>
|
||||
<Label htmlFor="edit-nickname">{t("agent.users.form.nickname")}</Label>
|
||||
<Input
|
||||
id="edit-nickname"
|
||||
value={editForm.nickname || ""}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, nickname: e.target.value })
|
||||
}
|
||||
placeholder="请输入昵称"
|
||||
placeholder={t("agent.users.placeholder.nickname")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-email">邮箱</Label>
|
||||
<Label htmlFor="edit-email">{t("agent.users.form.email")}</Label>
|
||||
<Input
|
||||
id="edit-email"
|
||||
type="email"
|
||||
@@ -632,7 +659,7 @@ export default function UsersPage(props: any = {}) {
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, email: e.target.value })
|
||||
}
|
||||
placeholder="请输入邮箱"
|
||||
placeholder={t("agent.users.placeholder.email")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -649,7 +676,7 @@ export default function UsersPage(props: any = {}) {
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<Label htmlFor="edit-receive-ai" className="cursor-pointer">
|
||||
接收 AI 对话
|
||||
{t("agent.users.receiveAiLabel")}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
@@ -658,10 +685,10 @@ export default function UsersPage(props: any = {}) {
|
||||
onClick={() => setEditDialogOpen(false)}
|
||||
disabled={submitting}
|
||||
>
|
||||
取消
|
||||
{t("agent.common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleUpdate} disabled={submitting}>
|
||||
{submitting ? "更新中..." : "更新"}
|
||||
{submitting ? t("agent.users.submit.updating") : t("agent.common.update")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -673,17 +700,17 @@ export default function UsersPage(props: any = {}) {
|
||||
<Dialog open={passwordDialogOpen} onOpenChange={setPasswordDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>修改密码</DialogTitle>
|
||||
<DialogTitle>{t("agent.users.dialog.passwordTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedUser && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>用户名</Label>
|
||||
<Label>{t("agent.users.field.username")}</Label>
|
||||
<Input value={selectedUser.username} disabled />
|
||||
</div>
|
||||
{selectedUser.id === agent?.id && (
|
||||
<div>
|
||||
<Label htmlFor="password-old">旧密码 *</Label>
|
||||
<Label htmlFor="password-old">{t("agent.users.form.oldPassword")} *</Label>
|
||||
<Input
|
||||
id="password-old"
|
||||
type="password"
|
||||
@@ -694,12 +721,12 @@ export default function UsersPage(props: any = {}) {
|
||||
old_password: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="请输入旧密码"
|
||||
placeholder={t("agent.users.placeholder.oldPassword")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label htmlFor="password-new">新密码 *</Label>
|
||||
<Label htmlFor="password-new">{t("agent.users.form.newPassword")} *</Label>
|
||||
<Input
|
||||
id="password-new"
|
||||
type="password"
|
||||
@@ -710,7 +737,7 @@ export default function UsersPage(props: any = {}) {
|
||||
new_password: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="请输入新密码"
|
||||
placeholder={t("agent.users.placeholder.password")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
@@ -719,10 +746,10 @@ export default function UsersPage(props: any = {}) {
|
||||
onClick={() => setPasswordDialogOpen(false)}
|
||||
disabled={submitting}
|
||||
>
|
||||
取消
|
||||
{t("agent.common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleUpdatePassword} disabled={submitting}>
|
||||
{submitting ? "更新中..." : "更新"}
|
||||
{submitting ? t("agent.users.submit.updating") : t("agent.common.update")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -734,15 +761,17 @@ export default function UsersPage(props: any = {}) {
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>删除用户</DialogTitle>
|
||||
<DialogTitle>{t("agent.users.dialog.deleteTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedUser && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-foreground">
|
||||
确定要删除用户 <strong>{selectedUser.username}</strong> 吗?
|
||||
{tr("agent.users.dialog.deleteConfirm", {
|
||||
username: selectedUser.username,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
此操作不可恢复。若该用户有 AI 配置,系统会自动转移给当前管理员,避免配置丢失。
|
||||
{t("agent.users.dialog.deleteNote")}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
@@ -750,14 +779,14 @@ export default function UsersPage(props: any = {}) {
|
||||
onClick={() => setDeleteDialogOpen(false)}
|
||||
disabled={submitting}
|
||||
>
|
||||
取消
|
||||
{t("agent.common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? "删除中..." : "删除"}
|
||||
{submitting ? t("agent.users.submit.deleting") : t("agent.common.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -766,13 +795,24 @@ export default function UsersPage(props: any = {}) {
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
|
||||
if (embedded) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
{headerContent}
|
||||
{mainContent}
|
||||
</div>
|
||||
{userDialogs}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveLayout
|
||||
main={mainContent}
|
||||
header={headerContent}
|
||||
/>
|
||||
<>
|
||||
<ResponsiveLayout main={mainContent} header={headerContent} />
|
||||
{userDialogs}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -92,6 +92,16 @@ html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* 移动端:刘海 / 手势条安全区(与 Tailwind 任意值 env(safe-area-inset-*) 配合) */
|
||||
@supports (padding: env(safe-area-inset-bottom)) {
|
||||
.pb-safe {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
.pt-safe {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
|
||||
+14
-2
@@ -1,9 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import MatomoTracker from "@/components/MatomoTracker";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { getSiteUrl } from "@/lib/site";
|
||||
import { I18nProvider } from "@/lib/i18n/provider";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -20,6 +21,17 @@ export const metadata: Metadata = {
|
||||
description: "融合 AI 技术与人工客服,为企业提供高效、智能的客户服务解决方案",
|
||||
};
|
||||
|
||||
/** 移动端:正确缩放、刘海屏 safe-area、禁止误触极小字号(仍允许用户双指放大) */
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#f8fafc" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#0f172a" },
|
||||
],
|
||||
};
|
||||
|
||||
// Matomo 容器 URL(格式:container_*.js)
|
||||
const MATOMO_CONTAINER_URL = process.env.NEXT_PUBLIC_MATOMO_CONTAINER_URL || '';
|
||||
|
||||
@@ -43,7 +55,7 @@ export default function RootLayout({
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<I18nProvider>{children}</I18nProvider>
|
||||
<Toaster />
|
||||
{MATOMO_CONTAINER_URL && <MatomoTracker containerUrl={MATOMO_CONTAINER_URL} />}
|
||||
</body>
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
import { formatConversationTime } from "@/utils/format";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useI18n } from "@/lib/i18n/provider";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Bot } from "lucide-react";
|
||||
|
||||
interface ChatHeaderProps {
|
||||
conversationId: number;
|
||||
@@ -16,6 +19,8 @@ interface ChatHeaderProps {
|
||||
soundEnabled?: boolean;
|
||||
onToggleSound?: () => void;
|
||||
hideAIToggle?: boolean; // 内部对话时隐藏「显示 AI 消息」切换
|
||||
/** 为工作台移动端悬浮菜单(左/右圆钮)留出内边距,避免盖住标题与操作区 */
|
||||
mobileGutters?: { left?: boolean; right?: boolean };
|
||||
}
|
||||
|
||||
export function ChatHeader({
|
||||
@@ -31,27 +36,34 @@ export function ChatHeader({
|
||||
onToggleSound,
|
||||
hideAIToggle = false,
|
||||
}: ChatHeaderProps) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="h-16 flex items-center justify-between px-4 bg-background flex-shrink-0 relative">
|
||||
<div className="z-10">
|
||||
<div className="font-semibold text-foreground">对话 #{conversationId}</div>
|
||||
<div className="font-semibold text-foreground">
|
||||
{t("agent.chat.conversation")} #{conversationId}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
{lastSeenAt
|
||||
? `last seen ${formatConversationTime(lastSeenAt)}`
|
||||
: "last seen 未知"}
|
||||
? `${t("agent.chat.lastSeen")} ${formatConversationTime(lastSeenAt)}`
|
||||
: t("agent.chat.lastSeenUnknown")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 sm:gap-2 flex-shrink-0">
|
||||
{/* 显示/隐藏 AI 消息切换按钮(内部对话不显示,默认始终包含 AI 消息) */}
|
||||
{onToggleAIMessages && !hideAIToggle && (
|
||||
<Button
|
||||
variant={includeAIMessages ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={onToggleAIMessages}
|
||||
title={includeAIMessages ? "隐藏 AI 消息" : "显示 AI 消息"}
|
||||
className="text-xs"
|
||||
title={includeAIMessages ? t("agent.chat.hideAI") : t("agent.chat.showAI")}
|
||||
aria-label={includeAIMessages ? t("agent.chat.hideAI") : t("agent.chat.showAI")}
|
||||
className="text-xs gap-1 px-2 sm:px-3"
|
||||
>
|
||||
{includeAIMessages ? "隐藏 AI 消息" : "显示 AI 消息"}
|
||||
<Bot className="h-4 w-4 sm:hidden shrink-0" aria-hidden />
|
||||
<span className="hidden sm:inline">
|
||||
{includeAIMessages ? t("agent.chat.hideAI") : t("agent.chat.showAI")}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
{/* 声音开关按钮 */}
|
||||
@@ -59,7 +71,7 @@ export function ChatHeader({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={soundEnabled ? "关闭声音提示" : "开启声音提示"}
|
||||
title={soundEnabled ? t("agent.chat.soundOn") : t("agent.chat.soundOff")}
|
||||
onClick={onToggleSound}
|
||||
>
|
||||
{soundEnabled ? (
|
||||
@@ -102,7 +114,7 @@ export function ChatHeader({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="关闭会话"
|
||||
title={t("agent.chat.closeConversation")}
|
||||
onClick={onCloseConversation}
|
||||
disabled={!onCloseConversation}
|
||||
>
|
||||
@@ -123,7 +135,7 @@ export function ChatHeader({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="刷新"
|
||||
title={t("agent.chat.refresh")}
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<svg
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useI18n } from "@/lib/i18n/provider";
|
||||
|
||||
export type ConversationFilter = "all" | "mine" | "others";
|
||||
|
||||
@@ -27,10 +28,18 @@ export function ConversationHeader({
|
||||
listStatus,
|
||||
onListStatusChange,
|
||||
}: ConversationHeaderProps) {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const currentLabel = FILTER_OPTIONS.find((o) => o.value === filter)?.label ?? "全部对话";
|
||||
const options = [
|
||||
{ value: "all" as const, label: t("agent.conversations.filter.all") },
|
||||
{ value: "mine" as const, label: t("agent.conversations.filter.mine") },
|
||||
{ value: "others" as const, label: t("agent.conversations.filter.others") },
|
||||
];
|
||||
const currentLabel =
|
||||
options.find((o) => o.value === filter)?.label ??
|
||||
t("agent.conversations.filter.all");
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
@@ -58,7 +67,7 @@ export function ConversationHeader({
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute top-full left-0 mt-1 py-1 rounded-lg border border-border bg-popover shadow-md z-50 min-w-[theme(spacing.32)]">
|
||||
{FILTER_OPTIONS.map((opt) => (
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
@@ -90,7 +99,7 @@ export function ConversationHeader({
|
||||
}`}
|
||||
onClick={() => onListStatusChange!("open")}
|
||||
>
|
||||
进行中
|
||||
{t("agent.conversations.status.open")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -101,7 +110,7 @@ export function ConversationHeader({
|
||||
}`}
|
||||
onClick={() => onListStatusChange!("closed")}
|
||||
>
|
||||
历史
|
||||
{t("agent.conversations.status.closed")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "@/utils/format";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useI18n } from "@/lib/i18n/provider";
|
||||
|
||||
interface ConversationListItemProps {
|
||||
conversation: ConversationSummary;
|
||||
@@ -20,12 +21,13 @@ export function ConversationListItem({
|
||||
selected,
|
||||
onSelect,
|
||||
}: ConversationListItemProps) {
|
||||
const { t } = useI18n();
|
||||
const avatarColor = `hsl(${(conversation.id * 137.5) % 360}, 70%, 50%)`;
|
||||
const unreadCount = conversation.unread_count ?? 0;
|
||||
const lastMessage = conversation.last_message;
|
||||
const lastMessagePreview = lastMessage
|
||||
? buildMessagePreview(lastMessage.content)
|
||||
: "暂无消息";
|
||||
: t("agent.conversation.noMessage");
|
||||
// 根据 last_seen_at 判断是否在线(最近 10 秒内认为在线)
|
||||
const isOnline = isVisitorOnline(conversation.last_seen_at);
|
||||
|
||||
@@ -57,13 +59,13 @@ export function ConversationListItem({
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-foreground text-sm truncate">
|
||||
对话 #{conversation.id}
|
||||
{t("agent.chat.conversation")} #{conversation.id}
|
||||
</span>
|
||||
{/* 在线/离线状态图标 */}
|
||||
{isOnline && (
|
||||
<span
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
title="在线"
|
||||
title={t("agent.conversation.online")}
|
||||
style={{ backgroundColor: "#10b981" }}
|
||||
/>
|
||||
)}
|
||||
@@ -76,7 +78,9 @@ export function ConversationListItem({
|
||||
variant={conversation.status === "open" ? "default" : "secondary"}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
{conversation.status === "open" ? "进行中" : "已关闭"}
|
||||
{conversation.status === "open"
|
||||
? t("agent.conversations.status.open")
|
||||
: t("agent.conversations.status.closed")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mb-1 flex items-center gap-1">
|
||||
@@ -92,7 +96,9 @@ export function ConversationListItem({
|
||||
<span className="truncate">{lastMessagePreview}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground min-w-0">
|
||||
<span className="truncate">访客 #{conversation.visitor_id}</span>
|
||||
<span className="truncate">
|
||||
{t("agent.conversation.visitor")} #{conversation.visitor_id}
|
||||
</span>
|
||||
<span className="flex-shrink-0 whitespace-nowrap">{formatConversationTime(conversation.updated_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ConversationSearch } from "./ConversationSearch";
|
||||
import { ConversationList } from "./ConversationList";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useI18n } from "@/lib/i18n/provider";
|
||||
|
||||
type ConversationStatus = "open" | "closed";
|
||||
|
||||
@@ -37,15 +38,16 @@ export function ConversationSidebar({
|
||||
mode = "visitor",
|
||||
onNewClick,
|
||||
}: ConversationSidebarProps) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="w-80 min-w-0 flex-1 flex flex-col bg-white border-r border-gray-200 min-h-0 overflow-hidden">
|
||||
{mode === "internal" ? (
|
||||
<div className="h-14 flex items-center justify-between px-3 border-b border-border bg-background flex-shrink-0">
|
||||
<span className="text-sm font-medium text-foreground truncate">知识库测试</span>
|
||||
<span className="text-sm font-medium text-foreground truncate">{t("agent.internalChat.title")}</span>
|
||||
{onNewClick && (
|
||||
<Button size="sm" variant="outline" onClick={onNewClick} className="flex-shrink-0 gap-1">
|
||||
<Plus className="w-4 h-4" />
|
||||
新建
|
||||
{t("agent.internalChat.new")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -30,8 +30,10 @@ import { VisitorDetailPanel } from "./VisitorDetailPanel";
|
||||
import { useSoundNotification } from "@/hooks/useSoundNotification";
|
||||
import { usePageTitle } from "@/hooks/usePageTitle";
|
||||
import { reportFrontendLog } from "@/features/agent/services/systemLogApi";
|
||||
import { useI18n } from "@/lib/i18n/provider";
|
||||
|
||||
export function DashboardShell() {
|
||||
const { t } = useI18n();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -221,12 +223,12 @@ export function DashboardShell() {
|
||||
if (!selectedConversationId) return;
|
||||
try {
|
||||
await closeConversation(selectedConversationId);
|
||||
toast.success("已关闭会话");
|
||||
toast.success(t("agent.chat.toast.conversationClosed"));
|
||||
// 清空选中并刷新列表/详情
|
||||
selectConversation(null);
|
||||
refreshConversations();
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message || "关闭会话失败");
|
||||
toast.error((e as Error).message || t("agent.chat.toast.closeFailed"));
|
||||
}
|
||||
}, [refreshConversations, selectConversation, selectedConversationId]);
|
||||
|
||||
@@ -280,7 +282,7 @@ export function DashboardShell() {
|
||||
selectConversation(conversation_id);
|
||||
} catch (e) {
|
||||
console.error("创建内部对话失败:", e);
|
||||
toast.error((e as Error).message || "创建内部对话失败");
|
||||
toast.error((e as Error).message || t("agent.internalChat.createFailed"));
|
||||
}
|
||||
}, [agent?.id, refreshConversations, selectConversation]);
|
||||
|
||||
@@ -354,6 +356,11 @@ export function DashboardShell() {
|
||||
soundEnabled={soundEnabled}
|
||||
onToggleSound={toggleSound}
|
||||
hideAIToggle={isInternalChat}
|
||||
mobileGutters={{
|
||||
left: true,
|
||||
right:
|
||||
currentPage === "dashboard" && selectedConversationId != null,
|
||||
}}
|
||||
/>
|
||||
<MessageList
|
||||
messages={messages}
|
||||
@@ -382,7 +389,7 @@ export function DashboardShell() {
|
||||
<div className="flex justify-start mt-2">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-2xl rounded-bl-none bg-card border border-border/50 shadow-sm text-sm text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin flex-shrink-0" />
|
||||
<span>AI 正在思考...</span>
|
||||
<span>{t("agent.internalChat.aiThinking")}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -399,7 +406,7 @@ export function DashboardShell() {
|
||||
onCheckedChange={(v) => setNeedWebSearch(Boolean(v))}
|
||||
/>
|
||||
<Label htmlFor="internal-need-web-search" className="cursor-pointer font-normal">
|
||||
本回合联网搜索
|
||||
{t("agent.internalChat.webSearchThisTurn")}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -413,8 +420,8 @@ export function DashboardShell() {
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
|
||||
{isInternalChat ? "选择或新建内部对话,测试知识库效果" : "选择一个对话开始聊天"}
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm px-6 text-center">
|
||||
{isInternalChat ? t("agent.internalChat.emptyHint") : t("agent.chat.emptyPick")}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useI18n } from "@/lib/i18n/provider";
|
||||
|
||||
export function DashboardSuspenseFallback() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-background">
|
||||
<div className="text-muted-foreground">{t("common.loading")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { uploadFile, UploadFileResult } from "@/features/agent/services/messageApi";
|
||||
import { X, Paperclip, Image as ImageIcon } from "lucide-react";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { useI18n } from "@/lib/i18n/provider";
|
||||
|
||||
interface MessageInputProps {
|
||||
value: string;
|
||||
@@ -27,6 +28,7 @@ export function MessageInput({
|
||||
sending,
|
||||
conversationId,
|
||||
}: MessageInputProps) {
|
||||
const { t } = useI18n();
|
||||
// 输入框引用,用于发送消息后自动聚焦
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
// 文件输入框引用
|
||||
@@ -58,7 +60,7 @@ export function MessageInput({
|
||||
// 验证文件大小(10MB)
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
toast.error("文件大小超过限制(最大10MB)");
|
||||
toast.error(t("agent.input.fileTooLarge"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -66,7 +68,7 @@ export function MessageInput({
|
||||
const ext = file.name.toLowerCase().split(".").pop();
|
||||
const allowedExts = ["jpg", "jpeg", "png", "gif", "webp", "pdf", "doc", "docx", "txt"];
|
||||
if (!ext || !allowedExts.includes(ext)) {
|
||||
toast.error("不支持的文件类型");
|
||||
toast.error(t("agent.input.fileTypeNotSupported"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -178,7 +180,7 @@ export function MessageInput({
|
||||
try {
|
||||
fileInfo = await uploadFile(filePreview.file, conversationId);
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || "文件上传失败");
|
||||
toast.error((error as Error).message || t("agent.input.uploadFailed"));
|
||||
setUploading(false);
|
||||
return;
|
||||
}
|
||||
@@ -254,7 +256,10 @@ export function MessageInput({
|
||||
)}
|
||||
|
||||
{/* 输入区域 */}
|
||||
<form onSubmit={handleSubmit} className="px-4 py-3 flex items-center gap-2">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="px-4 py-3 flex items-center gap-2 pb-[max(0.75rem,env(safe-area-inset-bottom,0px))]"
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -268,7 +273,7 @@ export function MessageInput({
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={sending || uploading}
|
||||
title="上传文件"
|
||||
title={t("agent.input.upload")}
|
||||
className="hover:bg-primary/10 hover:text-primary transition-colors"
|
||||
>
|
||||
<Paperclip className="w-4 h-4" />
|
||||
@@ -276,7 +281,11 @@ export function MessageInput({
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder={filePreview ? "添加消息(可选)..." : "输入消息..."}
|
||||
placeholder={
|
||||
filePreview
|
||||
? t("agent.input.placeholder.withAttachment")
|
||||
: t("agent.input.placeholder")
|
||||
}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="flex-1 border-border/50 focus:border-primary/50 focus:ring-primary/20"
|
||||
@@ -289,7 +298,11 @@ export function MessageInput({
|
||||
size="default"
|
||||
className="bg-primary hover:bg-primary/90 shadow-md hover:shadow-lg transition-all"
|
||||
>
|
||||
{uploading ? "上传中..." : sending ? "发送中..." : "发送"}
|
||||
{uploading
|
||||
? t("agent.input.uploading")
|
||||
: sending
|
||||
? t("agent.input.sending")
|
||||
: t("agent.input.send")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { MessageItem } from "@/features/agent/types";
|
||||
import { formatMessageTime } from "@/utils/format";
|
||||
import { highlightText } from "@/utils/highlight";
|
||||
@@ -10,6 +10,7 @@ import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { Paperclip, Download, X } from "lucide-react";
|
||||
import { API_BASE_URL } from "@/lib/config";
|
||||
import { getAvatarUrl } from "@/utils/avatar";
|
||||
import { useI18n } from "@/lib/i18n/provider";
|
||||
|
||||
function TypewriterText({
|
||||
text,
|
||||
@@ -81,6 +82,7 @@ export function MessageList({
|
||||
internalChatMode = false,
|
||||
leftAvatarBySenderId,
|
||||
}: MessageListProps) {
|
||||
const { t } = useI18n();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const messageRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
||||
const shouldStickToBottomRef = useRef(true);
|
||||
@@ -90,10 +92,61 @@ export function MessageList({
|
||||
const lastMessageIdRef = useRef<number | null>(null);
|
||||
const lastMessageCountRef = useRef<number>(0);
|
||||
const hasInitialScrolledRef = useRef(false); // 标记是否已经完成初始滚动
|
||||
/** 逐字打字效果:避免历史消息在重进会话/重开小窗时重复播放 */
|
||||
const typewriterInitializedRef = useRef(false);
|
||||
const typewriterSeenIdsRef = useRef<Set<number>>(new Set());
|
||||
// 图片预览状态(必须在所有条件返回之前声明)
|
||||
const [imagePreviewOpen, setImagePreviewOpen] = useState(false);
|
||||
const [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null);
|
||||
|
||||
const typewriterStorageKey =
|
||||
conversationId != null ? `ai_cs_typewriter_seen_ai_${conversationId}` : null;
|
||||
|
||||
const loadTypewriterSeenSet = useCallback(() => {
|
||||
if (typeof window === "undefined" || !typewriterStorageKey) {
|
||||
typewriterSeenIdsRef.current = new Set();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(typewriterStorageKey);
|
||||
if (!raw) {
|
||||
typewriterSeenIdsRef.current = new Set();
|
||||
return;
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) {
|
||||
typewriterSeenIdsRef.current = new Set();
|
||||
return;
|
||||
}
|
||||
typewriterSeenIdsRef.current = new Set(
|
||||
parsed.map((n) => Number(n)).filter((n) => Number.isFinite(n))
|
||||
);
|
||||
} catch {
|
||||
typewriterSeenIdsRef.current = new Set();
|
||||
}
|
||||
}, [typewriterStorageKey]);
|
||||
|
||||
const persistTypewriterSeenSet = useCallback(() => {
|
||||
if (typeof window === "undefined" || !typewriterStorageKey) return;
|
||||
try {
|
||||
const ids = Array.from(typewriterSeenIdsRef.current);
|
||||
const sliced = ids.length > 600 ? ids.slice(ids.length - 600) : ids;
|
||||
window.sessionStorage.setItem(typewriterStorageKey, JSON.stringify(sliced));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [typewriterStorageKey]);
|
||||
|
||||
const markTypewriterSeen = useCallback(
|
||||
(messageId: number) => {
|
||||
if (!Number.isFinite(messageId)) return;
|
||||
if (typewriterSeenIdsRef.current.has(messageId)) return;
|
||||
typewriterSeenIdsRef.current.add(messageId);
|
||||
persistTypewriterSeenSet();
|
||||
},
|
||||
[persistTypewriterSeenSet]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (conversationId !== lastConversationIdRef.current) {
|
||||
lastConversationIdRef.current = conversationId;
|
||||
@@ -101,9 +154,28 @@ export function MessageList({
|
||||
lastMessageIdRef.current = null;
|
||||
lastMessageCountRef.current = 0;
|
||||
hasInitialScrolledRef.current = false; // 重置初始滚动标记
|
||||
typewriterInitializedRef.current = false;
|
||||
loadTypewriterSeenSet();
|
||||
}
|
||||
}, [conversationId]);
|
||||
|
||||
// 首次加载某个会话的历史消息:全部视作“已展示过打字”,避免重复播放
|
||||
useEffect(() => {
|
||||
if (typewriterInitializedRef.current) return;
|
||||
if (!messages || messages.length === 0) return;
|
||||
// 确保已载入 storage
|
||||
if (typewriterSeenIdsRef.current.size === 0) {
|
||||
loadTypewriterSeenSet();
|
||||
}
|
||||
for (const msg of messages) {
|
||||
const isAIMessage = Boolean(msg.sender_is_agent) && msg.sender_id === 0;
|
||||
if (isAIMessage) {
|
||||
markTypewriterSeen(msg.id);
|
||||
}
|
||||
}
|
||||
typewriterInitializedRef.current = true;
|
||||
}, [messages, loadTypewriterSeenSet, markTypewriterSeen]);
|
||||
|
||||
// 监听滚动事件,当滚动到底部附近时标记消息为已读
|
||||
// 注意:即使 disableAutoScroll 为 true,也应该允许通过滚动来标记消息为已读
|
||||
useEffect(() => {
|
||||
@@ -442,9 +514,11 @@ export function MessageList({
|
||||
: message.content;
|
||||
|
||||
const isAIMessage = Boolean(message.sender_is_agent) && message.sender_id === 0;
|
||||
// 仅当不需要高亮搜索关键词、且该消息为 AI 回复时才启用逐字显示
|
||||
const hasShownTypewriter = typewriterSeenIdsRef.current.has(message.id);
|
||||
// 仅当不需要高亮搜索关键词、且该消息为 AI 回复、且从未展示过打字效果时才启用逐字显示
|
||||
const shouldTypewriter =
|
||||
isAIMessage &&
|
||||
!hasShownTypewriter &&
|
||||
keyword === "" &&
|
||||
!message.file_url &&
|
||||
typeof message.content === "string" &&
|
||||
@@ -553,7 +627,13 @@ export function MessageList({
|
||||
{message.content && (
|
||||
<div className="whitespace-pre-wrap break-words text-sm">
|
||||
{shouldTypewriter ? (
|
||||
<TypewriterText text={message.content} animateKey={message.id} />
|
||||
(() => {
|
||||
// 标记为已展示,避免重新进入会话/重开小窗时重复打字
|
||||
markTypewriterSeen(message.id);
|
||||
return (
|
||||
<TypewriterText text={message.content} animateKey={message.id} />
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
bubbleContent
|
||||
)}
|
||||
@@ -621,9 +701,9 @@ export function MessageList({
|
||||
<div className="mt-1 text-[10px] text-muted-foreground flex flex-wrap gap-x-2 gap-y-0">
|
||||
{message.sources_used.split(",").map((s) => s.trim()).filter(Boolean).map((src) => (
|
||||
<span key={src}>
|
||||
{src === "knowledge_base" && "已使用知识库"}
|
||||
{src === "llm" && "已使用大模型"}
|
||||
{src === "web" && "已使用联网搜索"}
|
||||
{src === "knowledge_base" && t("agent.aiSource.kb")}
|
||||
{src === "llm" && t("agent.aiSource.llm")}
|
||||
{src === "web" && t("agent.aiSource.web")}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -7,8 +7,11 @@ import { Button } from "@/components/ui/button";
|
||||
import { websiteConfig } from "@/lib/website-config";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { LanguageSwitcher } from "@/components/i18n/LanguageSwitcher";
|
||||
import { useI18n } from "@/lib/i18n/provider";
|
||||
import {
|
||||
AGENT_PAGES,
|
||||
type AgentPageItem,
|
||||
type NavigationPage,
|
||||
} from "@/lib/constants/agent-pages";
|
||||
|
||||
@@ -33,6 +36,7 @@ export function NavigationSidebar({
|
||||
unreadChatCount = 0,
|
||||
}: NavigationSidebarProps) {
|
||||
const { agent } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const [profileMenuOpen, setProfileMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -68,7 +72,7 @@ export function NavigationSidebar({
|
||||
const need = p.requiredPermission;
|
||||
if (!need) return true;
|
||||
return permissions.includes(need);
|
||||
});
|
||||
}) as unknown as AgentPageItem[];
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
@@ -78,6 +82,7 @@ export function NavigationSidebar({
|
||||
const isActive = currentPage === page.id;
|
||||
const Icon = page.Icon;
|
||||
const showUnread = page.id === "dashboard" && unreadChatCount > 0;
|
||||
const pageTitle = page.titleKey ? t(page.titleKey) : page.title;
|
||||
return (
|
||||
<Tooltip key={page.id}>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -88,7 +93,7 @@ export function NavigationSidebar({
|
||||
: "bg-white border border-gray-200 hover:bg-gray-100 text-gray-700"
|
||||
}`}
|
||||
onClick={() => handleNavigate(page.id as NavigationPage)}
|
||||
aria-label={page.title}
|
||||
aria-label={pageTitle}
|
||||
>
|
||||
<div className="relative flex items-center justify-center">
|
||||
<Icon className={`h-5 w-5 ${isActive ? "text-white" : "text-gray-600"}`} />
|
||||
@@ -103,7 +108,7 @@ export function NavigationSidebar({
|
||||
</div>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{page.title}</TooltipContent>
|
||||
<TooltipContent side="right">{pageTitle}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
@@ -111,6 +116,7 @@ export function NavigationSidebar({
|
||||
|
||||
{/* 个人资料按钮和 GitHub 按钮(固定在底部) */}
|
||||
<div className="mt-auto flex flex-col items-center gap-2">
|
||||
<LanguageSwitcher variant="ghost" size="icon" className="text-gray-700 hover:text-gray-900" />
|
||||
<div className="relative" ref={menuRef}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -121,7 +127,7 @@ export function NavigationSidebar({
|
||||
: "bg-white border border-gray-200 hover:bg-gray-100"
|
||||
}`}
|
||||
onClick={() => setProfileMenuOpen(!profileMenuOpen)}
|
||||
aria-label="个人资料"
|
||||
aria-label={t("agent.profile")}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
{fullAvatarUrl ? (
|
||||
@@ -141,7 +147,7 @@ export function NavigationSidebar({
|
||||
</div>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">个人资料</TooltipContent>
|
||||
<TooltipContent side="right">{t("agent.profile")}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{profileMenuOpen && (
|
||||
@@ -196,7 +202,7 @@ export function NavigationSidebar({
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
个人资料
|
||||
{t("agent.profile")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -220,7 +226,7 @@ export function NavigationSidebar({
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
退出登录
|
||||
{t("agent.logout")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useI18n } from "@/lib/i18n/provider";
|
||||
import type { Lang } from "@/lib/i18n/dict";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function LanguageSwitcher({
|
||||
variant = "ghost",
|
||||
size = "sm",
|
||||
className = "",
|
||||
}: {
|
||||
variant?: "ghost" | "outline" | "default";
|
||||
size?: "sm" | "icon" | "default";
|
||||
className?: string;
|
||||
}) {
|
||||
const { lang, setLang } = useI18n();
|
||||
|
||||
const next = useMemo<Lang>(() => (lang === "zh-CN" ? "en" : "zh-CN"), [lang]);
|
||||
const label = lang === "zh-CN" ? "EN" : "中文";
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
size={size}
|
||||
onClick={() => setLang(next)}
|
||||
className={className}
|
||||
aria-label="Language"
|
||||
title="Language"
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,66 +3,64 @@
|
||||
import Link from "next/link";
|
||||
import { Github, Mail, MessageSquare } from "lucide-react";
|
||||
import { websiteConfig } from "@/lib/website-config";
|
||||
import { useI18n } from "@/lib/i18n/provider";
|
||||
|
||||
/**
|
||||
* 官网底部页脚
|
||||
* 包含公司信息、友情链接、联系方式等
|
||||
*/
|
||||
export function Footer() {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<footer className="border-t bg-muted/30">
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
{/* 关于产品 */}
|
||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-4">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
|
||||
<span className="text-primary-foreground font-bold text-lg">AI</span>
|
||||
<div className="mb-4 flex items-center space-x-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary">
|
||||
<span className="text-lg font-bold text-primary-foreground">AI</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold">AI-CS</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
AI-CS 是一款 AI 驱动的智能客服系统,融合 AI 技术与人工客服,为企业提供高效、智能的客户服务解决方案。
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-muted-foreground">{t("footer.blurb")}</p>
|
||||
<div className="flex items-center space-x-4">
|
||||
<a
|
||||
href={websiteConfig.github.repo}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
<Github className="w-5 h-5" />
|
||||
<Github className="h-5 w-5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 产品链接 */}
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4">产品</h3>
|
||||
<h3 className="mb-4 font-semibold">{t("footer.column.product")}</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>
|
||||
<Link
|
||||
href="#features"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
核心能力
|
||||
{t("nav.features")}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="#screenshots"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
界面展示
|
||||
{t("nav.screenshots")}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="#quick-start"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
快速接入
|
||||
{t("nav.quickStart")}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
@@ -70,17 +68,16 @@ export function Footer() {
|
||||
href="/agent/login"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
客服登录
|
||||
{t("nav.agentLogin")}
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* 友情链接 */}
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4">友情链接</h3>
|
||||
<h3 className="mb-4 font-semibold">{t("footer.column.friendLinks")}</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{websiteConfig.friendLinks.length > 0 ? (
|
||||
websiteConfig.friendLinks.map((link, index) => (
|
||||
@@ -89,62 +86,68 @@ export function Footer() {
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{link.name}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li className="text-muted-foreground text-xs">
|
||||
暂无友情链接
|
||||
</li>
|
||||
<li className="text-xs text-muted-foreground">{t("footer.noFriendLinks")}</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* 联系我们 */}
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4">联系我们</h3>
|
||||
<h3 className="mb-4 font-semibold">{t("footer.column.contact")}</h3>
|
||||
<ul className="space-y-3 text-sm">
|
||||
{websiteConfig.contact.email ? (
|
||||
<li className="flex items-center space-x-2 text-muted-foreground">
|
||||
<Mail className="h-4 w-4 shrink-0" />
|
||||
<a
|
||||
href={`mailto:${websiteConfig.contact.email}`}
|
||||
aria-label={t("footer.emailLabel")}
|
||||
className="break-all transition-colors hover:text-foreground"
|
||||
>
|
||||
{websiteConfig.contact.email}
|
||||
</a>
|
||||
</li>
|
||||
) : null}
|
||||
<li className="flex items-center space-x-2 text-muted-foreground">
|
||||
<Github className="w-4 h-4" />
|
||||
<Github className="h-4 w-4" />
|
||||
<a
|
||||
href={websiteConfig.github.repo}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground transition-colors"
|
||||
className="transition-colors hover:text-foreground"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li className="flex items-center space-x-2 text-muted-foreground">
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
<Link
|
||||
href="/chat"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
在线客服
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
<Link href="/chat" className="transition-colors hover:text-foreground">
|
||||
{t("footer.onlineChat")}
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 版权信息 */}
|
||||
<div className="mt-8 pt-8 border-t text-center text-sm text-muted-foreground">
|
||||
<div className="mt-8 border-t pt-8 text-center text-sm text-muted-foreground">
|
||||
<p className="mb-2">
|
||||
© {websiteConfig.copyright.year} {websiteConfig.copyright.company}. All rights reserved.
|
||||
© {websiteConfig.copyright.year} {websiteConfig.copyright.company}.{" "}
|
||||
{t("footer.allRightsReserved")}
|
||||
</p>
|
||||
<p>
|
||||
Powered by Next.js & Go |
|
||||
{t("footer.poweredBy")}{" "}
|
||||
<a
|
||||
href={websiteConfig.github.repo}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-1 hover:text-foreground transition-colors"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
开源协议
|
||||
{t("footer.openSourceLicense")}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
@@ -152,4 +155,3 @@ export function Footer() {
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,25 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Github } from "lucide-react";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Github, Menu } from "lucide-react";
|
||||
import { websiteConfig } from "@/lib/website-config";
|
||||
import { LanguageSwitcher } from "@/components/i18n/LanguageSwitcher";
|
||||
import { useI18n } from "@/lib/i18n/provider";
|
||||
|
||||
/**
|
||||
* 官网顶部导航栏
|
||||
* 包含 Logo、导航链接和 GitHub 链接
|
||||
*/
|
||||
export function Header() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b border-border/50 bg-background/80 backdrop-blur-md">
|
||||
<div className="container mx-auto px-6">
|
||||
@@ -21,25 +32,79 @@ export function Header() {
|
||||
<span className="text-[19px] font-semibold text-foreground tracking-tight">AI-CS</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-6 md:gap-8">
|
||||
<div className="flex items-center gap-2 sm:gap-4 md:gap-8">
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="md:hidden"
|
||||
aria-label={t("nav.menu")}
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-[min(100vw-2rem,20rem)]">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="text-left">{t("nav.menu")}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<nav className="flex flex-col gap-1 mt-8">
|
||||
<SheetClose asChild>
|
||||
<Link
|
||||
href="#features"
|
||||
className="py-3 text-[15px] text-muted-foreground hover:text-foreground transition-colors border-b border-border/60"
|
||||
>
|
||||
{t("nav.features")}
|
||||
</Link>
|
||||
</SheetClose>
|
||||
<SheetClose asChild>
|
||||
<Link
|
||||
href="#screenshots"
|
||||
className="py-3 text-[15px] text-muted-foreground hover:text-foreground transition-colors border-b border-border/60"
|
||||
>
|
||||
{t("nav.screenshots")}
|
||||
</Link>
|
||||
</SheetClose>
|
||||
<SheetClose asChild>
|
||||
<Link
|
||||
href="#quick-start"
|
||||
className="py-3 text-[15px] text-muted-foreground hover:text-foreground transition-colors border-b border-border/60"
|
||||
>
|
||||
{t("nav.quickStart")}
|
||||
</Link>
|
||||
</SheetClose>
|
||||
<SheetClose asChild>
|
||||
<Link
|
||||
href="/agent/login"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="py-3 text-[15px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{t("nav.agentLogin")}
|
||||
</Link>
|
||||
</SheetClose>
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<nav className="hidden md:flex items-center gap-6">
|
||||
<Link
|
||||
href="#features"
|
||||
className="text-[15px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
核心能力
|
||||
{t("nav.features")}
|
||||
</Link>
|
||||
<Link
|
||||
href="#screenshots"
|
||||
className="text-[15px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
界面展示
|
||||
{t("nav.screenshots")}
|
||||
</Link>
|
||||
<Link
|
||||
href="#quick-start"
|
||||
className="text-[15px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
快速接入
|
||||
{t("nav.quickStart")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/agent/login"
|
||||
@@ -47,10 +112,11 @@ export function Header() {
|
||||
rel="noopener noreferrer"
|
||||
className="text-[15px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
客服登录
|
||||
{t("nav.agentLogin")}
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<LanguageSwitcher variant="ghost" size="sm" className="hidden sm:flex text-muted-foreground hover:text-foreground" />
|
||||
<Button variant="ghost" size="sm" asChild className="hidden sm:flex text-muted-foreground hover:text-foreground">
|
||||
<a
|
||||
href={websiteConfig.github.repo}
|
||||
@@ -59,7 +125,7 @@ export function Header() {
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Github className="w-4 h-4" />
|
||||
<span>GitHub</span>
|
||||
<span>{t("common.github")}</span>
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" asChild className="sm:hidden text-muted-foreground hover:text-foreground">
|
||||
@@ -72,6 +138,7 @@ export function Header() {
|
||||
<Github className="w-5 h-5" />
|
||||
</a>
|
||||
</Button>
|
||||
<LanguageSwitcher variant="ghost" size="icon" className="sm:hidden text-muted-foreground hover:text-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,23 +3,15 @@
|
||||
import * as React from "react";
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Menu } from "lucide-react";
|
||||
import { Menu, PanelRight } from "lucide-react";
|
||||
import { LAYOUT } from "@/lib/constants/breakpoints";
|
||||
import { useI18n } from "@/lib/i18n/provider";
|
||||
|
||||
/**
|
||||
* ResponsiveLayout - 响应式布局组件
|
||||
*
|
||||
*
|
||||
* 提供统一的响应式布局,支持桌面端和移动端自适应。
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ResponsiveLayout
|
||||
* sidebar={<ConversationSidebar />}
|
||||
* main={<MessageList />}
|
||||
* rightPanel={<VisitorDetailPanel />}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
*
|
||||
* @param sidebar - 侧边栏内容(桌面端显示,移动端可折叠)
|
||||
* @param main - 主内容区(所有设备都显示)
|
||||
* @param rightPanel - 右侧面板(大屏幕显示,小屏幕隐藏或折叠)
|
||||
@@ -32,7 +24,7 @@ export interface ResponsiveLayoutProps {
|
||||
rightPanel?: React.ReactNode;
|
||||
header?: React.ReactNode;
|
||||
className?: string;
|
||||
sidebarWidth?: string; // 侧边栏宽度(可选,默认使用 LAYOUT.sidebarWidth)
|
||||
sidebarWidth?: string;
|
||||
}
|
||||
|
||||
export function ResponsiveLayout({
|
||||
@@ -44,70 +36,79 @@ export function ResponsiveLayout({
|
||||
sidebarWidth,
|
||||
}: ResponsiveLayoutProps) {
|
||||
const actualSidebarWidth = sidebarWidth || LAYOUT.sidebarWidth;
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
/** 与顶部安全区对齐;与 ChatHeader(h-16)上圆钮视觉对齐 */
|
||||
const mobileFabTop =
|
||||
"top-[max(0.75rem,env(safe-area-inset-top,0px)+0.25rem)]";
|
||||
|
||||
return (
|
||||
<div className={`flex h-screen bg-background overflow-hidden ${className || ""}`}>
|
||||
{/* 桌面端侧边栏:中等屏幕及以上显示 */}
|
||||
<div
|
||||
className={`flex h-[100dvh] max-h-[100dvh] bg-background overflow-hidden ${className || ""}`}
|
||||
>
|
||||
{sidebar && (
|
||||
<aside className={`hidden md:block border-r bg-background flex-shrink-0`} style={{ width: actualSidebarWidth }}>
|
||||
<aside
|
||||
className="hidden md:block border-r bg-background flex-shrink-0"
|
||||
style={{ width: actualSidebarWidth }}
|
||||
>
|
||||
{sidebar}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* 移动端侧边栏:使用 Sheet 组件实现可折叠侧边栏 */}
|
||||
{sidebar && (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="fixed top-4 left-4 z-50 md:hidden bg-background/80 backdrop-blur-sm"
|
||||
className={`fixed left-3 z-50 md:hidden h-10 w-10 rounded-full border-border/80 bg-background/90 shadow-sm backdrop-blur-sm ${mobileFabTop}`}
|
||||
aria-label={t("agent.layout.openNavMenu")}
|
||||
>
|
||||
<Menu className="h-6 w-6" />
|
||||
<span className="sr-only">打开菜单</span>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-80 p-0" style={{ width: actualSidebarWidth }}>
|
||||
<SheetContent
|
||||
side="left"
|
||||
className="w-[min(100vw-2rem,24rem)] max-w-[24rem] p-0"
|
||||
style={{ width: actualSidebarWidth }}
|
||||
>
|
||||
{sidebar}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
|
||||
{/* 主内容区 */}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
{/* 顶部栏(如果提供) */}
|
||||
{header && (
|
||||
<header className="flex-shrink-0 border-b bg-background">
|
||||
{header}
|
||||
</header>
|
||||
<header className="flex-shrink-0 border-b bg-background">{header}</header>
|
||||
)}
|
||||
|
||||
{/* 主内容区和右侧面板容器 */}
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
{/* 主内容区 */}
|
||||
<main className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
{main}
|
||||
</main>
|
||||
<main className="flex-1 flex flex-col min-h-0 overflow-hidden">{main}</main>
|
||||
|
||||
{/* 右侧面板:大屏幕显示,小屏幕隐藏 */}
|
||||
{rightPanel && (
|
||||
<>
|
||||
<aside className={`hidden lg:block border-l bg-background flex-shrink-0`} style={{ width: LAYOUT.rightPanelWidth }}>
|
||||
<aside
|
||||
className="hidden lg:block border-l bg-background flex-shrink-0"
|
||||
style={{ width: LAYOUT.rightPanelWidth }}
|
||||
>
|
||||
{rightPanel}
|
||||
</aside>
|
||||
{/* 移动端右侧面板:使用 Sheet 组件实现可折叠面板 */}
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="fixed top-4 right-4 z-50 lg:hidden bg-background/80 backdrop-blur-sm"
|
||||
className={`fixed right-3 z-50 lg:hidden h-10 w-10 rounded-full border-border/80 bg-background/90 shadow-sm backdrop-blur-sm ${mobileFabTop}`}
|
||||
aria-label={t("agent.layout.openVisitorPanel")}
|
||||
>
|
||||
<Menu className="h-6 w-6" />
|
||||
<span className="sr-only">打开详情</span>
|
||||
<PanelRight className="h-5 w-5" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-80 p-0" style={{ width: LAYOUT.rightPanelWidth }}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-[min(100vw-2rem,20rem)] max-w-[20rem] p-0"
|
||||
style={{ width: LAYOUT.rightPanelWidth }}
|
||||
>
|
||||
{rightPanel}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
@@ -118,4 +119,3 @@ export function ResponsiveLayout({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,145 +29,121 @@ import { Footer } from "@/components/layout/Footer";
|
||||
import { FadeIn, FadeInStagger, FadeInItem } from "@/components/ui/fade-in";
|
||||
import { websiteConfig } from "@/lib/website-config";
|
||||
import { stats } from "@/lib/stats-config";
|
||||
import type { I18nKey } from "@/lib/i18n/dict";
|
||||
import { useI18n } from "@/lib/i18n/provider";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
const capabilityCards = [
|
||||
{
|
||||
icon: Bot,
|
||||
title: "多模型 AI 客服",
|
||||
description:
|
||||
"支持配置多家大模型与绘画等能力,访客与后台可统一管理模型与使用方式,便于替换供应商、控制成本。",
|
||||
},
|
||||
{
|
||||
icon: BookOpen,
|
||||
title: "知识库与 RAG",
|
||||
description:
|
||||
"文档入库、向量检索,让回答贴近你的业务资料;回复可标记是否使用知识库、模型或联网,便于核对与优化。",
|
||||
},
|
||||
{
|
||||
icon: Wand2,
|
||||
title: "提示词工程",
|
||||
description:
|
||||
"配置系统中使用的提示词模板,用于不同领域 RAG、联网等不同的业务场景。",
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: "人工客服与实时协作",
|
||||
description:
|
||||
"在线状态、会话实时推送(WebSocket),支持人工接管与日常协作;访客小窗可嵌入任意站点。",
|
||||
},
|
||||
{
|
||||
icon: LineChart,
|
||||
title: "可视化报表",
|
||||
description:
|
||||
"按日或自定义区间查看访客小窗打开、会话与消息、AI 回复与失败率、知识库命中率等指标,快速掌握运营态势。",
|
||||
},
|
||||
{
|
||||
icon: ScrollText,
|
||||
title: "日志中心",
|
||||
description:
|
||||
"结构化日志按分类与事件落库,支持 trace_id 与关键字筛选,关键链路与异常可追溯,便于排障与审计。",
|
||||
},
|
||||
const CAPABILITY_ITEMS: {
|
||||
icon: LucideIcon;
|
||||
titleKey: I18nKey;
|
||||
descKey: I18nKey;
|
||||
}[] = [
|
||||
{ icon: Bot, titleKey: "home.cap.multimodel.title", descKey: "home.cap.multimodel.desc" },
|
||||
{ icon: BookOpen, titleKey: "home.cap.kb.title", descKey: "home.cap.kb.desc" },
|
||||
{ icon: Wand2, titleKey: "home.cap.prompt.title", descKey: "home.cap.prompt.desc" },
|
||||
{ icon: Users, titleKey: "home.cap.human.title", descKey: "home.cap.human.desc" },
|
||||
{ icon: LineChart, titleKey: "home.cap.reports.title", descKey: "home.cap.reports.desc" },
|
||||
{ icon: ScrollText, titleKey: "home.cap.logs.title", descKey: "home.cap.logs.desc" },
|
||||
];
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: "克隆与配置",
|
||||
body: "复制 .env 模板,填好数据库与管理员等必填项。",
|
||||
},
|
||||
{
|
||||
title: "一键启动",
|
||||
body: "使用 Docker Compose 拉起前后端与依赖服务(详见 README)。",
|
||||
},
|
||||
{
|
||||
title: "嵌入访客端",
|
||||
body: "在站点中挂载聊天小窗,后台完成模型与知识库配置后即可对外服务。",
|
||||
},
|
||||
const QUICK_STEPS: { titleKey: I18nKey; bodyKey: I18nKey }[] = [
|
||||
{ titleKey: "home.step1.title", bodyKey: "home.step1.body" },
|
||||
{ titleKey: "home.step2.title", bodyKey: "home.step2.body" },
|
||||
{ titleKey: "home.step3.title", bodyKey: "home.step3.body" },
|
||||
];
|
||||
|
||||
const screenshotCards = [
|
||||
const SCREENSHOT_ITEMS: {
|
||||
slug: string;
|
||||
imageName: string;
|
||||
placeholderIcon: LucideIcon;
|
||||
titleKey: I18nKey;
|
||||
placeholderKey: I18nKey;
|
||||
altKey: I18nKey;
|
||||
}[] = [
|
||||
{
|
||||
key: "dashboard",
|
||||
title: "工作台",
|
||||
slug: "dashboard",
|
||||
imageName: "dashboard.png",
|
||||
placeholderIcon: LayoutDashboard,
|
||||
placeholderText: "工作台界面",
|
||||
alt: "AI-CS 工作台界面",
|
||||
titleKey: "home.ss.dashboard.title",
|
||||
placeholderKey: "home.ss.dashboard.placeholder",
|
||||
altKey: "home.ss.dashboard.alt",
|
||||
},
|
||||
{
|
||||
key: "visitor",
|
||||
title: "访客端",
|
||||
slug: "visitor",
|
||||
imageName: "visitor.png",
|
||||
placeholderIcon: Globe,
|
||||
placeholderText: "访客端界面",
|
||||
alt: "AI-CS 访客端界面",
|
||||
titleKey: "home.ss.visitor.title",
|
||||
placeholderKey: "home.ss.visitor.placeholder",
|
||||
altKey: "home.ss.visitor.alt",
|
||||
},
|
||||
{
|
||||
key: "ai-config",
|
||||
title: "AI配置",
|
||||
slug: "ai-config",
|
||||
imageName: "ai-config.png",
|
||||
placeholderIcon: Bot,
|
||||
placeholderText: "AI配置界面",
|
||||
alt: "AI-CS AI配置界面",
|
||||
titleKey: "home.ss.aiconfig.title",
|
||||
placeholderKey: "home.ss.aiconfig.placeholder",
|
||||
altKey: "home.ss.aiconfig.alt",
|
||||
},
|
||||
{
|
||||
key: "users",
|
||||
title: "用户管理",
|
||||
slug: "users",
|
||||
imageName: "users.png",
|
||||
placeholderIcon: Users,
|
||||
placeholderText: "用户管理界面",
|
||||
alt: "AI-CS 用户管理界面",
|
||||
titleKey: "home.ss.users.title",
|
||||
placeholderKey: "home.ss.users.placeholder",
|
||||
altKey: "home.ss.users.alt",
|
||||
},
|
||||
{
|
||||
key: "faq",
|
||||
title: "FAQ管理",
|
||||
slug: "faq",
|
||||
imageName: "faq.png",
|
||||
placeholderIcon: FileText,
|
||||
placeholderText: "FAQ管理界面",
|
||||
alt: "AI-CS FAQ管理界面",
|
||||
titleKey: "home.ss.faq.title",
|
||||
placeholderKey: "home.ss.faq.placeholder",
|
||||
altKey: "home.ss.faq.alt",
|
||||
},
|
||||
{
|
||||
key: "knowledge",
|
||||
title: "知识库管理",
|
||||
slug: "knowledge",
|
||||
imageName: "knowledge.png",
|
||||
placeholderIcon: BookOpen,
|
||||
placeholderText: "知识库管理界面",
|
||||
alt: "AI-CS 知识库管理界面",
|
||||
titleKey: "home.ss.knowledge.title",
|
||||
placeholderKey: "home.ss.knowledge.placeholder",
|
||||
altKey: "home.ss.knowledge.alt",
|
||||
},
|
||||
{
|
||||
key: "conversations",
|
||||
title: "知识库测试",
|
||||
slug: "conversations",
|
||||
imageName: "conversations.png",
|
||||
placeholderIcon: MessageSquare,
|
||||
placeholderText: "知识库测试界面",
|
||||
alt: "AI-CS 知识库测试界面",
|
||||
titleKey: "home.ss.kbtest.title",
|
||||
placeholderKey: "home.ss.kbtest.placeholder",
|
||||
altKey: "home.ss.kbtest.alt",
|
||||
},
|
||||
{
|
||||
key: "prompts",
|
||||
title: "提示词工程",
|
||||
slug: "prompts",
|
||||
imageName: "prompts.png",
|
||||
placeholderIcon: Wand2,
|
||||
placeholderText: "提示词工程界面",
|
||||
alt: "AI-CS 提示词工程界面",
|
||||
titleKey: "home.ss.prompts.title",
|
||||
placeholderKey: "home.ss.prompts.placeholder",
|
||||
altKey: "home.ss.prompts.alt",
|
||||
},
|
||||
{
|
||||
key: "logs",
|
||||
title: "日志中心",
|
||||
slug: "logs",
|
||||
imageName: "logs.png",
|
||||
placeholderIcon: ScrollText,
|
||||
placeholderText: "日志中心界面",
|
||||
alt: "AI-CS 日志中心界面",
|
||||
titleKey: "home.ss.logs.title",
|
||||
placeholderKey: "home.ss.logs.placeholder",
|
||||
altKey: "home.ss.logs.alt",
|
||||
},
|
||||
{
|
||||
key: "analytics",
|
||||
title: "可视化报表",
|
||||
slug: "analytics",
|
||||
imageName: "analytics.png",
|
||||
placeholderIcon: LineChart,
|
||||
placeholderText: "可视化报表界面",
|
||||
alt: "AI-CS 可视化报表界面",
|
||||
titleKey: "home.ss.analytics.title",
|
||||
placeholderKey: "home.ss.analytics.placeholder",
|
||||
altKey: "home.ss.analytics.alt",
|
||||
},
|
||||
];
|
||||
|
||||
export function HomePageClient() {
|
||||
const { t } = useI18n();
|
||||
const [visitorId, setVisitorId] = useState<number | null>(null);
|
||||
const [isChatOpen, setIsChatOpen] = useState(false);
|
||||
const [activeScreenshot, setActiveScreenshot] = useState(0);
|
||||
@@ -192,7 +168,7 @@ export function HomePageClient() {
|
||||
}
|
||||
};
|
||||
|
||||
const totalScreenshots = screenshotCards.length;
|
||||
const totalScreenshots = SCREENSHOT_ITEMS.length;
|
||||
const prevScreenshotIndex =
|
||||
(activeScreenshot - 1 + totalScreenshots) % totalScreenshots;
|
||||
const nextScreenshotIndex = (activeScreenshot + 1) % totalScreenshots;
|
||||
@@ -230,13 +206,13 @@ export function HomePageClient() {
|
||||
<FadeIn>
|
||||
<div className="mx-auto max-w-4xl text-center">
|
||||
<p className="mb-4 text-sm font-medium text-muted-foreground tracking-wide uppercase">
|
||||
AI 智能客服
|
||||
{t("home.hero.tagline")}
|
||||
</p>
|
||||
<h1 className="mb-6 text-balance text-4xl font-bold tracking-tight text-foreground sm:text-5xl md:text-6xl md:leading-[1.12]">
|
||||
让客户服务更简单、更高效
|
||||
{t("home.hero.title")}
|
||||
</h1>
|
||||
<p className="mx-auto mb-10 max-w-3xl text-pretty text-lg sm:text-xl text-muted-foreground leading-relaxed">
|
||||
7×24 小时智能应答,AI 与人工无缝切换,释放团队时间专注更有价值的事
|
||||
{t("home.hero.subtitle")}
|
||||
</p>
|
||||
<div className="flex flex-col items-stretch justify-center gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<Button
|
||||
@@ -244,7 +220,7 @@ export function HomePageClient() {
|
||||
className="rounded-xl bg-blue-600 px-8 py-6 text-[15px] shadow-sm transition-all hover:bg-blue-500 hover:shadow-md"
|
||||
onClick={handleOpenChat}
|
||||
>
|
||||
立即体验
|
||||
{t("home.hero.cta.tryNow")}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
@@ -259,11 +235,11 @@ export function HomePageClient() {
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center gap-2"
|
||||
>
|
||||
客服登录
|
||||
{t("home.hero.cta.agentLogin")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-muted-foreground">无需等待,可立即使用</p>
|
||||
<p className="mt-4 text-sm text-muted-foreground">{t("home.hero.hint")}</p>
|
||||
</div>
|
||||
</FadeIn>
|
||||
</div>
|
||||
@@ -274,13 +250,15 @@ export function HomePageClient() {
|
||||
<FadeIn>
|
||||
<div className="container mx-auto px-6">
|
||||
<p className="text-xs font-medium text-muted-foreground text-center mb-8 tracking-wide">
|
||||
深受企业信赖
|
||||
{t("home.stats.trustedBy")}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-10 max-w-6xl mx-auto">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label} className="text-center">
|
||||
<div className="text-3xl md:text-4xl font-semibold text-foreground">{stat.value}</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">{stat.label}</div>
|
||||
<div key={stat.labelKey} className="text-center">
|
||||
<div className="text-3xl md:text-4xl font-semibold text-foreground">
|
||||
{t(stat.valueKey)}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">{t(stat.labelKey)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -295,28 +273,28 @@ export function HomePageClient() {
|
||||
<FadeIn>
|
||||
<div className="mb-14 text-center px-4">
|
||||
<h2 className="mb-3 text-3xl font-semibold tracking-tight text-foreground sm:text-4xl">
|
||||
核心能力
|
||||
{t("home.features.title")}
|
||||
</h2>
|
||||
<p className="mx-auto max-w-xl text-base text-muted-foreground">
|
||||
从模型、知识库、提示词到人工协作、报表与日志,一套系统串起来。
|
||||
{t("home.features.lead")}
|
||||
</p>
|
||||
</div>
|
||||
</FadeIn>
|
||||
<FadeInStagger className="mx-auto grid max-w-6xl grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 lg:gap-6">
|
||||
{capabilityCards.map((item) => {
|
||||
{CAPABILITY_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<FadeInItem key={item.title}>
|
||||
<FadeInItem key={item.titleKey}>
|
||||
<Card className="group h-full border border-border/60 bg-card/90 shadow-sm backdrop-blur-sm transition-all duration-300 hover:-translate-y-0.5 hover:border-blue-200/70 hover:shadow-md">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="mb-4 flex h-11 w-11 items-center justify-center rounded-xl border border-blue-100/80 bg-gradient-to-br from-blue-50 to-background text-blue-700 transition-transform duration-300 group-hover:scale-[1.03]">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<CardTitle className="text-lg font-semibold tracking-tight">
|
||||
{item.title}
|
||||
{t(item.titleKey)}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm leading-relaxed text-muted-foreground">
|
||||
{item.description}
|
||||
{t(item.descKey)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
@@ -336,17 +314,17 @@ export function HomePageClient() {
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="mb-14 text-center px-4">
|
||||
<h2 className="mb-3 text-3xl font-semibold tracking-tight sm:text-4xl">
|
||||
界面展示
|
||||
{t("home.screenshots.title")}
|
||||
</h2>
|
||||
<p className="mx-auto max-w-xl text-muted-foreground">
|
||||
精心设计的界面,让管理更轻松
|
||||
{t("home.screenshots.lead")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="mb-8 flex flex-wrap justify-center gap-2">
|
||||
{screenshotCards.map((item, idx) => (
|
||||
{SCREENSHOT_ITEMS.map((item, idx) => (
|
||||
<button
|
||||
key={item.key}
|
||||
key={item.slug}
|
||||
type="button"
|
||||
onClick={() => setActiveScreenshot(idx)}
|
||||
className={`rounded-full px-4 py-1.5 text-sm transition-all ${
|
||||
@@ -355,7 +333,7 @@ export function HomePageClient() {
|
||||
: "bg-background text-muted-foreground border border-border/70 hover:text-foreground hover:border-blue-200"
|
||||
}`}
|
||||
>
|
||||
{item.title}
|
||||
{t(item.titleKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -368,7 +346,7 @@ export function HomePageClient() {
|
||||
type="button"
|
||||
onClick={goPrevScreenshot}
|
||||
className="absolute left-0 top-1/2 z-40 -translate-y-1/2 rounded-full border border-border/70 bg-background/90 p-2.5 shadow-sm backdrop-blur transition hover:border-blue-200 hover:bg-background"
|
||||
aria-label="查看上一张"
|
||||
aria-label={t("home.screenshots.prevAria")}
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -377,7 +355,7 @@ export function HomePageClient() {
|
||||
type="button"
|
||||
onClick={goNextScreenshot}
|
||||
className="absolute right-0 top-1/2 z-40 -translate-y-1/2 rounded-full border border-border/70 bg-background/90 p-2.5 shadow-sm backdrop-blur transition hover:border-blue-200 hover:bg-background"
|
||||
aria-label="查看下一张"
|
||||
aria-label={t("home.screenshots.nextAria")}
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -386,10 +364,10 @@ export function HomePageClient() {
|
||||
<div className="absolute left-6 right-[42%] top-7 z-10 hidden overflow-hidden rounded-2xl border border-border/60 bg-background/85 shadow-md md:block">
|
||||
<div className="pointer-events-none absolute inset-0 z-10 bg-background/18" />
|
||||
<ScreenshotDisplay
|
||||
imageName={screenshotCards[prevScreenshotIndex].imageName}
|
||||
placeholderIcon={screenshotCards[prevScreenshotIndex].placeholderIcon}
|
||||
placeholderText={screenshotCards[prevScreenshotIndex].placeholderText}
|
||||
alt={screenshotCards[prevScreenshotIndex].alt}
|
||||
imageName={SCREENSHOT_ITEMS[prevScreenshotIndex].imageName}
|
||||
placeholderIcon={SCREENSHOT_ITEMS[prevScreenshotIndex].placeholderIcon}
|
||||
placeholderText={t(SCREENSHOT_ITEMS[prevScreenshotIndex].placeholderKey)}
|
||||
alt={t(SCREENSHOT_ITEMS[prevScreenshotIndex].altKey)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -397,20 +375,20 @@ export function HomePageClient() {
|
||||
<div className="absolute left-[42%] right-6 top-7 z-10 hidden overflow-hidden rounded-2xl border border-border/60 bg-background/85 shadow-md md:block">
|
||||
<div className="pointer-events-none absolute inset-0 z-10 bg-background/18" />
|
||||
<ScreenshotDisplay
|
||||
imageName={screenshotCards[nextScreenshotIndex].imageName}
|
||||
placeholderIcon={screenshotCards[nextScreenshotIndex].placeholderIcon}
|
||||
placeholderText={screenshotCards[nextScreenshotIndex].placeholderText}
|
||||
alt={screenshotCards[nextScreenshotIndex].alt}
|
||||
imageName={SCREENSHOT_ITEMS[nextScreenshotIndex].imageName}
|
||||
placeholderIcon={SCREENSHOT_ITEMS[nextScreenshotIndex].placeholderIcon}
|
||||
placeholderText={t(SCREENSHOT_ITEMS[nextScreenshotIndex].placeholderKey)}
|
||||
alt={t(SCREENSHOT_ITEMS[nextScreenshotIndex].altKey)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 中间主卡片 */}
|
||||
<div className="absolute inset-x-8 top-0 z-30 overflow-hidden rounded-2xl border border-border/70 bg-background shadow-xl ring-1 ring-blue-100/60 md:inset-x-16 lg:inset-x-24">
|
||||
<ScreenshotDisplay
|
||||
imageName={screenshotCards[activeScreenshot].imageName}
|
||||
placeholderIcon={screenshotCards[activeScreenshot].placeholderIcon}
|
||||
placeholderText={screenshotCards[activeScreenshot].placeholderText}
|
||||
alt={screenshotCards[activeScreenshot].alt}
|
||||
imageName={SCREENSHOT_ITEMS[activeScreenshot].imageName}
|
||||
placeholderIcon={SCREENSHOT_ITEMS[activeScreenshot].placeholderIcon}
|
||||
placeholderText={t(SCREENSHOT_ITEMS[activeScreenshot].placeholderKey)}
|
||||
alt={t(SCREENSHOT_ITEMS[activeScreenshot].altKey)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -430,20 +408,20 @@ export function HomePageClient() {
|
||||
<FadeIn>
|
||||
<div className="mb-12 text-center px-4">
|
||||
<h2 className="mb-3 text-3xl font-semibold tracking-tight sm:text-4xl">
|
||||
快速接入
|
||||
{t("home.quickStart.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">三步跑通,从仓库到访客小窗。</p>
|
||||
<p className="text-muted-foreground">{t("home.quickStart.lead")}</p>
|
||||
</div>
|
||||
</FadeIn>
|
||||
<FadeInStagger className="mx-auto grid max-w-4xl grid-cols-1 gap-8 md:grid-cols-3">
|
||||
{steps.map((step, i) => (
|
||||
<FadeInItem key={step.title}>
|
||||
{QUICK_STEPS.map((step, i) => (
|
||||
<FadeInItem key={step.titleKey}>
|
||||
<div className="relative rounded-2xl border border-border/60 bg-card/50 p-6 text-center md:text-left transition-all duration-300 hover:border-blue-200/70 hover:shadow-md hover:-translate-y-0.5">
|
||||
<div className="mx-auto mb-4 flex h-10 w-10 items-center justify-center rounded-full border border-blue-200/80 bg-blue-50/80 text-sm font-semibold text-blue-800 md:mx-0">
|
||||
{i + 1}
|
||||
</div>
|
||||
<h3 className="mb-2 font-semibold">{step.title}</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{step.body}</p>
|
||||
<h3 className="mb-2 font-semibold">{t(step.titleKey)}</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{t(step.bodyKey)}</p>
|
||||
</div>
|
||||
</FadeInItem>
|
||||
))}
|
||||
@@ -460,10 +438,10 @@ export function HomePageClient() {
|
||||
<div className="container relative mx-auto px-6 py-20 text-center md:py-28">
|
||||
<FadeIn>
|
||||
<h2 className="mb-4 text-3xl font-semibold tracking-tight sm:text-4xl">
|
||||
准备好把 AI-CS 接到你的产品里了吗?
|
||||
{t("home.cta.title")}
|
||||
</h2>
|
||||
<p className="mx-auto mb-10 max-w-lg text-muted-foreground leading-relaxed">
|
||||
从开源仓库开始,或用在线 Demo 先看交互与能力边界。
|
||||
{t("home.cta.subtitle")}
|
||||
</p>
|
||||
<div className="flex flex-col items-stretch justify-center gap-3 sm:flex-row sm:flex-wrap sm:justify-center">
|
||||
<Button size="lg" className="rounded-xl bg-blue-600 px-8 shadow-sm hover:bg-blue-500" asChild>
|
||||
@@ -474,18 +452,15 @@ export function HomePageClient() {
|
||||
className="inline-flex items-center justify-center gap-2"
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
Star / Fork 仓库
|
||||
{t("home.cta.starRepo")}
|
||||
</a>
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" className="rounded-xl border-border/80 px-8 bg-background/80" asChild>
|
||||
<a
|
||||
// 点击后直接唤起邮箱客户端(可在客户端自动带上主题/正文)
|
||||
href={`mailto:2930134478@qq.com?subject=${encodeURIComponent("AI-CS 建议反馈")}&body=${encodeURIComponent(
|
||||
"你好,我想反馈:\n\n1)问题/建议:\n2)影响范围/环境:\n3)期望结果:\n\n---\n联系方式(可选):"
|
||||
)}`}
|
||||
href={`mailto:2930134478@qq.com?subject=${encodeURIComponent(t("home.cta.mailSubject"))}&body=${encodeURIComponent(t("home.cta.mailBody"))}`}
|
||||
className="inline-flex items-center justify-center gap-2"
|
||||
>
|
||||
建议反馈
|
||||
{t("home.cta.feedback")}
|
||||
<Mail className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
@@ -39,6 +39,8 @@ import { playNotificationSound } from "@/utils/sound";
|
||||
import { getAvatarUrl } from "@/utils/avatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Check, ChevronDown, Loader2 } from "lucide-react";
|
||||
import { useI18n } from "@/lib/i18n/provider";
|
||||
import { LanguageSwitcher } from "@/components/i18n/LanguageSwitcher";
|
||||
|
||||
interface ChatWidgetProps {
|
||||
visitorId: number;
|
||||
@@ -93,6 +95,7 @@ const CHAT_WIDGET_PANEL_MAX_W =
|
||||
* 提供小窗形式的聊天界面,支持展开/收起
|
||||
*/
|
||||
export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
|
||||
const { t } = useI18n();
|
||||
const WEB_SEARCH_PREF_KEY = "visitor_widget_need_web_search";
|
||||
// 数据分析:每次由关→开上报一次小窗打开(供后台「小窗打开次数」统计)
|
||||
const prevIsOpenRef = useRef(false);
|
||||
@@ -741,9 +744,14 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-base font-bold text-white truncate">客服聊天</h2>
|
||||
<h2 className="text-base font-bold text-white truncate">{t("chat.title")}</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<LanguageSwitcher
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-white/90 hover:text-white hover:bg-white/20 h-8 px-2 rounded-lg transition-colors"
|
||||
/>
|
||||
{/* 声音开关按钮 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -830,7 +838,7 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
|
||||
: "bg-white text-slate-700 hover:text-slate-900 hover:bg-slate-100 border border-slate-300"
|
||||
}
|
||||
>
|
||||
人工客服
|
||||
{t("chat.mode.human")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={chatMode === "ai" ? "default" : "outline"}
|
||||
@@ -844,7 +852,7 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
|
||||
: "bg-white text-slate-700 hover:text-slate-900 hover:bg-slate-100 border border-slate-300"
|
||||
}
|
||||
>
|
||||
AI 客服
|
||||
{t("chat.mode.ai")}
|
||||
</Button>
|
||||
</div>
|
||||
{/* 模型选择已下沉到输入区发送按钮左侧(仅 AI 模式显示) */}
|
||||
|
||||
@@ -202,6 +202,56 @@ export function useMessages({
|
||||
refreshConversationDetail(conversationId);
|
||||
}, [conversationId, agentId, effectiveIncludeAIMessages, loadMessages, refreshConversationDetail]);
|
||||
|
||||
/**
|
||||
* 关闭「显示 AI 消息」时,列表接口会过滤 chat_mode=ai 的访客消息;
|
||||
* 它们仍会计入 unread_count,但 MessageList 里看不到未读,滚动逻辑永远不会触发标记已读。
|
||||
* 此时在加载完成后自动调用一次 mark,清掉「仅存在于 AI 分段」里的访客未读。
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!conversationId || !agentId) {
|
||||
return;
|
||||
}
|
||||
if (loadingMessages) {
|
||||
return;
|
||||
}
|
||||
if (effectiveIncludeAIMessages) {
|
||||
return;
|
||||
}
|
||||
if (conversationDetail && conversationDetail.id !== conversationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const serverUnread = Number(conversationDetail?.unread_count ?? 0);
|
||||
if (serverUnread <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messagesBelongToConv =
|
||||
messages.length === 0 ||
|
||||
messages.every((m) => m.conversation_id === conversationId);
|
||||
if (!messagesBelongToConv) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleVisitorUnread = messages.filter(
|
||||
(msg) => !msg.sender_is_agent && !msg.is_read
|
||||
).length;
|
||||
if (visibleVisitorUnread > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
void handleMarkMessagesRead(conversationId, true);
|
||||
}, [
|
||||
conversationId,
|
||||
agentId,
|
||||
loadingMessages,
|
||||
effectiveIncludeAIMessages,
|
||||
messages,
|
||||
conversationDetail?.id,
|
||||
conversationDetail?.unread_count,
|
||||
handleMarkMessagesRead,
|
||||
]);
|
||||
|
||||
const handleNewMessageRef = useRef<(message: MessageItem) => void>(() => {});
|
||||
|
||||
const handleNewMessage = useCallback(
|
||||
|
||||
@@ -49,6 +49,8 @@ export interface AgentPageItem {
|
||||
id: string;
|
||||
label: string;
|
||||
title: string;
|
||||
/** i18n:title 对应的 key(可选,未接入时回退 title) */
|
||||
titleKey?: import("@/lib/i18n/dict").I18nKey;
|
||||
Icon: LucideIcon;
|
||||
/** 需要的功能权限键(单级开关)。admin 视为全权限 */
|
||||
requiredPermission?: string;
|
||||
@@ -67,6 +69,7 @@ export const AGENT_PAGES = [
|
||||
id: "dashboard",
|
||||
label: "会话对话",
|
||||
title: "对话",
|
||||
titleKey: "agent.page.dashboard",
|
||||
Icon: MessageCircle,
|
||||
requiredPermission: "chat",
|
||||
isChatPage: true,
|
||||
@@ -75,6 +78,7 @@ export const AGENT_PAGES = [
|
||||
id: "internal-chat",
|
||||
label: "知识测试",
|
||||
title: "知识库测试",
|
||||
titleKey: "agent.page.internalChat",
|
||||
Icon: Lightbulb,
|
||||
requiredPermission: "kb_test",
|
||||
isChatPage: true,
|
||||
@@ -83,6 +87,7 @@ export const AGENT_PAGES = [
|
||||
id: "knowledge",
|
||||
label: "知识管理",
|
||||
title: "知识库",
|
||||
titleKey: "agent.page.knowledge",
|
||||
Icon: BookOpen,
|
||||
requiredPermission: "knowledge",
|
||||
component: KnowledgePage,
|
||||
@@ -91,6 +96,7 @@ export const AGENT_PAGES = [
|
||||
id: "faqs",
|
||||
label: "事件管理",
|
||||
title: "事件管理",
|
||||
titleKey: "agent.page.faqs",
|
||||
Icon: ClipboardList,
|
||||
requiredPermission: "faqs",
|
||||
component: FAQsPage,
|
||||
@@ -99,6 +105,7 @@ export const AGENT_PAGES = [
|
||||
id: "analytics",
|
||||
label: "数据报表",
|
||||
title: "数据报表",
|
||||
titleKey: "agent.page.analytics",
|
||||
Icon: BarChart3,
|
||||
requiredPermission: "analytics",
|
||||
component: AnalyticsPage,
|
||||
@@ -107,6 +114,7 @@ export const AGENT_PAGES = [
|
||||
id: "logs",
|
||||
label: "日志中心",
|
||||
title: "日志中心",
|
||||
titleKey: "agent.page.logs",
|
||||
Icon: ScrollText,
|
||||
requiredPermission: "logs",
|
||||
component: LogsPage,
|
||||
@@ -115,6 +123,7 @@ export const AGENT_PAGES = [
|
||||
id: "users",
|
||||
label: "用户管理",
|
||||
title: "用户管理",
|
||||
titleKey: "agent.page.users",
|
||||
Icon: Users,
|
||||
requiredPermission: "users",
|
||||
component: UsersPage,
|
||||
@@ -123,6 +132,7 @@ export const AGENT_PAGES = [
|
||||
id: "prompts",
|
||||
label: "提示配置",
|
||||
title: "提示词",
|
||||
titleKey: "agent.page.prompts",
|
||||
Icon: FileText,
|
||||
requiredPermission: "prompts",
|
||||
component: PromptsPage,
|
||||
@@ -131,6 +141,7 @@ export const AGENT_PAGES = [
|
||||
id: "settings",
|
||||
label: "AI配置",
|
||||
title: "AI 配置",
|
||||
titleKey: "agent.page.settings",
|
||||
Icon: Settings,
|
||||
requiredPermission: "settings",
|
||||
component: SettingsPage,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { DEFAULT_LANG, DICT, LANG_STORAGE_KEY, type I18nKey, type Lang } from "./dict";
|
||||
|
||||
function isLang(x: string | null | undefined): x is Lang {
|
||||
return x === "zh-CN" || x === "en";
|
||||
}
|
||||
|
||||
function getLangFromLocation(): Lang | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const url = new URL(window.location.href);
|
||||
const q = url.searchParams.get("lang");
|
||||
if (isLang(q)) return q;
|
||||
const stored = window.localStorage.getItem(LANG_STORAGE_KEY);
|
||||
if (isLang(stored)) return stored;
|
||||
return null;
|
||||
}
|
||||
|
||||
type I18nContextValue = {
|
||||
lang: Lang;
|
||||
setLang: (lang: Lang) => void;
|
||||
t: (key: I18nKey) => string;
|
||||
};
|
||||
|
||||
const I18nContext = createContext<I18nContextValue | null>(null);
|
||||
|
||||
export function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||
const [lang, setLangState] = useState<Lang>(DEFAULT_LANG);
|
||||
|
||||
useEffect(() => {
|
||||
const initial = getLangFromLocation();
|
||||
if (initial) setLangState(initial);
|
||||
}, []);
|
||||
|
||||
const setLang = useCallback((next: Lang) => {
|
||||
setLangState(next);
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(LANG_STORAGE_KEY, next);
|
||||
// 同步 URL(可分享)
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("lang", next);
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
}, []);
|
||||
|
||||
const t = useCallback(
|
||||
(key: I18nKey) => {
|
||||
const v = DICT[lang]?.[key];
|
||||
if (typeof v === "string" && v) return v;
|
||||
return DICT[DEFAULT_LANG][key] ?? key;
|
||||
},
|
||||
[lang]
|
||||
);
|
||||
|
||||
const value = useMemo(() => ({ lang, setLang, t }), [lang, setLang, t]);
|
||||
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||
}
|
||||
|
||||
export function useI18n(): I18nContextValue {
|
||||
const ctx = useContext(I18nContext);
|
||||
if (!ctx) {
|
||||
return {
|
||||
lang: DEFAULT_LANG,
|
||||
setLang: () => {},
|
||||
t: (key) => DICT[DEFAULT_LANG][key] ?? key,
|
||||
};
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// 统计数据配置
|
||||
export const stats = [
|
||||
{ label: "服务企业", value: "1000+" },
|
||||
{ label: "处理对话", value: "100万+" },
|
||||
{ label: "响应时间", value: "<100ms" },
|
||||
{ label: "满意度", value: "98%" },
|
||||
import type { I18nKey } from "@/lib/i18n/dict";
|
||||
|
||||
/** 首页数字条:标签与展示数值均支持中英 */
|
||||
export const stats: { labelKey: I18nKey; valueKey: I18nKey }[] = [
|
||||
{ labelKey: "home.stats.clients", valueKey: "home.stats.val.clients" },
|
||||
{ labelKey: "home.stats.conversations", valueKey: "home.stats.val.conversations" },
|
||||
{ labelKey: "home.stats.latency", valueKey: "home.stats.val.latency" },
|
||||
{ labelKey: "home.stats.satisfaction", valueKey: "home.stats.val.satisfaction" },
|
||||
];
|
||||
|
||||
// 客户评价
|
||||
|
||||
Reference in New Issue
Block a user