From 3738e14716f77f53bfd4cd60966f91811b3cb3fb Mon Sep 17 00:00:00 2001 From: tanlianwang Date: Fri, 27 Feb 2026 15:57:22 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=9B=B8=E5=85=B3=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/endpoints/hotwords.py | 72 +++++ backend/app/api/v1/endpoints/prompts.py | 10 +- backend/app/api/v1/router.py | 3 +- backend/app/models/__init__.py | 2 + backend/app/models/hotword.py | 11 + backend/app/schemas/hotword.py | 20 ++ backend/docs/init_mysql.sql | 1 - backend/requirements.txt | 50 +++- frontend/package-lock.json | 84 +++++- frontend/package.json | 2 + frontend/src/App.tsx | 20 +- frontend/src/api.ts | 35 ++- .../src/components/ListTable/ListTable.tsx | 6 +- .../ModernSidebar/ModernSidebar.css | 194 ++++++------- .../ModernSidebar/ModernSidebar.tsx | 111 +++++--- .../components/SplitLayout/SplitLayout.css | 9 +- frontend/src/components/Toast/Toast.tsx | 25 +- .../src/components/UserAvatar/UserAvatar.tsx | 9 +- frontend/src/contexts/ThemeContext.tsx | 70 +++++ frontend/src/i18n.ts | 26 ++ frontend/src/layout/AppHeader.css | 15 +- frontend/src/layout/AppHeader.tsx | 50 +++- frontend/src/layout/AppLayout.tsx | 156 +++++----- frontend/src/locales/en.json | 54 ++++ frontend/src/locales/zh.json | 54 ++++ frontend/src/main.tsx | 5 +- frontend/src/pages/HistoryMeeting.tsx | 8 +- frontend/src/pages/Hotwords.tsx | 268 ++++++++++++++++++ frontend/src/pages/MeetingLive.tsx | 121 ++++++++ frontend/src/pages/MeetingReport.tsx | 7 +- frontend/src/pages/ModelSettings/Layout.tsx | 50 ++++ .../ModelList.tsx} | 133 ++++----- frontend/src/pages/PermissionTree.tsx | 52 +++- frontend/src/pages/PromptManage.tsx | 2 +- frontend/src/pages/VoiceProfile.tsx | 208 ++++++++++++++ frontend/src/router.tsx | 19 +- frontend/src/utils/icons.tsx | 20 +- frontend/src/vite-env.d.ts | 1 + frontend/tsconfig.tsbuildinfo | 1 + frontend/vite.config.ts | 7 + frontend/yarn.lock | 39 ++- start.sh | 73 +++++ 42 files changed, 1736 insertions(+), 367 deletions(-) create mode 100644 backend/app/api/v1/endpoints/hotwords.py create mode 100644 backend/app/models/hotword.py create mode 100644 backend/app/schemas/hotword.py create mode 100644 frontend/src/contexts/ThemeContext.tsx create mode 100644 frontend/src/i18n.ts create mode 100644 frontend/src/locales/en.json create mode 100644 frontend/src/locales/zh.json create mode 100644 frontend/src/pages/Hotwords.tsx create mode 100644 frontend/src/pages/MeetingLive.tsx create mode 100644 frontend/src/pages/ModelSettings/Layout.tsx rename frontend/src/pages/{ModelManage.tsx => ModelSettings/ModelList.tsx} (63%) create mode 100644 frontend/src/pages/VoiceProfile.tsx create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tsconfig.tsbuildinfo create mode 100755 start.sh diff --git a/backend/app/api/v1/endpoints/hotwords.py b/backend/app/api/v1/endpoints/hotwords.py new file mode 100644 index 0000000..c0adf2f --- /dev/null +++ b/backend/app/api/v1/endpoints/hotwords.py @@ -0,0 +1,72 @@ +from typing import List +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from pypinyin import pinyin, Style + +from app.core.deps import get_db +from app.models.hotword import Hotword +from app.schemas.hotword import Hotword as HotwordSchema, HotwordCreate, HotwordUpdate + +router = APIRouter() + +@router.get("", response_model=List[HotwordSchema]) +def list_hotwords(db: Session = Depends(get_db)): + return db.query(Hotword).all() + +@router.post("", response_model=HotwordSchema) +def create_hotword( + hotword_in: HotwordCreate, + db: Session = Depends(get_db) +): + # Auto generate pinyin + py_list = pinyin(hotword_in.word, style=Style.NORMAL) + # py_list is like [['zhong'], ['guo']] + generated_pinyin = " ".join([item[0] for item in py_list]) + + db_obj = Hotword( + word=hotword_in.word, + pinyin=generated_pinyin, + weight=hotword_in.weight, + scope="global" + ) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + +@router.put("/{hotword_id}", response_model=HotwordSchema) +def update_hotword( + hotword_id: int, + hotword_in: HotwordUpdate, + db: Session = Depends(get_db) +): + hotword = db.query(Hotword).filter(Hotword.id == hotword_id).first() + if not hotword: + raise HTTPException(status_code=404, detail="Hotword not found") + + update_data = hotword_in.dict(exclude_unset=True) + + # If word is updated, regenerate pinyin + if "word" in update_data and update_data["word"]: + py_list = pinyin(update_data["word"], style=Style.NORMAL) + update_data["pinyin"] = " ".join([item[0] for item in py_list]) + + for field, value in update_data.items(): + setattr(hotword, field, value) + + db.add(hotword) + db.commit() + db.refresh(hotword) + return hotword + +@router.delete("/{hotword_id}") +def delete_hotword( + hotword_id: int, + db: Session = Depends(get_db) +): + hotword = db.query(Hotword).filter(Hotword.id == hotword_id).first() + if not hotword: + raise HTTPException(status_code=404, detail="Hotword not found") + db.delete(hotword) + db.commit() + return {"status": "success"} diff --git a/backend/app/api/v1/endpoints/prompts.py b/backend/app/api/v1/endpoints/prompts.py index 4799622..163ecd3 100644 --- a/backend/app/api/v1/endpoints/prompts.py +++ b/backend/app/api/v1/endpoints/prompts.py @@ -177,15 +177,21 @@ def update_user_config( if not config: template = db.query(PromptTemplate).filter(PromptTemplate.id == prompt_id).first() + if not template: + raise HTTPException(status_code=404, detail="模板不存在") + config = UserPromptConfig( user_id=current_user.user_id, template_id=prompt_id, - is_active=True, - user_sort_order=template.sort_order if template else 0 + is_active=1, # Default to active (int) + user_sort_order=template.sort_order ) db.add(config) for k, v in payload.model_dump(exclude_unset=True).items(): + # Convert boolean to int for SmallInteger fields + if isinstance(v, bool): + v = int(v) setattr(config, k, v) db.commit() diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index c15bbd3..a906210 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -1,5 +1,5 @@ from fastapi import APIRouter -from app.api.v1.endpoints import auth, users, permissions, roles, menus, dicts, params, logs, dashboard, prompts, ai_models, meetings +from app.api.v1.endpoints import auth, users, permissions, roles, menus, dicts, params, logs, dashboard, prompts, ai_models, meetings, hotwords router = APIRouter() @@ -15,3 +15,4 @@ router.include_router(dashboard.router) router.include_router(prompts.router) router.include_router(ai_models.router) router.include_router(meetings.router) +router.include_router(hotwords.router, prefix="/hotwords", tags=["Hotwords"]) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 9b41a9e..33fece8 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -9,6 +9,7 @@ from .sys_log import SysLog from .prompt import PromptTemplate, UserPromptConfig from .ai_model import AIModel from .meeting import Meeting, MeetingAttendee, MeetingAudio, TranscriptTask, TranscriptSegment, SummarizeTask +from .hotword import Hotword __all__ = [ "Base", @@ -30,4 +31,5 @@ __all__ = [ "TranscriptTask", "TranscriptSegment", "SummarizeTask", + "Hotword", ] diff --git a/backend/app/models/hotword.py b/backend/app/models/hotword.py new file mode 100644 index 0000000..d636c1e --- /dev/null +++ b/backend/app/models/hotword.py @@ -0,0 +1,11 @@ +from sqlalchemy import Column, Integer, String, Float +from app.models.base import Base + +class Hotword(Base): + __tablename__ = "biz_hotword" + + id = Column(Integer, primary_key=True, index=True) + word = Column(String(255), nullable=False, comment="热词") + pinyin = Column(String(255), nullable=True, comment="拼音") + weight = Column(Float, default=1.0, comment="权重") + scope = Column(String(50), default="global", comment="作用域: global/user/meeting") diff --git a/backend/app/schemas/hotword.py b/backend/app/schemas/hotword.py new file mode 100644 index 0000000..16a372a --- /dev/null +++ b/backend/app/schemas/hotword.py @@ -0,0 +1,20 @@ +from typing import Optional +from pydantic import BaseModel + +class HotwordBase(BaseModel): + word: str + weight: Optional[float] = 1.0 + +class HotwordCreate(HotwordBase): + pass + +class HotwordUpdate(HotwordBase): + word: Optional[str] = None + +class Hotword(HotwordBase): + id: int + pinyin: Optional[str] = None + scope: str + + class Config: + from_attributes = True diff --git a/backend/docs/init_mysql.sql b/backend/docs/init_mysql.sql index e3ca976..46b9c0f 100644 --- a/backend/docs/init_mysql.sql +++ b/backend/docs/init_mysql.sql @@ -126,7 +126,6 @@ VALUES (12, 2, '历史记录', 'workspace.history', 'menu', 2, '/workspace/history', 'MeetingHistory', 'history', 2, 1, 1, '二级菜单', NULL), (13, 2, '声纹档案', 'workspace.voice', 'menu', 2, '/workspace/voice', 'VoiceProfile', 'voice', 3, 1, 1, '二级菜单', NULL), (14, 3, '知识库管理', 'ai_agent.kb', 'menu', 2, '/ai-agent/kb', 'KnowledgeBase', 'kb', 1, 1, 1, '二级菜单', NULL), - (15, 3, '总结模板管理', 'ai_agent.templates', 'menu', 2, '/ai-agent/templates', 'PromptTemplates', 'template', 2, 1, 1, '二级菜单', NULL), (16, 3, '热词管理', 'ai_agent.hotwords', 'menu', 2, '/ai-agent/hotwords', 'Hotwords', 'hot', 3, 1, 1, '二级菜单', NULL), (17, 4, '系统设置', 'system.setting', 'menu', 2, '/system/settings', 'SystemSettings', 'setting', 1, 1, 1, '二级菜单', NULL), (18, 4, '用户管理', 'system.users', 'menu', 2, '/system/users', 'UserManage', 'user', 2, 1, 1, '二级菜单', NULL), diff --git a/backend/requirements.txt b/backend/requirements.txt index 21ecb66..ea27809 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,16 +1,42 @@ -fastapi==0.115.6 -uvicorn[standard]==0.30.6 -sqlalchemy==2.0.36 -pydantic==2.9.2 -pydantic-settings==2.6.1 alembic==1.13.3 -python-dotenv==1.0.1 -passlib[bcrypt]==1.7.4 +annotated-types==0.7.0 +anyio==4.12.1 bcrypt==4.1.3 -python-jose[cryptography]==3.3.0 -redis==5.1.1 -pymysql==1.1.1 +certifi==2026.2.25 +cffi==2.0.0 +click==8.3.1 +cryptography==46.0.5 +ecdsa==0.19.1 +fastapi==0.115.6 +greenlet==3.3.2 +h11==0.16.0 +httpcore==1.0.9 +httptools==0.7.1 +httpx==0.28.1 +idna==3.11 +Mako==1.3.10 +MarkupSafe==3.0.3 +passlib==1.7.4 psutil==5.9.8 psycopg2-binary==2.9.9 -python-multipart -httpx +pyasn1==0.6.2 +pycparser==3.0 +pydantic==2.9.2 +pydantic-settings==2.6.1 +pydantic_core==2.23.4 +PyMySQL==1.1.1 +pypinyin==0.55.0 +python-dotenv==1.0.1 +python-jose==3.3.0 +python-multipart==0.0.22 +PyYAML==6.0.3 +redis==5.1.1 +rsa==4.9.1 +six==1.17.0 +SQLAlchemy==2.0.36 +starlette==0.41.3 +typing_extensions==4.15.0 +uvicorn==0.30.6 +uvloop==0.22.1 +watchfiles==1.1.1 +websockets==16.0 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bc28a35..df74e30 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,10 +13,12 @@ "antd": "^5.21.6", "antd-img-crop": "^4.29.0", "d3": "^7.9.0", + "i18next": "^25.8.13", "markmap-lib": "^0.18.12", "markmap-view": "^0.18.12", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-i18next": "^16.5.4", "react-markdown": "^10.1.0", "react-router-dom": "^6.27.0" }, @@ -2619,6 +2621,14 @@ "node": ">=12.0.0" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmmirror.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -2657,6 +2667,36 @@ "entities": "^4.5.0" } }, + "node_modules/i18next": { + "version": "25.8.13", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.13.tgz", + "integrity": "sha512-E0vzjBY1yM+nsFrtgkjLhST2NBkirkvOVoQa0MSldhsuZ3jUge7ZNpuwG0Cfc74zwo5ZwRzg3uOgT+McBn32iA==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "dependencies": { + "@babel/runtime": "^7.28.4" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -4629,6 +4669,32 @@ "react-dom": ">=16.4.0" } }, + "node_modules/react-i18next": { + "version": "16.5.4", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.4.tgz", + "integrity": "sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g==", + "dependencies": { + "@babel/runtime": "^7.28.4", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 25.6.2", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz", @@ -5180,7 +5246,7 @@ "version": "5.9.3", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -5333,6 +5399,14 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmmirror.com/vfile/-/vfile-6.0.3.tgz", @@ -5434,6 +5508,14 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index cc03a62..4656965 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,10 +14,12 @@ "antd": "^5.21.6", "antd-img-crop": "^4.29.0", "d3": "^7.9.0", + "i18next": "^25.8.13", "markmap-lib": "^0.18.12", "markmap-view": "^0.18.12", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-i18next": "^16.5.4", "react-markdown": "^10.1.0", "react-router-dom": "^6.27.0" }, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d263930..57ac0d0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,24 @@ import { RouterProvider } from "react-router-dom"; +import { App as AntdApp } from "antd"; import { router } from "./router"; +import { ThemeProvider } from "./contexts/ThemeContext"; +import { setNotificationInstance } from "./components/Toast/Toast"; +import "./i18n"; + +// Component to extract static functions from App +const AntdStaticHolder = () => { + const { notification } = AntdApp.useApp(); + setNotificationInstance(notification); + return null; +}; export default function App() { - return ; + return ( + + + + + + + ); } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 0fad3ae..805e232 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,6 +1,6 @@ import { getAccessToken, getRefreshToken, setTokens, clearTokens } from "./auth"; -const API_BASE = import.meta.env.VITE_API_BASE || "http://localhost:8001/api/v1"; +const API_BASE = import.meta.env.VITE_API_BASE || "/api/v1"; async function request(path: string, options: RequestInit = {}): Promise { const headers: Record = { @@ -70,7 +70,7 @@ export const api = { body: JSON.stringify({ refresh_token: refreshToken }) }), me: () => request<{ username: string; display_name: string; user_id: number; avatar?: string; roles: string[] }>("/users/me"), - changePassword: (payload: { old_password: str; new_password: str }) => + changePassword: (payload: { old_password: string; new_password: string }) => request("/users/me/password", { method: "PUT", body: JSON.stringify(payload) }), uploadAvatar: (file: File) => { const formData = new FormData(); @@ -98,6 +98,35 @@ export const api = { listUsers: () => request>("/users"), + + // Meeting APIs + createMeeting: (payload: { title: string; participants?: string[] } | FormData) => + request<{ meeting_id: number; title: string }>("/meetings", { + method: "POST", + body: payload instanceof FormData ? payload : JSON.stringify(payload) + }), + + uploadMeetingAudio: (file: File, onProgress?: (p: number) => void) => { + const formData = new FormData(); + formData.append("file", file); + return request("/meetings/upload", { method: "POST", body: formData }); + }, + + getTranscripts: (meetingId: number) => request>(`/meetings/${meetingId}/transcripts`), + + // Hotwords APIs + listHotwords: () => request>("/hotwords"), + createHotword: (payload: { word: string; pinyin?: string; weight?: number }) => + request("/hotwords", { method: "POST", body: JSON.stringify(payload) }), + updateHotword: (id: number, payload: { word?: string; weight?: number }) => + request(`/hotwords/${id}`, { method: "PUT", body: JSON.stringify(payload) }), + deleteHotword: (id: number) => request(`/hotwords/${id}`, { method: "DELETE" }), + + // Speaker/Voice APIs + listSpeakers: () => request>("/speakers"), + createSpeaker: (payload: FormData) => request("/speakers/enroll", { method: "POST", body: payload }), + deleteSpeaker: (id: string) => request(`/speakers/${id}`, { method: "DELETE" }), + verifySpeaker: (payload: FormData) => request("/speakers/verify", { method: "POST", body: payload }), createUser: (payload: { username: string; display_name: string; email?: string; phone?: string; password?: string; status?: number; role_ids?: number[] }) => request("/users", { method: "POST", body: JSON.stringify(payload) }), updateUser: (userId: number, payload: { display_name?: string; email?: string; phone?: string; status?: number; role_ids?: number[] }) => @@ -191,5 +220,5 @@ export const api = { const params = new URLSearchParams(); if (filters?.status) params.append("status", filters.status); return request(`/meetings/tasks?${params.toString()}`); - } + }, }; diff --git a/frontend/src/components/ListTable/ListTable.tsx b/frontend/src/components/ListTable/ListTable.tsx index bb4b81b..45ec566 100644 --- a/frontend/src/components/ListTable/ListTable.tsx +++ b/frontend/src/components/ListTable/ListTable.tsx @@ -1,5 +1,6 @@ import { Table } from "antd"; import type { ColumnsType, TablePaginationConfig, TableRowSelection } from "antd/es/table"; +import { ExpandableConfig } from "antd/es/table/interface"; import "./ListTable.css"; export type ListTableProps> = { @@ -13,11 +14,12 @@ export type ListTableProps> = { onSelectAllPages?: () => void; onClearSelection?: () => void; pagination?: TablePaginationConfig | false; - scroll?: { x?: number | true | string }; + scroll?: { x?: number | true | string; y?: number | string }; onRowClick?: (record: T) => void; selectedRow?: T | null; loading?: boolean; className?: string; + expandable?: ExpandableConfig; }; function ListTable>({ @@ -40,6 +42,7 @@ function ListTable>({ selectedRow, loading = false, className = "", + expandable, }: ListTableProps) { const rowSelection: TableRowSelection | undefined = onSelectionChange ? { @@ -114,6 +117,7 @@ function ListTable>({ onClick: () => onRowClick?.(record), className: selectedRow?.[rowKey] === record[rowKey] ? "row-selected" : "", })} + expandable={expandable} /> ); diff --git a/frontend/src/components/ModernSidebar/ModernSidebar.css b/frontend/src/components/ModernSidebar/ModernSidebar.css index 60f8988..928cfa3 100644 --- a/frontend/src/components/ModernSidebar/ModernSidebar.css +++ b/frontend/src/components/ModernSidebar/ModernSidebar.css @@ -1,10 +1,12 @@ .modern-sidebar { height: 100vh; position: relative; - background: #ffffff !important; - border-right: 1px solid #f3f4f6; + background: var(--sidebar-bg, #ffffff) !important; + border-right: 1px solid var(--sidebar-border, #f3f4f6); display: flex; flex-direction: column; + color: var(--sidebar-text, #1f2937); + transition: all 0.2s; } .modern-sidebar .ant-layout-sider-children { @@ -47,7 +49,7 @@ width: 44px; height: 44px; flex-shrink: 0; - background: #4a90e2; + background: #4a90e2; /* Keep logo brand color */ border-radius: 50%; display: flex; align-items: center; @@ -80,7 +82,7 @@ .logo-text { font-size: 24px; font-weight: 700; - color: #1f2937; + color: var(--sidebar-text, #1f2937); letter-spacing: -0.5px; white-space: nowrap; /* Prevent text wrapping */ } @@ -94,172 +96,164 @@ top: 36px; /* Align with logo center */ width: 24px; height: 24px; - background: #ffffff; - border: 1px solid #e5e7eb; + background: var(--sidebar-bg, #ffffff); + border: 1px solid var(--sidebar-border, #e5e7eb); border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; - z-index: 100; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); - color: #9ca3af; - font-size: 10px; - transition: all 0.2s ease; + z-index: 10; + box-shadow: 0 2px 5px rgba(0,0,0,0.05); + color: var(--sidebar-text, #6b7280); + transition: all 0.2s; } .collapse-trigger:hover { - color: #2563eb; - border-color: #2563eb; - box-shadow: 0 4px 12px rgba(37, 99, 235, 0.15); + background: var(--sidebar-hover-bg, #f9fafb); + color: var(--sidebar-active-text, #4a90e2); + border-color: var(--sidebar-active-text, #4a90e2); } -/* Menu Area */ -.modern-sidebar-menu { +/* Menu Content */ +.modern-sidebar-content { flex: 1; overflow-y: auto; - padding: 8px 16px; + padding: 0 12px 20px; } -.modern-sidebar.ant-layout-sider-collapsed .modern-sidebar-menu { - padding: 8px 12px; -} - -.modern-sidebar-menu::-webkit-scrollbar { - width: 0px; +/* Scrollbar styling */ +.modern-sidebar-content::-webkit-scrollbar { + width: 4px; +} +.modern-sidebar-content::-webkit-scrollbar-track { + background: transparent; +} +.modern-sidebar-content::-webkit-scrollbar-thumb { + background: rgba(0,0,0,0.1); + border-radius: 4px; } +/* Menu Group */ .menu-group { margin-bottom: 24px; } -.group-title { +.menu-group-title { + padding: 0 12px; + margin-bottom: 8px; font-size: 11px; - color: #9ca3af; - font-weight: 700; - letter-spacing: 1px; - margin-bottom: 12px; - padding-left: 12px; text-transform: uppercase; + color: var(--sidebar-text-secondary, #9ca3af); + font-weight: 600; + letter-spacing: 0.5px; } /* Menu Item */ .modern-sidebar-item { display: flex; align-items: center; - padding: 12px 16px; + padding: 10px 12px; margin-bottom: 4px; + border-radius: 8px; cursor: pointer; - border-radius: 12px; - transition: all 0.2s; - color: #4b5563; + color: var(--sidebar-text, #4b5563); font-weight: 500; + font-size: 14px; + transition: all 0.2s; } .modern-sidebar-item:hover { - background-color: #f8fafc; - color: #2563eb; + background: var(--sidebar-hover-bg, #f3f4f6); + color: var(--sidebar-text, #111827); } .modern-sidebar-item.active { - background-color: #f0f7ff; - color: #2563eb; + background: var(--sidebar-active-bg, #eff6ff); + color: var(--sidebar-active-text, #2563eb); } -.modern-sidebar-item.active .item-icon { - color: #2563eb; -} - -.item-content { - display: flex; - align-items: center; - gap: 16px; +.modern-sidebar-item.collapsed { + justify-content: center; + padding: 12px 0; } .item-icon { - font-size: 20px; display: flex; align-items: center; - color: #64748b; - transition: color 0.2s; -} - -.item-label { - font-size: 15px; -} - -/* Collapsed Item */ -.modern-sidebar-item.collapsed { justify-content: center; - padding: 12px; + font-size: 18px; + min-width: 24px; } -/* Footer Area */ -.modern-sidebar-footer { - padding: 16px; - flex-shrink: 0; - background: #ffffff; - border-top: 1px solid #f3f4f6; -} - -.modern-sidebar.ant-layout-sider-collapsed .modern-sidebar-footer { - padding: 16px 8px; /* Reduce padding when collapsed */ -} - -.user-card { - background-color: #f8fafc; - border-radius: 16px; - padding: 12px; - display: flex; - align-items: center; - justify-content: space-between; - transition: all 0.2s; -} - -.modern-sidebar.ant-layout-sider-collapsed .user-card { - justify-content: center; - padding: 12px 0; - background: transparent; -} - -.user-info { +.modern-sidebar-item .item-content { display: flex; align-items: center; gap: 12px; - overflow: hidden; + width: 100%; } -.user-details { +.item-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Footer / User Profile */ +.modern-sidebar-footer { + padding: 16px; + border-top: 1px solid var(--sidebar-border, #f3f4f6); + margin-top: auto; +} + +.user-profile-card { display: flex; - flex-direction: column; + align-items: center; + gap: 12px; + padding: 8px; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; +} + +.user-profile-card:hover { + background: var(--sidebar-hover-bg, #f3f4f6); +} + +.user-info { + flex: 1; overflow: hidden; } .user-name { font-size: 14px; font-weight: 600; - color: #1f2937; + color: var(--sidebar-text, #1f2937); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .user-role { - font-size: 11px; - color: #9ca3af; - font-weight: 500; + font-size: 12px; + color: var(--sidebar-text-secondary, #6b7280); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .logout-btn { - color: #9ca3af; - cursor: pointer; - padding: 6px; - border-radius: 8px; - transition: all 0.2s; + width: 32px; + height: 32px; display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + color: var(--sidebar-text-secondary, #9ca3af); + transition: all 0.2s; } .logout-btn:hover { - background-color: #fee2e2; + background: #fee2e2; color: #ef4444; -} \ No newline at end of file +} diff --git a/frontend/src/components/ModernSidebar/ModernSidebar.tsx b/frontend/src/components/ModernSidebar/ModernSidebar.tsx index 55508d1..52073b9 100644 --- a/frontend/src/components/ModernSidebar/ModernSidebar.tsx +++ b/frontend/src/components/ModernSidebar/ModernSidebar.tsx @@ -1,15 +1,17 @@ import React, { CSSProperties, ReactNode } from 'react'; -import { Layout, Avatar, Tooltip } from 'antd'; +import { Layout, Avatar, Tooltip, theme } from 'antd'; import { RightOutlined, LeftOutlined, QuestionCircleOutlined, LogoutOutlined, - FileTextOutlined + FileTextOutlined, + UserOutlined } from '@ant-design/icons'; import './ModernSidebar.css'; const { Sider } = Layout; +const { useToken } = theme; export interface SidebarItem { key: string; @@ -62,7 +64,18 @@ const ModernSidebar: React.FC = ({ className = '', style = {} }) => { + const { token } = useToken(); + const cssVars = { + '--sidebar-bg': token.colorBgContainer, + '--sidebar-text': token.colorText, + '--sidebar-text-secondary': token.colorTextSecondary, + '--sidebar-hover-bg': token.colorFillTertiary, + '--sidebar-active-bg': token.colorPrimaryBg, + '--sidebar-active-text': token.colorPrimary, + '--sidebar-border': token.colorSplit, + } as React.CSSProperties; + const handleItemClick = (item: SidebarItem) => { if (onNavigate) { onNavigate(item.key, item); @@ -102,79 +115,85 @@ const ModernSidebar: React.FC = ({ return ( - {/* 1. Header with Logo (matches image) */} + {/* Header / Logo */}
-
-
-
- -
N
+ {logo ? ( + logo + ) : ( +
+
+
+ +
AI
+
+ {!collapsed && {platformName}}
- {!collapsed && {platformName}}
-
+ )} - {/* Collapse Trigger - Circular button on the boundary */} + {/* Collapse Trigger */}
onCollapse && onCollapse(!collapsed)} > - {collapsed ? : } + {collapsed ? : }
- {/* 2. Menu Items */} -
+ {/* Menu Content */} +
{menuGroups.map((group, index) => (
{!collapsed && group.title && ( -
{group.title}
+
{group.title}
)} -
- {group.items.map(item => renderMenuItem(item))} +
+ {group.items.map(renderMenuItem)}
))}
- {/* 3. Footer with User Info */} + {/* Footer / User Profile */}
-
-
- - {user?.name?.[0]?.toUpperCase() || 'U'} - - {!collapsed && ( -
-
{user?.name || 'User'}
-
{user?.role || 'Admin'}
-
+ {collapsed ? ( + +
+ } style={{ backgroundColor: token.colorPrimary }} /> +
+
+ ) : ( +
+ } style={{ backgroundColor: token.colorPrimary }} /> +
+
{user?.name}
+
{user?.role}
+
+ {onLogout && ( + +
{ + e.stopPropagation(); + onLogout(); + }} + > + +
+
)}
- {!collapsed && ( -
- -
- )} -
+ )}
); }; -export default ModernSidebar; \ No newline at end of file +export default ModernSidebar; diff --git a/frontend/src/components/SplitLayout/SplitLayout.css b/frontend/src/components/SplitLayout/SplitLayout.css index 097fa77..f5cd9a0 100644 --- a/frontend/src/components/SplitLayout/SplitLayout.css +++ b/frontend/src/components/SplitLayout/SplitLayout.css @@ -2,7 +2,8 @@ .split-layout { display: flex; width: 100%; - align-items: flex-start; + height: 100%; + /* align-items: flex-start; Removed to allow stretch */ } /* 横向布局(左右分栏) */ @@ -32,11 +33,11 @@ /* 右侧扩展区(横向布局) */ .split-layout-extend-right { - height: 693px; + height: 100%; overflow-y: auto; overflow-x: hidden; - position: sticky; - top: 16px; + /* position: sticky; Removed as height is 100% */ + /* top: 16px; Removed */ padding-right: 4px; } diff --git a/frontend/src/components/Toast/Toast.tsx b/frontend/src/components/Toast/Toast.tsx index 70a921c..162ff9e 100644 --- a/frontend/src/components/Toast/Toast.tsx +++ b/frontend/src/components/Toast/Toast.tsx @@ -1,4 +1,5 @@ import { notification } from "antd"; +import { NotificationInstance } from "antd/es/notification/interface"; import { CheckCircleOutlined, CloseCircleOutlined, @@ -6,6 +7,14 @@ import { InfoCircleOutlined, } from "@ant-design/icons"; +// Holder for the notification instance from App.useApp() +let notificationInstance: NotificationInstance | null = null; + +export const setNotificationInstance = (instance: NotificationInstance) => { + notificationInstance = instance; +}; + +// Fallback configuration for static method notification.config({ placement: "topRight", top: 24, @@ -13,9 +22,11 @@ notification.config({ maxCount: 3, }); +const getNotification = () => notificationInstance || notification; + const Toast = { success: (message: string, description = "", duration = 3) => { - notification.success({ + getNotification().success({ message, description, duration, @@ -28,7 +39,7 @@ const Toast = { }, error: (message: string, description = "", duration = 3) => { - notification.error({ + getNotification().error({ message, description, duration, @@ -41,7 +52,7 @@ const Toast = { }, warning: (message: string, description = "", duration = 3) => { - notification.warning({ + getNotification().warning({ message, description, duration, @@ -54,7 +65,7 @@ const Toast = { }, info: (message: string, description = "", duration = 3) => { - notification.info({ + getNotification().info({ message, description, duration, @@ -66,13 +77,13 @@ const Toast = { }); }, - custom: (config: Record) => { - notification.open({ + custom: (config: any) => { + getNotification().open({ ...config, style: { borderRadius: "8px", boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)", - ...config.style, + ...(config.style || {}), }, }); }, diff --git a/frontend/src/components/UserAvatar/UserAvatar.tsx b/frontend/src/components/UserAvatar/UserAvatar.tsx index 258f12d..5b8f40d 100644 --- a/frontend/src/components/UserAvatar/UserAvatar.tsx +++ b/frontend/src/components/UserAvatar/UserAvatar.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { forwardRef } from 'react'; import { Avatar, AvatarProps } from 'antd'; import { UserOutlined } from '@ant-design/icons'; import { resolveUrl } from '../../utils/url'; @@ -11,9 +11,9 @@ interface UserAvatarProps extends AvatarProps { }; } -const UserAvatar: React.FC = ({ user, ...rest }) => { +const UserAvatar = forwardRef(({ user, ...rest }, ref) => { if (user?.avatar) { - return ; + return ; } const name = user?.display_name || user?.username || '?'; @@ -21,12 +21,13 @@ const UserAvatar: React.FC = ({ user, ...rest }) => { return ( {firstLetter} ); -}; +}); export default UserAvatar; diff --git a/frontend/src/contexts/ThemeContext.tsx b/frontend/src/contexts/ThemeContext.tsx new file mode 100644 index 0000000..b9f0837 --- /dev/null +++ b/frontend/src/contexts/ThemeContext.tsx @@ -0,0 +1,70 @@ +import React, { createContext, useContext, useState } from 'react'; +import { ConfigProvider, theme } from 'antd'; +import zhCN from 'antd/locale/zh_CN'; +import enUS from 'antd/locale/en_US'; +import { useTranslation } from 'react-i18next'; + +// Define theme types +export type ThemeMode = 'light' | 'dark'; + +interface ThemeContextType { + mode: ThemeMode; + setMode: (mode: ThemeMode) => void; + primaryColor: string; + setPrimaryColor: (color: string) => void; +} + +const ThemeContext = createContext(undefined); + +export const useTheme = () => { + const context = useContext(ThemeContext); + if (!context) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +}; + +export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { i18n } = useTranslation(); + + // Load from local storage or default + const [mode, setModeState] = useState(() => { + return (localStorage.getItem('theme_mode') as ThemeMode) || 'light'; + }); + + const [primaryColor, setPrimaryColorState] = useState(() => { + return localStorage.getItem('theme_primary_color') || '#1677ff'; + }); + + const setMode = (newMode: ThemeMode) => { + setModeState(newMode); + localStorage.setItem('theme_mode', newMode); + }; + + const setPrimaryColor = (newColor: string) => { + setPrimaryColorState(newColor); + localStorage.setItem('theme_primary_color', newColor); + }; + + // Ant Design Theme Algorithm + const algorithm = mode === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm; + + // Ant Design Locale + const antdLocale = i18n.language === 'en' ? enUS : zhCN; + + return ( + + + {children} + + + ); +}; diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts new file mode 100644 index 0000000..9c27903 --- /dev/null +++ b/frontend/src/i18n.ts @@ -0,0 +1,26 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import en from './locales/en.json'; +import zh from './locales/zh.json'; + +const resources = { + en: { + translation: en, + }, + zh: { + translation: zh, + }, +}; + +i18n + .use(initReactI18next) + .init({ + resources, + lng: localStorage.getItem('lang') || 'zh', // default language + fallbackLng: 'zh', + interpolation: { + escapeValue: false, + }, + }); + +export default i18n; diff --git a/frontend/src/layout/AppHeader.css b/frontend/src/layout/AppHeader.css index 537bbc7..0b2223f 100644 --- a/frontend/src/layout/AppHeader.css +++ b/frontend/src/layout/AppHeader.css @@ -1,6 +1,6 @@ .app-header-main { - background: #fff !important; - border-bottom: 1px solid #e5e7eb; + background: var(--header-bg, #fff) !important; + border-bottom: 1px solid var(--header-border, #e5e7eb); padding: 0 24px; height: 64px; line-height: 64px; @@ -12,6 +12,7 @@ position: sticky; top: 0; width: 100%; + transition: all 0.2s; } .header-left { @@ -26,7 +27,7 @@ .header-icon-btn { font-size: 18px; - color: #4b5563; + color: var(--header-text-secondary, #4b5563); cursor: pointer; display: flex; align-items: center; @@ -38,8 +39,8 @@ } .header-icon-btn:hover { - background: #f3f4f6; - color: #2563eb; + background: var(--header-hover-bg, #f3f4f6); + color: var(--header-active-text, #2563eb); } .user-dropdown-trigger { @@ -50,11 +51,11 @@ } .user-dropdown-trigger:hover { - background: #f3f4f6; + background: var(--header-hover-bg, #f3f4f6); } .display-name { font-size: 14px; font-weight: 500; - color: #374151; + color: var(--header-text, #374151); } diff --git a/frontend/src/layout/AppHeader.tsx b/frontend/src/layout/AppHeader.tsx index 242d9f2..e8cd1ee 100644 --- a/frontend/src/layout/AppHeader.tsx +++ b/frontend/src/layout/AppHeader.tsx @@ -1,5 +1,5 @@ -import React, { useState } from 'react'; -import { Layout, Space, Badge, Segmented, Tooltip, Avatar, Dropdown, MenuProps } from 'antd'; +import React from 'react'; +import { Layout, Space, Badge, Segmented, Tooltip, Dropdown, MenuProps, ColorPicker, theme } from 'antd'; import { BellOutlined, SunOutlined, @@ -8,9 +8,12 @@ import { LogoutOutlined, SettingOutlined } from '@ant-design/icons'; +import { useTranslation } from 'react-i18next'; +import { useTheme } from '../contexts/ThemeContext'; import './AppHeader.css'; const { Header } = Layout; +const { useToken } = theme; interface AppHeaderProps { displayName: string; @@ -19,19 +22,40 @@ interface AppHeaderProps { } const AppHeader: React.FC = ({ displayName, onLogout, onProfileClick }) => { - const [isDarkMode, setIsDarkMode] = useState(false); - const [lang, setLang] = useState('zh'); + const { token } = useToken(); + const { t, i18n } = useTranslation(); + const { mode, setMode, primaryColor, setPrimaryColor } = useTheme(); + + const isDarkMode = mode === 'dark'; + + const cssVars = { + '--header-bg': token.colorBgContainer, + '--header-border': token.colorSplit, + '--header-text': token.colorText, + '--header-text-secondary': token.colorTextSecondary, + '--header-hover-bg': token.colorFillTertiary, + '--header-active-text': token.colorPrimary, + } as React.CSSProperties; + + const handleLangChange = (value: string) => { + i18n.changeLanguage(value); + localStorage.setItem('lang', value); + }; + + const handleThemeModeChange = () => { + setMode(isDarkMode ? 'light' : 'dark'); + }; const userMenuItems: MenuProps['items'] = [ { key: 'profile', - label: '个人资料', + label: t('header.profile'), icon: , onClick: onProfileClick, }, { key: 'settings', - label: '个人设置', + label: t('header.settings'), icon: , }, { @@ -39,7 +63,7 @@ const AppHeader: React.FC = ({ displayName, onLogout, onProfileC }, { key: 'logout', - label: '退出登录', + label: t('header.logout'), icon: , danger: true, onClick: onLogout, @@ -47,7 +71,7 @@ const AppHeader: React.FC = ({ displayName, onLogout, onProfileC ]; return ( -
+
{/* Logo removed as it is present in the sidebar */}
@@ -55,16 +79,16 @@ const AppHeader: React.FC = ({ displayName, onLogout, onProfileC
{/* Theme Toggle */} - -
setIsDarkMode(!isDarkMode)}> + +
{isDarkMode ? : }
{/* Language Toggle */} setLang(v as string)} + value={i18n.language === 'en' ? 'en' : 'zh'} + onChange={(v) => handleLangChange(v as string)} options={[ { label: '中', value: 'zh' }, { label: 'EN', value: 'en' }, @@ -72,7 +96,7 @@ const AppHeader: React.FC = ({ displayName, onLogout, onProfileC /> {/* Notifications */} - +
diff --git a/frontend/src/layout/AppLayout.tsx b/frontend/src/layout/AppLayout.tsx index 158ee79..3d16999 100644 --- a/frontend/src/layout/AppLayout.tsx +++ b/frontend/src/layout/AppLayout.tsx @@ -1,22 +1,15 @@ import { useEffect, useMemo, useState } from "react"; import { Layout } from "antd"; -import { - AppstoreOutlined, - SettingOutlined, - UserOutlined -} from "@ant-design/icons"; import { Outlet, useLocation, useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import { api } from "../api"; -import { clearTokens, getRefreshToken } from "../auth"; +import { clearTokens } from "../auth"; import Toast from "../components/Toast/Toast"; -import ModernSidebar, { SidebarGroup, SidebarItem } from "../components/ModernSidebar/ModernSidebar"; +import ModernSidebar, { SidebarGroup } from "../components/ModernSidebar/ModernSidebar"; import AppHeader from "./AppHeader"; import { getIcon } from "../utils/icons"; -import { resolveUrl } from "../utils/url"; import "./AppLayout.css"; -const { Content } = Layout; - type MenuNode = { id: number; name: string; @@ -30,11 +23,12 @@ type MenuNode = { }; export default function AppLayout() { + const { t } = useTranslation(); const navigate = useNavigate(); const location = useLocation(); const [collapsed, setCollapsed] = useState(false); const [menus, setMenus] = useState([]); - const [displayName, setDisplayName] = useState("管理员"); + const [displayName, setDisplayName] = useState("Admin"); const [username, setUsername] = useState(""); const [userRole, setUserRole] = useState("Admin"); const [avatar, setAvatar] = useState(null); @@ -54,7 +48,7 @@ export default function AppLayout() { setDisplayName(res.display_name || res.username); setUsername(res.username); setAvatar(res.avatar || null); - setUserRole(res.roles && res.roles.length > 0 ? res.roles.join(" / ") : "普通用户"); + setUserRole(res.roles && res.roles.length > 0 ? res.roles.join(" / ") : "User"); }).catch(() => { clearTokens(); navigate("/login"); @@ -71,15 +65,18 @@ export default function AppLayout() { const fetchMenu = () => { api .getMenuTree() - .then((res) => setMenus(res as MenuNode[])) - .catch(() => Toast.error("菜单加载失败")); + .then((res) => { + const serverMenus = res as MenuNode[]; + setMenus(serverMenus); + }) + .catch(() => Toast.error(t('common.error'))); }; fetchMenu(); window.addEventListener('menu-refresh', fetchMenu); return () => window.removeEventListener('menu-refresh', fetchMenu); - }, []); + }, [t]); useEffect(() => { if (!menus.length) return; @@ -93,77 +90,98 @@ export default function AppLayout() { } }, [menus, location.pathname, navigate]); - const menuGroups: SidebarGroup[] = useMemo(() => { - return menus.map((group) => ({ - title: group.name, - items: (group.children || []).map((child) => ({ - key: child.path || child.code, - label: child.name, - icon: getIcon(child.icon), - path: child.path || "" - })), - })); - }, [menus]); + // Helper to translate menu names + const translateMenuName = (name: string, code: string) => { + // Try to find translation by code (e.g. 'system.user' -> 'menu.userManage') + // This requires a mapping or convention. + // For now, let's try a simple mapping based on the code or just return name if not found. + + // Example codes: 'dashboard', 'workspace', 'system' + // Map code to translation key + const codeMap: Record = { + 'home': 'menu.home', + 'dashboard': 'menu.dashboard', + 'workspace': 'menu.workspace', + 'meeting.realtime': 'menu.realtimeMeeting', + 'meeting.history': 'menu.historyMeeting', + 'voice.profile': 'menu.voiceProfile', + 'knowledge': 'menu.knowledgeBase', + 'knowledge.manage': 'menu.kbManage', + 'hotwords': 'menu.hotwords', + 'system': 'menu.system', + 'system.settings': 'menu.systemSettings', + 'system.user': 'menu.userManage', + 'system.role': 'menu.roleManage', + 'system.permission': 'menu.permissionManage', + 'system.dict': 'menu.dictManage', + 'system.param': 'menu.paramManage', + 'system.log': 'menu.logManage', + 'monitor.task': 'menu.taskMonitor', + 'ai_agent.prompts': 'menu.promptManage', + 'ai_agent.models': 'menu.modelManage' + }; - const activeKey = useMemo(() => { - return location.pathname; - }, [location.pathname]); - - const onNavigate = (key: string, item: SidebarItem) => { - if (item.path) { - navigate(item.path); - } else { - navigate(key); + if (codeMap[code]) { + return t(codeMap[code]); } + + return name; }; - const handleLogout = async () => { - const token = getRefreshToken(); - if (token) { - try { - await api.logout(token); - } catch { - // ignore - } - } + const menuGroups: SidebarGroup[] = useMemo(() => { + return menus.map((group) => ({ + title: translateMenuName(group.name, group.code), + items: (group.children || []).map((child) => ({ + key: child.path || child.code, + label: translateMenuName(child.name, child.code), + icon: getIcon(child.icon), + path: child.path + })) + })); + }, [menus, t]); + + const handleLogout = () => { clearTokens(); navigate("/login"); }; return ( - - {/* Sidebar on the left, full height */} - + navigate(key)} onLogout={handleLogout} - onProfileClick={() => navigate("/profile")} - style={{ height: '100vh', zIndex: 200 }} + onProfileClick={() => navigate('/profile')} /> - {/* Right side: Header + Content */} - + navigate("/profile")} + displayName={displayName} + onLogout={handleLogout} + onProfileClick={() => navigate('/profile')} /> - - - - - + + + ); diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json new file mode 100644 index 0000000..a6579c1 --- /dev/null +++ b/frontend/src/locales/en.json @@ -0,0 +1,54 @@ +{ + "common": { + "confirm": "Confirm", + "cancel": "Cancel", + "save": "Save", + "delete": "Delete", + "edit": "Edit", + "add": "Add", + "search": "Search", + "reset": "Reset", + "success": "Success", + "error": "Error", + "loading": "Loading...", + "noData": "No Data", + "actions": "Actions", + "submit": "Submit", + "status": "Status", + "remark": "Remark", + "createTime": "Created At", + "updateTime": "Updated At" + }, + "header": { + "profile": "Profile", + "settings": "Settings", + "logout": "Logout", + "darkMode": "Dark Mode", + "lightMode": "Light Mode", + "notifications": "Notifications", + "language": "Language", + "themeColor": "Theme Color" + }, + "menu": { + "home": "Home", + "dashboard": "Dashboard", + "workspace": "Workspace", + "realtimeMeeting": "Real-time Meeting", + "historyMeeting": "History", + "voiceProfile": "Voice Profile", + "knowledgeBase": "Knowledge Base", + "kbManage": "KB Management", + "hotwords": "Hotwords", + "system": "System", + "systemSettings": "Settings", + "userManage": "Users", + "roleManage": "Roles", + "permissionManage": "Permissions", + "dictManage": "Dictionaries", + "paramManage": "Parameters", + "logManage": "Logs", + "taskMonitor": "Task Monitor", + "promptManage": "Prompts", + "modelManage": "Models" + } +} diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json new file mode 100644 index 0000000..f2086ab --- /dev/null +++ b/frontend/src/locales/zh.json @@ -0,0 +1,54 @@ +{ + "common": { + "confirm": "确认", + "cancel": "取消", + "save": "保存", + "delete": "删除", + "edit": "编辑", + "add": "新增", + "search": "搜索", + "reset": "重置", + "success": "成功", + "error": "失败", + "loading": "加载中...", + "noData": "暂无数据", + "actions": "操作", + "submit": "提交", + "status": "状态", + "remark": "备注", + "createTime": "创建时间", + "updateTime": "更新时间" + }, + "header": { + "profile": "个人资料", + "settings": "个人设置", + "logout": "退出登录", + "darkMode": "深色模式", + "lightMode": "浅色模式", + "notifications": "消息通知", + "language": "语言", + "themeColor": "主题色" + }, + "menu": { + "home": "首页", + "dashboard": "仪表盘", + "workspace": "工作空间", + "realtimeMeeting": "实时会议", + "historyMeeting": "历史记录", + "voiceProfile": "声纹档案", + "knowledgeBase": "知识库", + "kbManage": "知识库管理", + "hotwords": "热词管理", + "system": "系统管理", + "systemSettings": "系统设置", + "userManage": "用户管理", + "roleManage": "角色管理", + "permissionManage": "权限管理", + "dictManage": "码表管理", + "paramManage": "系统参数", + "logManage": "系统日志", + "taskMonitor": "任务监控", + "promptManage": "提示词管理", + "modelManage": "模型管理" + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index f69cbd9..5de7142 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,7 +1,6 @@ import React from "react"; import { createRoot } from "react-dom/client"; -import { RouterProvider } from "react-router-dom"; -import { router } from "./router"; +import App from "./App"; import "antd/dist/reset.css"; import "./styles.css"; @@ -12,6 +11,6 @@ if (!container) { createRoot(container).render( - + ); diff --git a/frontend/src/pages/HistoryMeeting.tsx b/frontend/src/pages/HistoryMeeting.tsx index 6e380e6..0acd86e 100644 --- a/frontend/src/pages/HistoryMeeting.tsx +++ b/frontend/src/pages/HistoryMeeting.tsx @@ -97,6 +97,7 @@ const HistoryMeeting: React.FC = () => { navigate(`/meeting/history/${m.meeting_id}`)} > @@ -136,7 +137,7 @@ const HistoryMeeting: React.FC = () => {
- + {m.attendees?.map((a: any) => ( @@ -234,7 +235,10 @@ const HistoryMeeting: React.FC = () => {
{loading ? ( -
+
+ +
努力加载中...
+
) : meetings.length > 0 ? ( <> {viewMode === 'grid' ? ( diff --git a/frontend/src/pages/Hotwords.tsx b/frontend/src/pages/Hotwords.tsx new file mode 100644 index 0000000..a10017f --- /dev/null +++ b/frontend/src/pages/Hotwords.tsx @@ -0,0 +1,268 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { Card, Button, Modal, Form, Input, InputNumber, message, Tag, Popconfirm, Tooltip, Empty } from 'antd'; +import { PlusOutlined, DeleteOutlined, FireOutlined, QuestionCircleOutlined, ReloadOutlined, EditOutlined } from '@ant-design/icons'; +import { api } from '../api'; +// @ts-ignore +import PageTitleBar from '../components/PageTitleBar/PageTitleBar'; +// @ts-ignore +import ListActionBar from '../components/ListActionBar/ListActionBar'; +import ListTable from '../components/ListTable/ListTable'; + +interface HotwordItem { + id: number; + word: string; + pinyin: string; + weight: number; +} + +const Hotwords: React.FC = () => { + const [hotwords, setHotwords] = useState([]); + const [loading, setLoading] = useState(false); + const [modalVisible, setModalVisible] = useState(false); + const [searchText, setSearchText] = useState(''); + const [currentHotword, setCurrentHotword] = useState(null); + const [form] = Form.useForm(); + + const fetchHotwords = async () => { + setLoading(true); + try { + const data = await api.listHotwords(); + setHotwords(data); + } catch (error) { + message.error('获取热词列表失败'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchHotwords(); + }, []); + + const handleSubmit = async () => { + try { + const values = await form.validateFields(); + if (currentHotword) { + await api.updateHotword(currentHotword.id, values); + message.success('修改成功'); + } else { + await api.createHotword(values); + message.success('添加成功'); + } + handleModalClose(); + fetchHotwords(); + } catch (error) { + // message.error('操作失败'); + } + }; + + const handleDelete = async (id: number) => { + try { + await api.deleteHotword(id); + message.success('删除成功'); + fetchHotwords(); + } catch (error) { + message.error('删除失败'); + } + }; + + const handleEdit = (record: HotwordItem) => { + setCurrentHotword(record); + form.setFieldsValue(record); + setModalVisible(true); + }; + + const handleModalClose = () => { + setModalVisible(false); + setCurrentHotword(null); + form.resetFields(); + }; + + const filteredHotwords = useMemo(() => { + if (!searchText) return hotwords; + return hotwords.filter(item => + item.word.includes(searchText) || + item.pinyin.toLowerCase().includes(searchText.toLowerCase()) + ); + }, [hotwords, searchText]); + + const columns = [ + { + title: '热词', + dataIndex: 'word', + key: 'word', + width: '30%', + render: (t: string) => ( +
+ + {t} +
+ ) + }, + { + title: '拼音', + dataIndex: 'pinyin', + key: 'pinyin', + width: '30%', + render: (t: string) => {t} + }, + { + title: '权重', + dataIndex: 'weight', + key: 'weight', + width: '20%', + render: (w: number) => { + let color = 'default'; + if (w >= 5) color = 'red'; + else if (w >= 2) color = 'orange'; + else if (w >= 1) color = 'green'; + + return ( + + {w}x + + ); + } + }, + { + title: '操作', + key: 'action', + width: 180, + render: (_: any, record: HotwordItem) => ( +
+ + handleDelete(record.id)} + okText="删除" + cancelText="取消" + okButtonProps={{ danger: true }} + > + + +
+ ), + }, + ]; + + return ( +
+ + +
+ +
+ , + onClick: () => setModalVisible(true), + } + ]} + search={{ + placeholder: "搜索热词或拼音...", + value: searchText, + onChange: (val: string) => setSearchText(val), + width: 320 + }} + showRefresh + onRefresh={fetchHotwords} + /> +
+ +
+ +
+
+
+ + +
+ +
+ {currentHotword ? '编辑热词' : '添加新热词'} +
+ } + open={modalVisible} + onCancel={handleModalClose} + onOk={handleSubmit} + destroyOnClose + width={520} + okText={currentHotword ? '保存' : '添加'} + cancelText="取消" + > +
+ + 热词文本 + + } + rules={[{ required: true, message: '请输入热词' }]} + > + + + + + 权重倍数 + + } + initialValue={2.0} + > + + + +
+
+ 使用说明 +
+
    +
  • 热词生效需要一定时间,通常在下一次识别会话开始时生效。
  • +
  • 系统会自动生成拼音,无需手动输入。
  • +
  • 建议权重设置在 2.0 - 5.0 之间,过高可能导致发音相近的词被误识别。
  • +
+
+
+ +
+ ); +}; + +export default Hotwords; diff --git a/frontend/src/pages/MeetingLive.tsx b/frontend/src/pages/MeetingLive.tsx new file mode 100644 index 0000000..5e9ee55 --- /dev/null +++ b/frontend/src/pages/MeetingLive.tsx @@ -0,0 +1,121 @@ +import React, { useState } from 'react'; +import { Card, Form, Input, Button, Upload, message, Progress, Select, Row, Col } from 'antd'; +import { InboxOutlined, AudioOutlined, UserAddOutlined } from '@ant-design/icons'; +import { useNavigate } from 'react-router-dom'; +import { api } from '../api'; +import PageHeader from '../components/PageHeader/PageHeader'; + +const { Dragger } = Upload; +const { Option } = Select; + +const MeetingLive: React.FC = () => { + const [form] = Form.useForm(); + const [uploading, setUploading] = useState(false); + const [progress, setProgress] = useState(0); + const navigate = useNavigate(); + + const handleCreate = async (values: any) => { + try { + const res = await api.createMeeting(values); + message.success('会议创建成功'); + navigate(`/meeting/history/${res.meeting_id}`); + } catch (error) { + console.error(error); + message.error('创建失败'); + } + }; + + const handleUpload = async (file: File) => { + setUploading(true); + setProgress(0); + try { + // Mock progress since fetch doesn't support it easily without XHR + const timer = setInterval(() => { + setProgress((prev) => { + if (prev >= 90) { + clearInterval(timer); + return 90; + } + return prev + 10; + }); + }, 500); + + const res = await api.uploadMeetingAudio(file); + clearInterval(timer); + setProgress(100); + message.success('上传成功,开始转译'); + // Assuming upload returns meeting_id or we navigate to list + if (res && res.meeting_id) { + navigate(`/meeting/history/${res.meeting_id}`); + } else { + navigate('/meeting/history'); + } + } catch (error) { + console.error(error); + message.error('上传失败'); + setUploading(false); + } + return false; // Prevent default upload behavior + }; + + return ( +
+ + + + + +
+ + } /> + + + + + + + +
+
+ + + + + +

+ +

+

点击或拖拽文件到此区域上传

+

+ 支持 MP3, WAV, M4A 等常见音频格式 +

+
+ {uploading && ( +
+ +
+ {progress === 100 ? '上传完成,正在处理...' : '上传中...'} +
+
+ )} +
+ +
+
+ ); +}; + +export default MeetingLive; diff --git a/frontend/src/pages/MeetingReport.tsx b/frontend/src/pages/MeetingReport.tsx index e39c6e5..8204af4 100644 --- a/frontend/src/pages/MeetingReport.tsx +++ b/frontend/src/pages/MeetingReport.tsx @@ -51,7 +51,12 @@ const MeetingReport: React.FC = () => { } }; - if (loading) return
; + if (loading) return ( +
+ +
构建报告中...
+
+ ); if (!meeting?.summary) return ; return ( diff --git a/frontend/src/pages/ModelSettings/Layout.tsx b/frontend/src/pages/ModelSettings/Layout.tsx new file mode 100644 index 0000000..d3bb930 --- /dev/null +++ b/frontend/src/pages/ModelSettings/Layout.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { Outlet, useNavigate, useLocation } from 'react-router-dom'; +import { Card, Menu } from 'antd'; +import { + RobotOutlined, + AudioOutlined, + SettingOutlined, + SafetyCertificateOutlined, + InfoCircleOutlined, + ThunderboltOutlined +} from '@ant-design/icons'; + +const ModelSettingsLayout: React.FC = () => { + const navigate = useNavigate(); + const location = useLocation(); + + // Get the last part of the path to set active key + // Assuming path is /system/model/llm, /system/model/asr etc. + const activeKey = location.pathname.split('/').pop() || 'llm'; + + const items = [ + { key: 'general', icon: , label: '通用设置', disabled: true }, + { key: 'llm', icon: , label: 'AI 模型' }, + { key: 'asr', icon: , label: '语音识别' }, + { key: 'voiceprint', icon: , label: '声纹识别', disabled: true }, + { key: 'security', icon: , label: '安全设置', disabled: true }, + { key: 'about', icon: , label: '关于系统', disabled: true }, + ]; + + return ( +
+ +
+ navigate(`/system/model/${key}`)} + /> +
+
+ +
+
+
+ ); +}; + +export default ModelSettingsLayout; diff --git a/frontend/src/pages/ModelManage.tsx b/frontend/src/pages/ModelSettings/ModelList.tsx similarity index 63% rename from frontend/src/pages/ModelManage.tsx rename to frontend/src/pages/ModelSettings/ModelList.tsx index 78daf8d..27cff76 100644 --- a/frontend/src/pages/ModelManage.tsx +++ b/frontend/src/pages/ModelSettings/ModelList.tsx @@ -1,11 +1,8 @@ import React, { useState, useEffect } from 'react'; -import { Card, Button, Form, Input, Select, Space, message, Tabs, Slider, Typography, Divider, Row, Col, Tag, Modal, Tooltip, Switch } from 'antd'; +import { Button, Form, Input, Select, Space, message, Slider, Typography, Divider, Row, Col, Tag, Modal, Tooltip, Switch } from 'antd'; import { RobotOutlined, AudioOutlined, - SettingOutlined, - SafetyCertificateOutlined, - InfoCircleOutlined, CheckCircleOutlined, ThunderboltOutlined, PlusOutlined, @@ -13,26 +10,28 @@ import { DeleteOutlined, StarOutlined, StarFilled, - SaveOutlined } from '@ant-design/icons'; -import { api } from '../api'; -import ListTable from '../components/ListTable/ListTable'; -import DetailDrawer from '../components/DetailDrawer/DetailDrawer'; +import { api } from '../../api'; +import ListTable from '../../components/ListTable/ListTable'; +import DetailDrawer from '../../components/DetailDrawer/DetailDrawer'; const { Option } = Select; const { Title, Text } = Typography; -const ModelManage: React.FC = () => { +interface ModelListProps { + type: 'llm' | 'asr'; +} + +const ModelList: React.FC = ({ type }) => { const [loading, setLoading] = useState(false); const [data, setData] = useState([]); const [vendors, setVendors] = useState([]); - const [activeTab, setActiveTab] = useState('llm'); const [isDrawerVisible, setIsDrawerVisible] = useState(false); const [editingItem, setEditingItem] = useState(null); const [testLoading, setTestLoading] = useState(false); const [form] = Form.useForm(); - const fetchModels = async (type: string) => { + const fetchModels = async () => { setLoading(true); try { const res = await api.listAIModels(type); @@ -54,8 +53,8 @@ const ModelManage: React.FC = () => { }; useEffect(() => { - fetchModels(activeTab); - }, [activeTab]); + fetchModels(); + }, [type]); useEffect(() => { fetchVendors(); @@ -65,10 +64,10 @@ const ModelManage: React.FC = () => { setEditingItem(null); form.resetFields(); form.setFieldsValue({ - model_type: activeTab, + model_type: type, status: 1, is_default: data.length === 0, - api_path: activeTab === 'llm' ? '/chat/completions' : '', + api_path: type === 'llm' ? '/chat/completions' : '', temperature: 0.7, top_p: 0.9 }); @@ -93,7 +92,7 @@ const ModelManage: React.FC = () => { onOk: async () => { await api.deleteAIModel(id); message.success('已删除'); - fetchModels(activeTab); + fetchModels(); }, }); }; @@ -102,7 +101,7 @@ const ModelManage: React.FC = () => { try { await api.updateAIModel(record.model_id, { ...record, is_default: true }); message.success(`${record.model_name} 已设为默认`); - fetchModels(activeTab); + fetchModels(); } catch (e) { message.error('设置失败'); } @@ -112,8 +111,8 @@ const ModelManage: React.FC = () => { const values = await form.validateFields(); const payload = { ...values, - model_type: activeTab, - config: activeTab === 'llm' ? { + model_type: type, + config: type === 'llm' ? { temperature: values.temperature, top_p: values.top_p } : {} @@ -126,7 +125,7 @@ const ModelManage: React.FC = () => { await api.createAIModel(payload); } setIsDrawerVisible(false); - fetchModels(activeTab); + fetchModels(); message.success('配置已保存'); } catch (e: any) { message.error('保存失败: ' + e.message); @@ -192,12 +191,12 @@ const ModelManage: React.FC = () => {
-
- {activeTab === 'llm' ? : } +
+ {type === 'llm' ? : }
- {activeTab === 'llm' ? 'AI 总结模型配置' : '语音识别 (ASR) 配置'} - {activeTab === 'llm' ? '选择用于生成会议纪要的大语言模型' : '转录模型与参数配置'} + {type === 'llm' ? 'AI 总结模型配置' : '语音识别 (ASR) 配置'} + {type === 'llm' ? '选择用于生成会议纪要的大语言模型' : '转录模型与参数配置'}
@@ -227,7 +226,7 @@ const ModelManage: React.FC = () => { - {activeTab === 'llm' && ( + {type === 'llm' && ( <> @@ -258,7 +257,7 @@ const ModelManage: React.FC = () => {
- {activeTab === 'llm' && ( + {type === 'llm' && (
-
- -
- ) - }, - { - key: 'asr', - label:
语音识别
, - children: ( -
-
-
- 语音转译模型列表 - 管理用于音频转文字的 ASR 服务 -
- -
- -
- ) - }, - { key: 'voiceprint', label:
声纹识别
, disabled: true }, - { key: 'security', label:
安全设置
, disabled: true }, - { key: 'about', label:
关于系统
, disabled: true }, - ]} - /> - + const getTitle = () => { + if (type === 'llm') { + return ( +
+ 大模型配置列表 + 配置并管理用于会议总结的 LLM 服务 +
+ ); + } else { + return ( +
+ 语音转译模型列表 + 管理用于音频转文字的 ASR 服务 +
+ ); + } + }; + return ( +
+
+ {getTitle()} + +
+ + setIsDrawerVisible(false)} @@ -326,10 +303,10 @@ const ModelManage: React.FC = () => { headerActions={[ { key: 'save', - label: activeTab === 'asr' ? '保存并重载 ASR' : '保存配置', + label: type === 'asr' ? '保存并重载 ASR' : '保存配置', type: 'primary', onClick: handleSave, - style: activeTab === 'asr' ? { background: '#fa541c', borderColor: '#fa541c' } : {} + style: type === 'asr' ? { background: '#fa541c', borderColor: '#fa541c' } : {} } as any ]} > @@ -341,4 +318,4 @@ const ModelManage: React.FC = () => { ); }; -export default ModelManage; +export default ModelList; diff --git a/frontend/src/pages/PermissionTree.tsx b/frontend/src/pages/PermissionTree.tsx index 18a232d..601515d 100644 --- a/frontend/src/pages/PermissionTree.tsx +++ b/frontend/src/pages/PermissionTree.tsx @@ -1,8 +1,9 @@ import { useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { Button, Card, Form, Input, Tag, Select, InputNumber, Space, Popconfirm, Popover, Tooltip } from "antd"; +import { Button, Card, Form, Input, Tag, Select, InputNumber, Space, Popconfirm, Popover, Tooltip, TreeSelect } from "antd"; import { - PlusOutlined, EditOutlined, DeleteOutlined, AppstoreOutlined + PlusOutlined, EditOutlined, DeleteOutlined, AppstoreOutlined, + CaretRightOutlined, CaretDownOutlined } from "@ant-design/icons"; import ListTable from "../components/ListTable/ListTable"; import DetailDrawer from "../components/DetailDrawer/DetailDrawer"; @@ -198,6 +199,24 @@ export default function PermissionTreePage() { } ], [perms]); + const treeData = useMemo(() => { + const loop = (data: PermItem[], disabled = false): any[] => + data.map((item) => { + const isDisabled = disabled || editing?.perm_id === item.perm_id; + return { + title: item.name, + value: item.perm_id, + key: item.perm_id, + disabled: isDisabled, + children: item.children ? loop(item.children, isDisabled) : [], + }; + }); + return [ + { title: '无 (顶级菜单)', value: null, key: 'root', children: [] }, + ...loop(perms) + ]; + }, [perms, editing]); + return (
{ + if (record.children && record.children.length > 0) { + return expanded ? ( + onExpand(record, e)} + style={{ marginRight: 8, cursor: 'pointer', color: '#666', fontSize: '12px' }} + /> + ) : ( + onExpand(record, e)} + style={{ marginRight: 8, cursor: 'pointer', color: '#666', fontSize: '12px' }} + /> + ); + } + return ; + } + }} />
@@ -232,8 +269,15 @@ export default function PermissionTreePage() { width={450} >
- - + + diff --git a/frontend/src/pages/PromptManage.tsx b/frontend/src/pages/PromptManage.tsx index 7bfffeb..8127a59 100644 --- a/frontend/src/pages/PromptManage.tsx +++ b/frontend/src/pages/PromptManage.tsx @@ -217,7 +217,7 @@ const PromptManage: React.FC = () => { - + diff --git a/frontend/src/pages/VoiceProfile.tsx b/frontend/src/pages/VoiceProfile.tsx new file mode 100644 index 0000000..1cac82f --- /dev/null +++ b/frontend/src/pages/VoiceProfile.tsx @@ -0,0 +1,208 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Card, Button, Table, Modal, Form, Input, message, Space, Upload, Tag } from 'antd'; +import { AudioOutlined, DeleteOutlined, UserAddOutlined, CheckCircleOutlined, UploadOutlined } from '@ant-design/icons'; +import { api } from '../api'; +import PageHeader from '../components/PageHeader/PageHeader'; + +const VoiceProfile: React.FC = () => { + const [speakers, setSpeakers] = useState([]); + const [loading, setLoading] = useState(false); + const [modalVisible, setModalVisible] = useState(false); + const [verifyModalVisible, setVerifyModalVisible] = useState(false); + const [recording, setRecording] = useState(false); + const [recordingTime, setRecordingTime] = useState(0); + const [form] = Form.useForm(); + + const mediaRecorderRef = useRef(null); + const chunksRef = useRef([]); + const timerRef = useRef(null); + + const fetchSpeakers = async () => { + setLoading(true); + try { + const data = await api.listSpeakers(); + setSpeakers(data); + } catch (error) { + message.error('获取声纹列表失败'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchSpeakers(); + }, []); + + const startRecording = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + mediaRecorderRef.current = new MediaRecorder(stream); + chunksRef.current = []; + + mediaRecorderRef.current.ondataavailable = (e) => { + if (e.data.size > 0) chunksRef.current.push(e.data); + }; + + mediaRecorderRef.current.start(); + setRecording(true); + setRecordingTime(0); + timerRef.current = setInterval(() => setRecordingTime(t => t + 1), 1000); + } catch (err) { + message.error('无法访问麦克风'); + } + }; + + const stopRecording = () => { + if (mediaRecorderRef.current && recording) { + mediaRecorderRef.current.stop(); + mediaRecorderRef.current.stream.getTracks().forEach(track => track.stop()); + setRecording(false); + clearInterval(timerRef.current); + } + }; + + const handleEnroll = async () => { + try { + const values = await form.validateFields(); + if (chunksRef.current.length === 0) { + return message.warning('请先录制音频'); + } + + const blob = new Blob(chunksRef.current, { type: 'audio/webm' }); + const file = new File([blob], 'enroll.webm', { type: 'audio/webm' }); + + const formData = new FormData(); + formData.append('name', values.name); + formData.append('file', file); + + await api.createSpeaker(formData); + message.success('注册成功'); + setModalVisible(false); + form.resetFields(); + chunksRef.current = []; + fetchSpeakers(); + } catch (error) { + console.error(error); + message.error('注册失败'); + } + }; + + const handleDelete = async (id: string) => { + try { + await api.deleteSpeaker(id); + message.success('删除成功'); + fetchSpeakers(); + } catch (error) { + message.error('删除失败'); + } + }; + + const columns = [ + { title: 'ID', dataIndex: 'id', key: 'id' }, + { title: '姓名', dataIndex: 'name', key: 'name' }, + { title: '注册时间', dataIndex: 'created_at', key: 'created_at' }, + { title: '状态', dataIndex: 'status', key: 'status', render: (s: string) => {s} }, + { + title: '操作', + key: 'action', + render: (_: any, record: any) => ( + + ), + }, + ]; + + return ( +
+ + + + + } + /> + + + + + + setModalVisible(false)} + onOk={handleEnroll} + confirmLoading={loading} + > + + + + + +
+ {!recording ? ( +
+
+ +
+ + setVerifyModalVisible(false)} + > +
+

请录制一段语音进行验证

+ {!recording ? ( +
+
+ + ); +}; + +export default VoiceProfile; diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 24ca284..0be9fd7 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -11,11 +11,15 @@ import PermissionTreePage from "./pages/PermissionTree"; import LogManagePage from "./pages/LogManage"; import PromptManagePage from "./pages/PromptManage"; import GlobalPromptManagePage from "./pages/GlobalPromptManage"; -import ModelManagePage from "./pages/ModelManage"; +import ModelSettingsLayout from "./pages/ModelSettings/Layout"; +import ModelList from "./pages/ModelSettings/ModelList"; import HistoryMeetingPage from "./pages/HistoryMeeting"; import MeetingDetailPage from "./pages/MeetingDetail"; import MeetingReportPage from "./pages/MeetingReport"; import TaskMonitorPage from "./pages/TaskMonitor"; +import HotwordsPage from "./pages/Hotwords"; +import VoiceProfilePage from "./pages/VoiceProfile"; +import MeetingLivePage from "./pages/MeetingLive"; import AppLayout from "./layout/AppLayout"; import { getAccessToken } from "./auth"; @@ -45,8 +49,19 @@ export const router = createBrowserRouter([ { path: "system/logs", element: }, { path: "system/tasks", element: }, { path: "system/prompts", element: }, - { path: "system/model", element: }, + { + path: "system/model", + element: , + children: [ + { index: true, element: }, + { path: "llm", element: }, + { path: "asr", element: }, + ], + }, { path: "meeting/history", element: }, + { path: "meeting/new", element: }, + { path: "ai-agent/hotwords", element: }, + { path: "setting/voice", element: }, { path: "meeting/history/:id", element: }, { path: "meeting/report/:id", element: }, { path: "setting/prompts", element: }, diff --git a/frontend/src/utils/icons.tsx b/frontend/src/utils/icons.tsx index 7421757..e180fe4 100644 --- a/frontend/src/utils/icons.tsx +++ b/frontend/src/utils/icons.tsx @@ -13,7 +13,10 @@ import { GlobalOutlined, InsuranceOutlined, MedicineBoxOutlined, ProjectOutlined, ReadOutlined, RocketOutlined, SaveOutlined, ShopOutlined, ShoppingOutlined, - TagOutlined, TrophyOutlined, VideoCameraOutlined + TagOutlined, TrophyOutlined, VideoCameraOutlined, + AudioOutlined, UnorderedListOutlined, + BulbOutlined, PartitionOutlined, RobotOutlined, + LaptopOutlined, HistoryOutlined, FileSearchOutlined } from "@ant-design/icons"; export const ICON_MAP: Record = { @@ -58,6 +61,21 @@ export const ICON_MAP: Record = { 'tag': , 'trophy': , 'video': , + 'audio': , + 'list': , + + // DB Icon Mappings + 'strategy': , + 'workspace': , + 'ai': , + 'system': , // Reusing setting or using another + 'kb': , + 'insight': , + 'meeting': , + 'template': , + 'history': , + 'hot': , + 'voice': , }; export const ICON_LIST = Object.keys(ICON_MAP).map(key => ({ diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo new file mode 100644 index 0000000..a820a57 --- /dev/null +++ b/frontend/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/components/ProtectedRoute.jsx","./src/components/ActionHelpPanel/ActionHelpPanel.jsx","./src/components/BottomHintBar/BottomHintBar.jsx","./src/components/ButtonWithGuide/ButtonWithGuide.jsx","./src/components/ButtonWithGuideBadge/ButtonWithGuideBadge.jsx","./src/components/ButtonWithHoverCard/ButtonWithHoverCard.jsx","./src/components/ButtonWithTip/ButtonWithTip.jsx","./src/components/ChartPanel/ChartPanel.jsx","./src/components/ConfirmDialog/ConfirmDialog.jsx","./src/components/DetailDrawer/DetailDrawer.tsx","./src/components/ExtendInfoPanel/ExtendInfoPanel.jsx","./src/components/InfoPanel/InfoPanel.jsx","./src/components/ListActionBar/ListActionBar.jsx","./src/components/ListTable/ListTable.tsx","./src/components/MainLayout/AppHeader.jsx","./src/components/MainLayout/AppSider.jsx","./src/components/MainLayout/MainLayout.jsx","./src/components/MainLayout/index.js","./src/components/ModernSidebar/ModernSidebar.tsx","./src/components/PDFViewer/PDFViewer.jsx","./src/components/PDFViewer/VirtualPDFViewer.jsx","./src/components/PageHeader/PageHeader.tsx","./src/components/PageTitleBar/PageTitleBar.jsx","./src/components/SelectionAlert/SelectionAlert.jsx","./src/components/SideInfoPanel/SideInfoPanel.jsx","./src/components/SplitLayout/SplitLayout.jsx","./src/components/StatCard/StatCard.jsx","./src/components/Toast/Toast.tsx","./src/components/TreeFilterPanel/TreeFilterPanel.jsx","./src/components/UserAvatar/UserAvatar.tsx","./src/layout/AppHeader.tsx","./src/layout/AppLayout.tsx","./src/pages/Dashboard.tsx","./src/pages/DictManage.tsx","./src/pages/GlobalPromptManage.tsx","./src/pages/HistoryMeeting.tsx","./src/pages/Home.tsx","./src/pages/HotwordManage.tsx","./src/pages/LogManage.tsx","./src/pages/Login.tsx","./src/pages/MeetingDetail.tsx","./src/pages/MeetingReport.tsx","./src/pages/ModelManage.tsx","./src/pages/NewMeeting.tsx","./src/pages/ParamManage.tsx","./src/pages/PermissionTree.tsx","./src/pages/Profile.tsx","./src/pages/PromptManage.tsx","./src/pages/RoleManage.tsx","./src/pages/TaskMonitor.tsx","./src/pages/UserManage.tsx","./src/pages/VoiceprintProfile.tsx","./src/utils/icons.tsx","./src/utils/url.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index f57d5e3..74b5839 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -6,6 +6,13 @@ export default defineConfig({ plugins: [react()], server: { port: 5173, + host: "0.0.0.0", + proxy: { + "/api": { + target: "http://localhost:8001", + changeOrigin: true, + }, + }, }, resolve: { alias: { diff --git a/frontend/yarn.lock b/frontend/yarn.lock index bac43cc..26fbb72 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -193,7 +193,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.27.1" -"@babel/runtime@^7.10.1", "@babel/runtime@^7.10.4", "@babel/runtime@^7.11.1", "@babel/runtime@^7.11.2", "@babel/runtime@^7.14.6", "@babel/runtime@^7.16.7", "@babel/runtime@^7.17.2", "@babel/runtime@^7.18.0", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.0", "@babel/runtime@^7.20.7", "@babel/runtime@^7.21.0", "@babel/runtime@^7.21.5", "@babel/runtime@^7.22.5", "@babel/runtime@^7.22.6", "@babel/runtime@^7.23.2", "@babel/runtime@^7.23.6", "@babel/runtime@^7.23.9", "@babel/runtime@^7.24.4", "@babel/runtime@^7.24.7", "@babel/runtime@^7.24.8", "@babel/runtime@^7.25.7", "@babel/runtime@^7.26.0": +"@babel/runtime@^7.10.1", "@babel/runtime@^7.10.4", "@babel/runtime@^7.11.1", "@babel/runtime@^7.11.2", "@babel/runtime@^7.14.6", "@babel/runtime@^7.16.7", "@babel/runtime@^7.17.2", "@babel/runtime@^7.18.0", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.0", "@babel/runtime@^7.20.7", "@babel/runtime@^7.21.0", "@babel/runtime@^7.21.5", "@babel/runtime@^7.22.5", "@babel/runtime@^7.22.6", "@babel/runtime@^7.23.2", "@babel/runtime@^7.23.6", "@babel/runtime@^7.23.9", "@babel/runtime@^7.24.4", "@babel/runtime@^7.24.7", "@babel/runtime@^7.24.8", "@babel/runtime@^7.25.7", "@babel/runtime@^7.26.0", "@babel/runtime@^7.28.4": version "7.28.6" resolved "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.28.6.tgz" integrity sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA== @@ -1371,6 +1371,13 @@ highlight.js@^11.8.0: resolved "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz" integrity sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w== +html-parse-stringify@^3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz" + integrity sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg== + dependencies: + void-elements "3.1.0" + html-url-attributes@^3.0.0: version "3.0.1" resolved "https://registry.npmmirror.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz" @@ -1391,6 +1398,13 @@ htmlparser2@^9.1.0: domutils "^3.1.0" entities "^4.5.0" +i18next@^25.8.13, "i18next@>= 25.6.2": + version "25.8.13" + resolved "https://registry.npmjs.org/i18next/-/i18next-25.8.13.tgz" + integrity sha512-E0vzjBY1yM+nsFrtgkjLhST2NBkirkvOVoQa0MSldhsuZ3jUge7ZNpuwG0Cfc74zwo5ZwRzg3uOgT+McBn32iA== + dependencies: + "@babel/runtime" "^7.28.4" + iconv-lite@^0.6.3, iconv-lite@0.6, iconv-lite@0.6.3: version "0.6.3" resolved "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz" @@ -2503,6 +2517,15 @@ react-easy-crop@^5.5.6: normalize-wheel "^1.0.1" tslib "^2.0.1" +react-i18next@^16.5.4: + version "16.5.4" + resolved "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.4.tgz" + integrity sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g== + dependencies: + "@babel/runtime" "^7.28.4" + html-parse-stringify "^3.0.1" + use-sync-external-store "^1.6.0" + react-is@^18.2.0: version "18.3.1" resolved "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz" @@ -2561,7 +2584,7 @@ react-router@6.30.3: dependencies: "@remix-run/router" "1.23.2" -react@*, react@^18.3.1, react@>=16.0.0, react@>=16.11.0, react@>=16.4.0, react@>=16.8, react@>=16.8.0, react@>=16.9.0, react@>=18: +react@*, "react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", react@^18.3.1, "react@>= 16.8.0", react@>=16.0.0, react@>=16.11.0, react@>=16.4.0, react@>=16.8, react@>=16.8.0, react@>=16.9.0, react@>=18: version "18.3.1" resolved "https://registry.npmmirror.com/react/-/react-18.3.1.tgz" integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== @@ -2887,7 +2910,7 @@ tslib@^2.0.1, tslib@^2.8.1: resolved "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== -typescript@^5.6.3: +typescript@^5, typescript@^5.6.3: version "5.9.3" resolved "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz" integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== @@ -2979,6 +3002,11 @@ update-browserslist-db@^1.2.0: escalade "^3.2.0" picocolors "^1.1.1" +use-sync-external-store@^1.6.0: + version "1.6.0" + resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz" + integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w== + vfile-location@^5.0.0: version "5.0.3" resolved "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz" @@ -3014,6 +3042,11 @@ vfile@^6.0.0: optionalDependencies: fsevents "~2.3.3" +void-elements@3.1.0: + version "3.1.0" + resolved "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz" + integrity sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w== + web-namespaces@^2.0.0: version "2.0.1" resolved "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz" diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..92ef5cc --- /dev/null +++ b/start.sh @@ -0,0 +1,73 @@ +#!/bin/bash + +# Exit on error +# set -e # Disable set -e because we want to continue if one service fails to start immediately or if lsof fails + +# Get script directory +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) + +echo "=== Starting Nex Basse Services ===" + +# --- Backend --- +echo "--- Setting up Backend ---" +cd "$SCRIPT_DIR/backend" + +# Check/Activate virtual environment +if [ -d "venv" ]; then + echo "Activating venv..." + source venv/bin/activate +elif [ -d ".venv" ]; then + echo "Activating .venv..." + source .venv/bin/activate +else + echo "Warning: No virtual environment found (venv or .venv). Using system python." +fi + +# Check backend port (8001) +BACKEND_PORT=8001 +if lsof -i :$BACKEND_PORT -t >/dev/null ; then + echo "Port $BACKEND_PORT is in use. Killing process..." + kill -9 $(lsof -i :$BACKEND_PORT -t) || true + sleep 1 +fi + +# Start Backend +echo "Starting Backend (FastAPI)..." +python app/main.py & +BACKEND_PID=$! +echo "Backend PID: $BACKEND_PID" + +# --- Frontend --- +echo "--- Setting up Frontend ---" +cd "$SCRIPT_DIR/frontend" + +# Check frontend port (5173) +FRONTEND_PORT=5173 +if lsof -i :$FRONTEND_PORT -t >/dev/null ; then + echo "Port $FRONTEND_PORT is in use. Killing process..." + kill -9 $(lsof -i :$FRONTEND_PORT -t) || true + sleep 1 +fi + +# Start Frontend +echo "Starting Frontend (Vite)..." +npm run dev & +FRONTEND_PID=$! +echo "Frontend PID: $FRONTEND_PID" + +# --- Cleanup --- +cleanup() { + echo "Stopping services..." + kill $BACKEND_PID 2>/dev/null + kill $FRONTEND_PID 2>/dev/null + exit +} + +trap cleanup SIGINT SIGTERM + +echo "Both services started." +echo "Backend: http://localhost:$BACKEND_PORT" +echo "Frontend: http://localhost:$FRONTEND_PORT" +echo "Press Ctrl+C to stop." + +wait $BACKEND_PID $FRONTEND_PID