多语言+移动端适配

This commit is contained in:
537yaha
2026-04-03 11:08:32 +08:00
parent 241de158bf
commit 2cf7316b00
33 changed files with 3567 additions and 1240 deletions
+94 -43
View File
@@ -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
+39 -11
View File
@@ -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>
+2 -1
View File
@@ -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>
);
+77 -68
View File
@@ -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>&quot;{selectedFAQ.question}&quot;</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
+11 -9
View File
@@ -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>
+83 -61
View File
@@ -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>
+66 -74
View File
@@ -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>
+102 -73
View File
@@ -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
&quot; AI &quot; 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">
SerperMCP 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>
+136 -96
View File
@@ -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}
</>
);
}
+10
View File
@@ -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
View File
@@ -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>
+22 -10
View File
@@ -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>
);
}
+20 -7
View File
@@ -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>
+86 -6
View File
@@ -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>
);
}
+46 -44
View File
@@ -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>
);
}
+74 -7
View File
@@ -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>
+42 -42
View File
@@ -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>
);
}
+116 -141
View File
@@ -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>
+11 -3
View File
@@ -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(
+11
View File
@@ -49,6 +49,8 @@ export interface AgentPageItem {
id: string;
label: string;
title: string;
/** i18ntitle 对应的 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
+71
View File
@@ -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;
}
+8 -6
View File
@@ -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" },
];
// 客户评价