更新相关错误逻辑

alan-pg
tanlianwang 2026-02-27 15:57:22 +08:00
parent 7f623d3e9f
commit 3738e14716
42 changed files with 1736 additions and 367 deletions

View File

@ -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"}

View File

@ -177,15 +177,21 @@ def update_user_config(
if not config: if not config:
template = db.query(PromptTemplate).filter(PromptTemplate.id == prompt_id).first() template = db.query(PromptTemplate).filter(PromptTemplate.id == prompt_id).first()
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
config = UserPromptConfig( config = UserPromptConfig(
user_id=current_user.user_id, user_id=current_user.user_id,
template_id=prompt_id, template_id=prompt_id,
is_active=True, is_active=1, # Default to active (int)
user_sort_order=template.sort_order if template else 0 user_sort_order=template.sort_order
) )
db.add(config) db.add(config)
for k, v in payload.model_dump(exclude_unset=True).items(): 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) setattr(config, k, v)
db.commit() db.commit()

View File

@ -1,5 +1,5 @@
from fastapi import APIRouter 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() router = APIRouter()
@ -15,3 +15,4 @@ router.include_router(dashboard.router)
router.include_router(prompts.router) router.include_router(prompts.router)
router.include_router(ai_models.router) router.include_router(ai_models.router)
router.include_router(meetings.router) router.include_router(meetings.router)
router.include_router(hotwords.router, prefix="/hotwords", tags=["Hotwords"])

View File

@ -9,6 +9,7 @@ from .sys_log import SysLog
from .prompt import PromptTemplate, UserPromptConfig from .prompt import PromptTemplate, UserPromptConfig
from .ai_model import AIModel from .ai_model import AIModel
from .meeting import Meeting, MeetingAttendee, MeetingAudio, TranscriptTask, TranscriptSegment, SummarizeTask from .meeting import Meeting, MeetingAttendee, MeetingAudio, TranscriptTask, TranscriptSegment, SummarizeTask
from .hotword import Hotword
__all__ = [ __all__ = [
"Base", "Base",
@ -30,4 +31,5 @@ __all__ = [
"TranscriptTask", "TranscriptTask",
"TranscriptSegment", "TranscriptSegment",
"SummarizeTask", "SummarizeTask",
"Hotword",
] ]

View File

@ -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")

View File

@ -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

View File

@ -126,7 +126,6 @@ VALUES
(12, 2, '历史记录', 'workspace.history', 'menu', 2, '/workspace/history', 'MeetingHistory', 'history', 2, 1, 1, '二级菜单', NULL), (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), (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), (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), (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), (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), (18, 4, '用户管理', 'system.users', 'menu', 2, '/system/users', 'UserManage', 'user', 2, 1, 1, '二级菜单', NULL),

View File

@ -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 alembic==1.13.3
python-dotenv==1.0.1 annotated-types==0.7.0
passlib[bcrypt]==1.7.4 anyio==4.12.1
bcrypt==4.1.3 bcrypt==4.1.3
python-jose[cryptography]==3.3.0 certifi==2026.2.25
redis==5.1.1 cffi==2.0.0
pymysql==1.1.1 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 psutil==5.9.8
psycopg2-binary==2.9.9 psycopg2-binary==2.9.9
python-multipart pyasn1==0.6.2
httpx 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

View File

@ -13,10 +13,12 @@
"antd": "^5.21.6", "antd": "^5.21.6",
"antd-img-crop": "^4.29.0", "antd-img-crop": "^4.29.0",
"d3": "^7.9.0", "d3": "^7.9.0",
"i18next": "^25.8.13",
"markmap-lib": "^0.18.12", "markmap-lib": "^0.18.12",
"markmap-view": "^0.18.12", "markmap-view": "^0.18.12",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-i18next": "^16.5.4",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^6.27.0" "react-router-dom": "^6.27.0"
}, },
@ -2619,6 +2621,14 @@
"node": ">=12.0.0" "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": { "node_modules/html-url-attributes": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmmirror.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz", "resolved": "https://registry.npmmirror.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@ -2657,6 +2667,36 @@
"entities": "^4.5.0" "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": { "node_modules/iconv-lite": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz",
@ -4629,6 +4669,32 @@
"react-dom": ">=16.4.0" "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": { "node_modules/react-is": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz", "resolved": "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz",
@ -5180,7 +5246,7 @@
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@ -5333,6 +5399,14 @@
"browserslist": ">= 4.21.0" "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": { "node_modules/vfile": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmmirror.com/vfile/-/vfile-6.0.3.tgz", "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": { "node_modules/web-namespaces": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",

View File

@ -14,10 +14,12 @@
"antd": "^5.21.6", "antd": "^5.21.6",
"antd-img-crop": "^4.29.0", "antd-img-crop": "^4.29.0",
"d3": "^7.9.0", "d3": "^7.9.0",
"i18next": "^25.8.13",
"markmap-lib": "^0.18.12", "markmap-lib": "^0.18.12",
"markmap-view": "^0.18.12", "markmap-view": "^0.18.12",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-i18next": "^16.5.4",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^6.27.0" "react-router-dom": "^6.27.0"
}, },

View File

@ -1,6 +1,24 @@
import { RouterProvider } from "react-router-dom"; import { RouterProvider } from "react-router-dom";
import { App as AntdApp } from "antd";
import { router } from "./router"; 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() { export default function App() {
return <RouterProvider router={router} />; return (
<ThemeProvider>
<AntdApp>
<AntdStaticHolder />
<RouterProvider router={router} />
</AntdApp>
</ThemeProvider>
);
} }

View File

@ -1,6 +1,6 @@
import { getAccessToken, getRefreshToken, setTokens, clearTokens } from "./auth"; 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<T>(path: string, options: RequestInit = {}): Promise<T> { async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = { const headers: Record<string, string> = {
@ -70,7 +70,7 @@ export const api = {
body: JSON.stringify({ refresh_token: refreshToken }) body: JSON.stringify({ refresh_token: refreshToken })
}), }),
me: () => request<{ username: string; display_name: string; user_id: number; avatar?: string; roles: string[] }>("/users/me"), 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) }), request("/users/me/password", { method: "PUT", body: JSON.stringify(payload) }),
uploadAvatar: (file: File) => { uploadAvatar: (file: File) => {
const formData = new FormData(); const formData = new FormData();
@ -98,6 +98,35 @@ export const api = {
listUsers: () => listUsers: () =>
request<Array<{ user_id: number; username: string; display_name: string; email: string | null; phone: string | null; status: number }>>("/users"), request<Array<{ user_id: number; username: string; display_name: string; email: string | null; phone: string | null; status: number }>>("/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<any>("/meetings/upload", { method: "POST", body: formData });
},
getTranscripts: (meetingId: number) => request<Array<any>>(`/meetings/${meetingId}/transcripts`),
// Hotwords APIs
listHotwords: () => request<Array<{ id: number; word: string; pinyin: string; weight: number }>>("/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<Array<{ id: string; name: string; created_at: string; status: string }>>("/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[] }) => 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) }), request("/users", { method: "POST", body: JSON.stringify(payload) }),
updateUser: (userId: number, payload: { display_name?: string; email?: string; phone?: string; status?: number; role_ids?: number[] }) => 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(); const params = new URLSearchParams();
if (filters?.status) params.append("status", filters.status); if (filters?.status) params.append("status", filters.status);
return request<any>(`/meetings/tasks?${params.toString()}`); return request<any>(`/meetings/tasks?${params.toString()}`);
} },
}; };

View File

@ -1,5 +1,6 @@
import { Table } from "antd"; import { Table } from "antd";
import type { ColumnsType, TablePaginationConfig, TableRowSelection } from "antd/es/table"; import type { ColumnsType, TablePaginationConfig, TableRowSelection } from "antd/es/table";
import { ExpandableConfig } from "antd/es/table/interface";
import "./ListTable.css"; import "./ListTable.css";
export type ListTableProps<T extends Record<string, any>> = { export type ListTableProps<T extends Record<string, any>> = {
@ -13,11 +14,12 @@ export type ListTableProps<T extends Record<string, any>> = {
onSelectAllPages?: () => void; onSelectAllPages?: () => void;
onClearSelection?: () => void; onClearSelection?: () => void;
pagination?: TablePaginationConfig | false; pagination?: TablePaginationConfig | false;
scroll?: { x?: number | true | string }; scroll?: { x?: number | true | string; y?: number | string };
onRowClick?: (record: T) => void; onRowClick?: (record: T) => void;
selectedRow?: T | null; selectedRow?: T | null;
loading?: boolean; loading?: boolean;
className?: string; className?: string;
expandable?: ExpandableConfig<T>;
}; };
function ListTable<T extends Record<string, any>>({ function ListTable<T extends Record<string, any>>({
@ -40,6 +42,7 @@ function ListTable<T extends Record<string, any>>({
selectedRow, selectedRow,
loading = false, loading = false,
className = "", className = "",
expandable,
}: ListTableProps<T>) { }: ListTableProps<T>) {
const rowSelection: TableRowSelection<T> | undefined = onSelectionChange const rowSelection: TableRowSelection<T> | undefined = onSelectionChange
? { ? {
@ -114,6 +117,7 @@ function ListTable<T extends Record<string, any>>({
onClick: () => onRowClick?.(record), onClick: () => onRowClick?.(record),
className: selectedRow?.[rowKey] === record[rowKey] ? "row-selected" : "", className: selectedRow?.[rowKey] === record[rowKey] ? "row-selected" : "",
})} })}
expandable={expandable}
/> />
</div> </div>
); );

View File

@ -1,10 +1,12 @@
.modern-sidebar { .modern-sidebar {
height: 100vh; height: 100vh;
position: relative; position: relative;
background: #ffffff !important; background: var(--sidebar-bg, #ffffff) !important;
border-right: 1px solid #f3f4f6; border-right: 1px solid var(--sidebar-border, #f3f4f6);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
color: var(--sidebar-text, #1f2937);
transition: all 0.2s;
} }
.modern-sidebar .ant-layout-sider-children { .modern-sidebar .ant-layout-sider-children {
@ -47,7 +49,7 @@
width: 44px; width: 44px;
height: 44px; height: 44px;
flex-shrink: 0; flex-shrink: 0;
background: #4a90e2; background: #4a90e2; /* Keep logo brand color */
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
@ -80,7 +82,7 @@
.logo-text { .logo-text {
font-size: 24px; font-size: 24px;
font-weight: 700; font-weight: 700;
color: #1f2937; color: var(--sidebar-text, #1f2937);
letter-spacing: -0.5px; letter-spacing: -0.5px;
white-space: nowrap; /* Prevent text wrapping */ white-space: nowrap; /* Prevent text wrapping */
} }
@ -94,172 +96,164 @@
top: 36px; /* Align with logo center */ top: 36px; /* Align with logo center */
width: 24px; width: 24px;
height: 24px; height: 24px;
background: #ffffff; background: var(--sidebar-bg, #ffffff);
border: 1px solid #e5e7eb; border: 1px solid var(--sidebar-border, #e5e7eb);
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
z-index: 100; z-index: 10;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); box-shadow: 0 2px 5px rgba(0,0,0,0.05);
color: #9ca3af; color: var(--sidebar-text, #6b7280);
font-size: 10px; transition: all 0.2s;
transition: all 0.2s ease;
} }
.collapse-trigger:hover { .collapse-trigger:hover {
color: #2563eb; background: var(--sidebar-hover-bg, #f9fafb);
border-color: #2563eb; color: var(--sidebar-active-text, #4a90e2);
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.15); border-color: var(--sidebar-active-text, #4a90e2);
} }
/* Menu Area */ /* Menu Content */
.modern-sidebar-menu { .modern-sidebar-content {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 8px 16px; padding: 0 12px 20px;
} }
.modern-sidebar.ant-layout-sider-collapsed .modern-sidebar-menu { /* Scrollbar styling */
padding: 8px 12px; .modern-sidebar-content::-webkit-scrollbar {
} width: 4px;
}
.modern-sidebar-menu::-webkit-scrollbar { .modern-sidebar-content::-webkit-scrollbar-track {
width: 0px; background: transparent;
}
.modern-sidebar-content::-webkit-scrollbar-thumb {
background: rgba(0,0,0,0.1);
border-radius: 4px;
} }
/* Menu Group */
.menu-group { .menu-group {
margin-bottom: 24px; margin-bottom: 24px;
} }
.group-title { .menu-group-title {
padding: 0 12px;
margin-bottom: 8px;
font-size: 11px; font-size: 11px;
color: #9ca3af;
font-weight: 700;
letter-spacing: 1px;
margin-bottom: 12px;
padding-left: 12px;
text-transform: uppercase; text-transform: uppercase;
color: var(--sidebar-text-secondary, #9ca3af);
font-weight: 600;
letter-spacing: 0.5px;
} }
/* Menu Item */ /* Menu Item */
.modern-sidebar-item { .modern-sidebar-item {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 12px 16px; padding: 10px 12px;
margin-bottom: 4px; margin-bottom: 4px;
border-radius: 8px;
cursor: pointer; cursor: pointer;
border-radius: 12px; color: var(--sidebar-text, #4b5563);
transition: all 0.2s;
color: #4b5563;
font-weight: 500; font-weight: 500;
font-size: 14px;
transition: all 0.2s;
} }
.modern-sidebar-item:hover { .modern-sidebar-item:hover {
background-color: #f8fafc; background: var(--sidebar-hover-bg, #f3f4f6);
color: #2563eb; color: var(--sidebar-text, #111827);
} }
.modern-sidebar-item.active { .modern-sidebar-item.active {
background-color: #f0f7ff; background: var(--sidebar-active-bg, #eff6ff);
color: #2563eb; color: var(--sidebar-active-text, #2563eb);
} }
.modern-sidebar-item.active .item-icon { .modern-sidebar-item.collapsed {
color: #2563eb; justify-content: center;
} padding: 12px 0;
.item-content {
display: flex;
align-items: center;
gap: 16px;
} }
.item-icon { .item-icon {
font-size: 20px;
display: flex; display: flex;
align-items: center; align-items: center;
color: #64748b;
transition: color 0.2s;
}
.item-label {
font-size: 15px;
}
/* Collapsed Item */
.modern-sidebar-item.collapsed {
justify-content: center; justify-content: center;
padding: 12px; font-size: 18px;
min-width: 24px;
} }
/* Footer Area */ .modern-sidebar-item .item-content {
.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 {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; 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; 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; overflow: hidden;
} }
.user-name { .user-name {
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: #1f2937; color: var(--sidebar-text, #1f2937);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.user-role { .user-role {
font-size: 11px; font-size: 12px;
color: #9ca3af; color: var(--sidebar-text-secondary, #6b7280);
font-weight: 500; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.logout-btn { .logout-btn {
color: #9ca3af; width: 32px;
cursor: pointer; height: 32px;
padding: 6px;
border-radius: 8px;
transition: all 0.2s;
display: flex; display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
color: var(--sidebar-text-secondary, #9ca3af);
transition: all 0.2s;
} }
.logout-btn:hover { .logout-btn:hover {
background-color: #fee2e2; background: #fee2e2;
color: #ef4444; color: #ef4444;
} }

View File

@ -1,15 +1,17 @@
import React, { CSSProperties, ReactNode } from 'react'; import React, { CSSProperties, ReactNode } from 'react';
import { Layout, Avatar, Tooltip } from 'antd'; import { Layout, Avatar, Tooltip, theme } from 'antd';
import { import {
RightOutlined, RightOutlined,
LeftOutlined, LeftOutlined,
QuestionCircleOutlined, QuestionCircleOutlined,
LogoutOutlined, LogoutOutlined,
FileTextOutlined FileTextOutlined,
UserOutlined
} from '@ant-design/icons'; } from '@ant-design/icons';
import './ModernSidebar.css'; import './ModernSidebar.css';
const { Sider } = Layout; const { Sider } = Layout;
const { useToken } = theme;
export interface SidebarItem { export interface SidebarItem {
key: string; key: string;
@ -62,7 +64,18 @@ const ModernSidebar: React.FC<ModernSidebarProps> = ({
className = '', className = '',
style = {} 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) => { const handleItemClick = (item: SidebarItem) => {
if (onNavigate) { if (onNavigate) {
onNavigate(item.key, item); onNavigate(item.key, item);
@ -102,79 +115,85 @@ const ModernSidebar: React.FC<ModernSidebarProps> = ({
return ( return (
<Sider <Sider
width={width} width={width}
collapsed={collapsed}
collapsedWidth={collapsedWidth} collapsedWidth={collapsedWidth}
trigger={null} trigger={null}
collapsible
collapsed={collapsed}
className={`modern-sidebar ${className}`} className={`modern-sidebar ${className}`}
theme="light" style={{ ...style, ...cssVars }}
style={style}
> >
{/* 1. Header with Logo (matches image) */} {/* Header / Logo */}
<div className="modern-sidebar-header"> <div className="modern-sidebar-header">
<div className="logo-container"> {logo ? (
<div className="nexdocus-logo"> logo
<div className="logo-icon-wrapper"> ) : (
<FileTextOutlined className="logo-file-icon" /> <div className="logo-container">
<div className="logo-badge">N</div> <div className="nexdocus-logo">
<div className="logo-icon-wrapper">
<FileTextOutlined className="logo-file-icon" />
<div className="logo-badge">AI</div>
</div>
{!collapsed && <span className="logo-text">{platformName}</span>}
</div> </div>
{!collapsed && <span className="logo-text">{platformName}</span>}
</div> </div>
</div> )}
{/* Collapse Trigger - Circular button on the boundary */} {/* Collapse Trigger */}
<div <div
className="collapse-trigger" className="collapse-trigger"
onClick={() => onCollapse && onCollapse(!collapsed)} onClick={() => onCollapse && onCollapse(!collapsed)}
> >
{collapsed ? <RightOutlined /> : <LeftOutlined />} {collapsed ? <RightOutlined style={{ fontSize: 10 }} /> : <LeftOutlined style={{ fontSize: 10 }} />}
</div> </div>
</div> </div>
{/* 2. Menu Items */} {/* Menu Content */}
<div className="modern-sidebar-menu"> <div className="modern-sidebar-content">
{menuGroups.map((group, index) => ( {menuGroups.map((group, index) => (
<div key={index} className="menu-group"> <div key={index} className="menu-group">
{!collapsed && group.title && ( {!collapsed && group.title && (
<div className="group-title">{group.title}</div> <div className="menu-group-title">{group.title}</div>
)} )}
<div className="group-items"> <div className="menu-group-items">
{group.items.map(item => renderMenuItem(item))} {group.items.map(renderMenuItem)}
</div> </div>
</div> </div>
))} ))}
</div> </div>
{/* 3. Footer with User Info */} {/* Footer / User Profile */}
<div className="modern-sidebar-footer"> <div className="modern-sidebar-footer">
<div className="user-card"> {collapsed ? (
<div <Tooltip title={user?.name} placement="right">
className="user-info" <div className="user-profile-card collapsed" onClick={onProfileClick}>
onClick={onProfileClick} <Avatar src={user?.avatar} icon={<UserOutlined />} style={{ backgroundColor: token.colorPrimary }} />
style={{ cursor: onProfileClick ? 'pointer' : 'default' }} </div>
> </Tooltip>
<Avatar ) : (
size={collapsed ? 32 : 40} <div className="user-profile-card" onClick={onProfileClick}>
src={user?.avatar} <Avatar src={user?.avatar} icon={<UserOutlined />} style={{ backgroundColor: token.colorPrimary }} />
style={{ backgroundColor: '#1677ff' }} <div className="user-info">
> <div className="user-name">{user?.name}</div>
{user?.name?.[0]?.toUpperCase() || 'U'} <div className="user-role">{user?.role}</div>
</Avatar> </div>
{!collapsed && ( {onLogout && (
<div className="user-details"> <Tooltip title="退出登录">
<div className="user-name">{user?.name || 'User'}</div> <div
<div className="user-role">{user?.role || 'Admin'}</div> className="logout-btn"
</div> onClick={(e) => {
e.stopPropagation();
onLogout();
}}
>
<LogoutOutlined />
</div>
</Tooltip>
)} )}
</div> </div>
{!collapsed && ( )}
<div className="logout-btn" onClick={onLogout} title="退出登录">
<LogoutOutlined />
</div>
)}
</div>
</div> </div>
</Sider> </Sider>
); );
}; };
export default ModernSidebar; export default ModernSidebar;

View File

@ -2,7 +2,8 @@
.split-layout { .split-layout {
display: flex; display: flex;
width: 100%; width: 100%;
align-items: flex-start; height: 100%;
/* align-items: flex-start; Removed to allow stretch */
} }
/* 横向布局(左右分栏) */ /* 横向布局(左右分栏) */
@ -32,11 +33,11 @@
/* 右侧扩展区(横向布局) */ /* 右侧扩展区(横向布局) */
.split-layout-extend-right { .split-layout-extend-right {
height: 693px; height: 100%;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
position: sticky; /* position: sticky; Removed as height is 100% */
top: 16px; /* top: 16px; Removed */
padding-right: 4px; padding-right: 4px;
} }

View File

@ -1,4 +1,5 @@
import { notification } from "antd"; import { notification } from "antd";
import { NotificationInstance } from "antd/es/notification/interface";
import { import {
CheckCircleOutlined, CheckCircleOutlined,
CloseCircleOutlined, CloseCircleOutlined,
@ -6,6 +7,14 @@ import {
InfoCircleOutlined, InfoCircleOutlined,
} from "@ant-design/icons"; } 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({ notification.config({
placement: "topRight", placement: "topRight",
top: 24, top: 24,
@ -13,9 +22,11 @@ notification.config({
maxCount: 3, maxCount: 3,
}); });
const getNotification = () => notificationInstance || notification;
const Toast = { const Toast = {
success: (message: string, description = "", duration = 3) => { success: (message: string, description = "", duration = 3) => {
notification.success({ getNotification().success({
message, message,
description, description,
duration, duration,
@ -28,7 +39,7 @@ const Toast = {
}, },
error: (message: string, description = "", duration = 3) => { error: (message: string, description = "", duration = 3) => {
notification.error({ getNotification().error({
message, message,
description, description,
duration, duration,
@ -41,7 +52,7 @@ const Toast = {
}, },
warning: (message: string, description = "", duration = 3) => { warning: (message: string, description = "", duration = 3) => {
notification.warning({ getNotification().warning({
message, message,
description, description,
duration, duration,
@ -54,7 +65,7 @@ const Toast = {
}, },
info: (message: string, description = "", duration = 3) => { info: (message: string, description = "", duration = 3) => {
notification.info({ getNotification().info({
message, message,
description, description,
duration, duration,
@ -66,13 +77,13 @@ const Toast = {
}); });
}, },
custom: (config: Record<string, any>) => { custom: (config: any) => {
notification.open({ getNotification().open({
...config, ...config,
style: { style: {
borderRadius: "8px", borderRadius: "8px",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)", boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
...config.style, ...(config.style || {}),
}, },
}); });
}, },

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, { forwardRef } from 'react';
import { Avatar, AvatarProps } from 'antd'; import { Avatar, AvatarProps } from 'antd';
import { UserOutlined } from '@ant-design/icons'; import { UserOutlined } from '@ant-design/icons';
import { resolveUrl } from '../../utils/url'; import { resolveUrl } from '../../utils/url';
@ -11,9 +11,9 @@ interface UserAvatarProps extends AvatarProps {
}; };
} }
const UserAvatar: React.FC<UserAvatarProps> = ({ user, ...rest }) => { const UserAvatar = forwardRef<HTMLSpanElement, UserAvatarProps>(({ user, ...rest }, ref) => {
if (user?.avatar) { if (user?.avatar) {
return <Avatar src={resolveUrl(user.avatar)} {...rest} />; return <Avatar ref={ref} src={resolveUrl(user.avatar)} {...rest} />;
} }
const name = user?.display_name || user?.username || '?'; const name = user?.display_name || user?.username || '?';
@ -21,12 +21,13 @@ const UserAvatar: React.FC<UserAvatarProps> = ({ user, ...rest }) => {
return ( return (
<Avatar <Avatar
ref={ref}
style={{ backgroundColor: '#1890ff', verticalAlign: 'middle' }} style={{ backgroundColor: '#1890ff', verticalAlign: 'middle' }}
{...rest} {...rest}
> >
{firstLetter} {firstLetter}
</Avatar> </Avatar>
); );
}; });
export default UserAvatar; export default UserAvatar;

View File

@ -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<ThemeContextType | undefined>(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<ThemeMode>(() => {
return (localStorage.getItem('theme_mode') as ThemeMode) || 'light';
});
const [primaryColor, setPrimaryColorState] = useState<string>(() => {
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 (
<ThemeContext.Provider value={{ mode, setMode, primaryColor, setPrimaryColor }}>
<ConfigProvider
locale={antdLocale}
theme={{
algorithm,
token: {
colorPrimary: primaryColor,
},
}}
>
{children}
</ConfigProvider>
</ThemeContext.Provider>
);
};

View File

@ -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;

View File

@ -1,6 +1,6 @@
.app-header-main { .app-header-main {
background: #fff !important; background: var(--header-bg, #fff) !important;
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid var(--header-border, #e5e7eb);
padding: 0 24px; padding: 0 24px;
height: 64px; height: 64px;
line-height: 64px; line-height: 64px;
@ -12,6 +12,7 @@
position: sticky; position: sticky;
top: 0; top: 0;
width: 100%; width: 100%;
transition: all 0.2s;
} }
.header-left { .header-left {
@ -26,7 +27,7 @@
.header-icon-btn { .header-icon-btn {
font-size: 18px; font-size: 18px;
color: #4b5563; color: var(--header-text-secondary, #4b5563);
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
@ -38,8 +39,8 @@
} }
.header-icon-btn:hover { .header-icon-btn:hover {
background: #f3f4f6; background: var(--header-hover-bg, #f3f4f6);
color: #2563eb; color: var(--header-active-text, #2563eb);
} }
.user-dropdown-trigger { .user-dropdown-trigger {
@ -50,11 +51,11 @@
} }
.user-dropdown-trigger:hover { .user-dropdown-trigger:hover {
background: #f3f4f6; background: var(--header-hover-bg, #f3f4f6);
} }
.display-name { .display-name {
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
color: #374151; color: var(--header-text, #374151);
} }

View File

@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React from 'react';
import { Layout, Space, Badge, Segmented, Tooltip, Avatar, Dropdown, MenuProps } from 'antd'; import { Layout, Space, Badge, Segmented, Tooltip, Dropdown, MenuProps, ColorPicker, theme } from 'antd';
import { import {
BellOutlined, BellOutlined,
SunOutlined, SunOutlined,
@ -8,9 +8,12 @@ import {
LogoutOutlined, LogoutOutlined,
SettingOutlined SettingOutlined
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { useTheme } from '../contexts/ThemeContext';
import './AppHeader.css'; import './AppHeader.css';
const { Header } = Layout; const { Header } = Layout;
const { useToken } = theme;
interface AppHeaderProps { interface AppHeaderProps {
displayName: string; displayName: string;
@ -19,19 +22,40 @@ interface AppHeaderProps {
} }
const AppHeader: React.FC<AppHeaderProps> = ({ displayName, onLogout, onProfileClick }) => { const AppHeader: React.FC<AppHeaderProps> = ({ displayName, onLogout, onProfileClick }) => {
const [isDarkMode, setIsDarkMode] = useState(false); const { token } = useToken();
const [lang, setLang] = useState('zh'); 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'] = [ const userMenuItems: MenuProps['items'] = [
{ {
key: 'profile', key: 'profile',
label: '个人资料', label: t('header.profile'),
icon: <UserOutlined />, icon: <UserOutlined />,
onClick: onProfileClick, onClick: onProfileClick,
}, },
{ {
key: 'settings', key: 'settings',
label: '个人设置', label: t('header.settings'),
icon: <SettingOutlined />, icon: <SettingOutlined />,
}, },
{ {
@ -39,7 +63,7 @@ const AppHeader: React.FC<AppHeaderProps> = ({ displayName, onLogout, onProfileC
}, },
{ {
key: 'logout', key: 'logout',
label: '退出登录', label: t('header.logout'),
icon: <LogoutOutlined />, icon: <LogoutOutlined />,
danger: true, danger: true,
onClick: onLogout, onClick: onLogout,
@ -47,7 +71,7 @@ const AppHeader: React.FC<AppHeaderProps> = ({ displayName, onLogout, onProfileC
]; ];
return ( return (
<Header className="app-header-main"> <Header className="app-header-main" style={cssVars}>
<div className="header-left"> <div className="header-left">
{/* Logo removed as it is present in the sidebar */} {/* Logo removed as it is present in the sidebar */}
</div> </div>
@ -55,16 +79,16 @@ const AppHeader: React.FC<AppHeaderProps> = ({ displayName, onLogout, onProfileC
<div className="header-right"> <div className="header-right">
<Space size={20}> <Space size={20}>
{/* Theme Toggle */} {/* Theme Toggle */}
<Tooltip title={isDarkMode ? '切换到浅色模式' : '切换到深色模式'}> <Tooltip title={isDarkMode ? t('header.lightMode') : t('header.darkMode')}>
<div className="header-icon-btn" onClick={() => setIsDarkMode(!isDarkMode)}> <div className="header-icon-btn" onClick={handleThemeModeChange}>
{isDarkMode ? <SunOutlined /> : <MoonOutlined />} {isDarkMode ? <SunOutlined /> : <MoonOutlined />}
</div> </div>
</Tooltip> </Tooltip>
{/* Language Toggle */} {/* Language Toggle */}
<Segmented <Segmented
value={lang} value={i18n.language === 'en' ? 'en' : 'zh'}
onChange={(v) => setLang(v as string)} onChange={(v) => handleLangChange(v as string)}
options={[ options={[
{ label: '中', value: 'zh' }, { label: '中', value: 'zh' },
{ label: 'EN', value: 'en' }, { label: 'EN', value: 'en' },
@ -72,7 +96,7 @@ const AppHeader: React.FC<AppHeaderProps> = ({ displayName, onLogout, onProfileC
/> />
{/* Notifications */} {/* Notifications */}
<Tooltip title="消息通知"> <Tooltip title={t('header.notifications')}>
<div className="header-icon-btn"> <div className="header-icon-btn">
<Badge count={5} size="small" offset={[2, -2]}> <Badge count={5} size="small" offset={[2, -2]}>
<BellOutlined /> <BellOutlined />

View File

@ -1,22 +1,15 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Layout } from "antd"; import { Layout } from "antd";
import {
AppstoreOutlined,
SettingOutlined,
UserOutlined
} from "@ant-design/icons";
import { Outlet, useLocation, useNavigate } from "react-router-dom"; import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api"; import { api } from "../api";
import { clearTokens, getRefreshToken } from "../auth"; import { clearTokens } from "../auth";
import Toast from "../components/Toast/Toast"; 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 AppHeader from "./AppHeader";
import { getIcon } from "../utils/icons"; import { getIcon } from "../utils/icons";
import { resolveUrl } from "../utils/url";
import "./AppLayout.css"; import "./AppLayout.css";
const { Content } = Layout;
type MenuNode = { type MenuNode = {
id: number; id: number;
name: string; name: string;
@ -30,11 +23,12 @@ type MenuNode = {
}; };
export default function AppLayout() { export default function AppLayout() {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [menus, setMenus] = useState<MenuNode[]>([]); const [menus, setMenus] = useState<MenuNode[]>([]);
const [displayName, setDisplayName] = useState("管理员"); const [displayName, setDisplayName] = useState("Admin");
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [userRole, setUserRole] = useState("Admin"); const [userRole, setUserRole] = useState("Admin");
const [avatar, setAvatar] = useState<string | null>(null); const [avatar, setAvatar] = useState<string | null>(null);
@ -54,7 +48,7 @@ export default function AppLayout() {
setDisplayName(res.display_name || res.username); setDisplayName(res.display_name || res.username);
setUsername(res.username); setUsername(res.username);
setAvatar(res.avatar || null); 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(() => { }).catch(() => {
clearTokens(); clearTokens();
navigate("/login"); navigate("/login");
@ -71,15 +65,18 @@ export default function AppLayout() {
const fetchMenu = () => { const fetchMenu = () => {
api api
.getMenuTree() .getMenuTree()
.then((res) => setMenus(res as MenuNode[])) .then((res) => {
.catch(() => Toast.error("菜单加载失败")); const serverMenus = res as MenuNode[];
setMenus(serverMenus);
})
.catch(() => Toast.error(t('common.error')));
}; };
fetchMenu(); fetchMenu();
window.addEventListener('menu-refresh', fetchMenu); window.addEventListener('menu-refresh', fetchMenu);
return () => window.removeEventListener('menu-refresh', fetchMenu); return () => window.removeEventListener('menu-refresh', fetchMenu);
}, []); }, [t]);
useEffect(() => { useEffect(() => {
if (!menus.length) return; if (!menus.length) return;
@ -93,77 +90,98 @@ export default function AppLayout() {
} }
}, [menus, location.pathname, navigate]); }, [menus, location.pathname, navigate]);
const menuGroups: SidebarGroup[] = useMemo(() => { // Helper to translate menu names
return menus.map((group) => ({ const translateMenuName = (name: string, code: string) => {
title: group.name, // Try to find translation by code (e.g. 'system.user' -> 'menu.userManage')
items: (group.children || []).map((child) => ({ // This requires a mapping or convention.
key: child.path || child.code, // For now, let's try a simple mapping based on the code or just return name if not found.
label: child.name,
icon: getIcon(child.icon), // Example codes: 'dashboard', 'workspace', 'system'
path: child.path || "" // Map code to translation key
})), const codeMap: Record<string, string> = {
})); 'home': 'menu.home',
}, [menus]); '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(() => { if (codeMap[code]) {
return location.pathname; return t(codeMap[code]);
}, [location.pathname]);
const onNavigate = (key: string, item: SidebarItem) => {
if (item.path) {
navigate(item.path);
} else {
navigate(key);
} }
return name;
}; };
const handleLogout = async () => { const menuGroups: SidebarGroup[] = useMemo(() => {
const token = getRefreshToken(); return menus.map((group) => ({
if (token) { title: translateMenuName(group.name, group.code),
try { items: (group.children || []).map((child) => ({
await api.logout(token); key: child.path || child.code,
} catch { label: translateMenuName(child.name, child.code),
// ignore icon: getIcon(child.icon),
} path: child.path
} }))
}));
}, [menus, t]);
const handleLogout = () => {
clearTokens(); clearTokens();
navigate("/login"); navigate("/login");
}; };
return ( return (
<Layout className="app-layout-root"> <Layout style={{ minHeight: "100vh" }}>
{/* Sidebar on the left, full height */} <ModernSidebar
<ModernSidebar platformName={platformName}
user={{
name: displayName,
role: userRole,
avatar: avatar || undefined
}}
menuGroups={menuGroups}
collapsed={collapsed} collapsed={collapsed}
onCollapse={setCollapsed} onCollapse={setCollapsed}
logo={null} activeKey={location.pathname}
platformName={platformName} onNavigate={(key) => navigate(key)}
menuGroups={menuGroups}
activeKey={activeKey}
onNavigate={onNavigate}
user={{
name: displayName,
role: username,
avatar: resolveUrl(avatar)
}}
onLogout={handleLogout} onLogout={handleLogout}
onProfileClick={() => navigate("/profile")} onProfileClick={() => navigate('/profile')}
style={{ height: '100vh', zIndex: 200 }}
/> />
{/* Right side: Header + Content */} <Layout>
<Layout className="app-layout-right">
<AppHeader <AppHeader
displayName={displayName} displayName={displayName}
onLogout={handleLogout} onLogout={handleLogout}
onProfileClick={() => navigate("/profile")} onProfileClick={() => navigate('/profile')}
/> />
<Layout className="app-layout-content-wrapper"> <Layout.Content
<Content className="app-content-area"> style={{
<Outlet /> margin: '24px 16px',
</Content> padding: 24,
</Layout> minHeight: 280,
// background: colorBgContainer, // ModernSidebar handles its own background, but content needs one?
// Actually AppLayout.css or global css might handle it.
// Let's use standard layout for now.
}}
>
<Outlet />
</Layout.Content>
</Layout> </Layout>
</Layout> </Layout>
); );

View File

@ -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"
}
}

View File

@ -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": "模型管理"
}
}

View File

@ -1,7 +1,6 @@
import React from "react"; import React from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { RouterProvider } from "react-router-dom"; import App from "./App";
import { router } from "./router";
import "antd/dist/reset.css"; import "antd/dist/reset.css";
import "./styles.css"; import "./styles.css";
@ -12,6 +11,6 @@ if (!container) {
createRoot(container).render( createRoot(container).render(
<React.StrictMode> <React.StrictMode>
<RouterProvider router={router} /> <App />
</React.StrictMode> </React.StrictMode>
); );

View File

@ -97,6 +97,7 @@ const HistoryMeeting: React.FC = () => {
<Card <Card
hoverable hoverable
style={{ borderRadius: '12px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.05)' }} style={{ borderRadius: '12px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.05)' }}
variant="borderless"
styles={{ body: { padding: '20px' } }} styles={{ body: { padding: '20px' } }}
onClick={() => navigate(`/meeting/history/${m.meeting_id}`)} onClick={() => navigate(`/meeting/history/${m.meeting_id}`)}
> >
@ -136,7 +137,7 @@ const HistoryMeeting: React.FC = () => {
</div> </div>
<div style={{ borderTop: '1px solid #f0f0f0', paddingTop: '15px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ borderTop: '1px solid #f0f0f0', paddingTop: '15px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Avatar.Group maxCount={4} size="small"> <Avatar.Group max={{ count: 4 }} size="small">
{m.attendees?.map((a: any) => ( {m.attendees?.map((a: any) => (
<Tooltip title={a.display_name} key={a.attendee_id}> <Tooltip title={a.display_name} key={a.attendee_id}>
<UserAvatar user={a} /> <UserAvatar user={a} />
@ -234,7 +235,10 @@ const HistoryMeeting: React.FC = () => {
</div> </div>
{loading ? ( {loading ? (
<div style={{ textAlign: 'center', padding: '100px' }}><Spin tip="努力加载中..." /></div> <div style={{ textAlign: 'center', padding: '100px' }}>
<Spin />
<div style={{ marginTop: 8 }}>...</div>
</div>
) : meetings.length > 0 ? ( ) : meetings.length > 0 ? (
<> <>
{viewMode === 'grid' ? ( {viewMode === 'grid' ? (

View File

@ -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<HotwordItem[]>([]);
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [searchText, setSearchText] = useState('');
const [currentHotword, setCurrentHotword] = useState<HotwordItem | null>(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) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<FireOutlined style={{ color: '#ff4d4f' }} />
<span style={{ fontWeight: 600, fontSize: '15px' }}>{t}</span>
</div>
)
},
{
title: '拼音',
dataIndex: 'pinyin',
key: 'pinyin',
width: '30%',
render: (t: string) => <Tag color="blue" style={{ fontFamily: 'monospace' }}>{t}</Tag>
},
{
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 (
<Tooltip title={`权重: ${w}`}>
<Tag color={color} style={{ minWidth: 40, textAlign: 'center', fontWeight: 'bold' }}>{w}x</Tag>
</Tooltip>
);
}
},
{
title: '操作',
key: 'action',
width: 180,
render: (_: any, record: HotwordItem) => (
<div style={{ display: 'flex', gap: 8 }}>
<Button
type="text"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
style={{ color: '#1890ff' }}
>
</Button>
<Popconfirm
title="确定删除该热词吗?"
description="删除后将不再对该词进行强化识别"
onConfirm={() => handleDelete(record.id)}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Button type="text" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</div>
),
},
];
return (
<div className="page-wrapper" style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<PageTitleBar
title="热词管理"
description="配置语音识别热词,提升特定词汇(如人名、地名、专业术语)的识别准确率"
badge={hotwords.length}
/>
<div style={{ flex: 1, padding: '0 24px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<Card bodyStyle={{ padding: 0 }} bordered={false} style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '16px 24px', borderBottom: '1px solid #f0f0f0' }}>
<ListActionBar
actions={[
{
key: 'add',
label: '添加热词',
type: 'primary',
icon: <PlusOutlined />,
onClick: () => setModalVisible(true),
}
]}
search={{
placeholder: "搜索热词或拼音...",
value: searchText,
onChange: (val: string) => setSearchText(val),
width: 320
}}
showRefresh
onRefresh={fetchHotwords}
/>
</div>
<div style={{ flex: 1, overflow: 'hidden' }}>
<ListTable
columns={columns}
dataSource={filteredHotwords}
rowKey="id"
loading={loading}
pagination={{ pageSize: 10, showSizeChanger: true, showQuickJumper: true }}
scroll={{ x: 800, y: 'calc(100vh - 340px)' }}
/>
</div>
</Card>
</div>
<Modal
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ background: '#e6f7ff', padding: 8, borderRadius: '50%', color: '#1890ff', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<FireOutlined style={{ fontSize: 18 }} />
</div>
<span style={{ fontSize: 16 }}>{currentHotword ? '编辑热词' : '添加新热词'}</span>
</div>
}
open={modalVisible}
onCancel={handleModalClose}
onOk={handleSubmit}
destroyOnClose
width={520}
okText={currentHotword ? '保存' : '添加'}
cancelText="取消"
>
<Form form={form} layout="vertical" style={{ marginTop: 24 }}>
<Form.Item
name="word"
label={
<span>
<Tooltip title="需要提高识别准确率的专有名词、术语等"><QuestionCircleOutlined style={{ color: '#999' }} /></Tooltip>
</span>
}
rules={[{ required: true, message: '请输入热词' }]}
>
<Input placeholder="请输入需要强化的词汇,如:人工智能" size="large" />
</Form.Item>
<Form.Item
name="weight"
label={
<span>
<Tooltip title="权重越高,该词被识别出的概率越大。推荐范围 2.0 - 5.0"><QuestionCircleOutlined style={{ color: '#999' }} /></Tooltip>
</span>
}
initialValue={2.0}
>
<InputNumber
min={1.0}
max={10.0}
step={0.5}
style={{ width: '100%' }}
size="large"
addonAfter="倍"
/>
</Form.Item>
<div style={{ background: '#f9f9f9', padding: 16, borderRadius: 8, border: '1px solid #f0f0f0' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8, fontWeight: 500, color: '#333' }}>
<QuestionCircleOutlined /> 使
</div>
<ul style={{ paddingLeft: 20, margin: 0, color: '#666', fontSize: 13, lineHeight: 1.8 }}>
<li></li>
<li></li>
<li> <b>2.0 - 5.0</b> </li>
</ul>
</div>
</Form>
</Modal>
</div>
);
};
export default Hotwords;

View File

@ -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 (
<div style={{ padding: 24 }}>
<PageHeader
title="新建会议"
description="创建实时会议或上传音频文件进行转写"
/>
<Row gutter={[24, 24]}>
<Col span={12}>
<Card title="创建实时会议" bordered={false} hoverable>
<Form form={form} layout="vertical" onFinish={handleCreate}>
<Form.Item name="title" label="会议标题" rules={[{ required: true }]}>
<Input placeholder="请输入会议标题" prefix={<AudioOutlined />} />
</Form.Item>
<Form.Item name="participants" label="参会人">
<Select mode="tags" placeholder="请输入参会人姓名" open={false}>
</Select>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" icon={<UserAddOutlined />} block>
</Button>
</Form.Item>
</Form>
</Card>
</Col>
<Col span={12}>
<Card title="上传录音文件" variant="borderless" hoverable>
<Dragger
name="file"
multiple={false}
beforeUpload={handleUpload}
showUploadList={false}
accept="audio/*,video/*"
disabled={uploading}
>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text"></p>
<p className="ant-upload-hint">
MP3, WAV, M4A
</p>
</Dragger>
{uploading && (
<div style={{ marginTop: 16 }}>
<Progress percent={progress} status={progress === 100 ? 'success' : 'active'} />
<div style={{ textAlign: 'center', marginTop: 8 }}>
{progress === 100 ? '上传完成,正在处理...' : '上传中...'}
</div>
</div>
)}
</Card>
</Col>
</Row>
</div>
);
};
export default MeetingLive;

View File

@ -51,7 +51,12 @@ const MeetingReport: React.FC = () => {
} }
}; };
if (loading) return <div style={{ textAlign: 'center', padding: '100px' }}><Spin size="large" tip="构建报告中..." /></div>; if (loading) return (
<div style={{ textAlign: 'center', padding: '100px' }}>
<Spin size="large" />
<div style={{ marginTop: 8 }}>...</div>
</div>
);
if (!meeting?.summary) return <Empty description="暂无报告内容" style={{ padding: '100px' }} />; if (!meeting?.summary) return <Empty description="暂无报告内容" style={{ padding: '100px' }} />;
return ( return (

View File

@ -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: <SettingOutlined />, label: '通用设置', disabled: true },
{ key: 'llm', icon: <RobotOutlined />, label: 'AI 模型' },
{ key: 'asr', icon: <AudioOutlined />, label: '语音识别' },
{ key: 'voiceprint', icon: <ThunderboltOutlined />, label: '声纹识别', disabled: true },
{ key: 'security', icon: <SafetyCertificateOutlined />, label: '安全设置', disabled: true },
{ key: 'about', icon: <InfoCircleOutlined />, label: '关于系统', disabled: true },
];
return (
<div className="settings-container" style={{ padding: '24px', background: '#f5f7f9', minHeight: '100vh' }}>
<Card bordered={false} bodyStyle={{ padding: 0, display: 'flex', minHeight: 'calc(100vh - 100px)' }} style={{ borderRadius: '12px', overflow: 'hidden' }}>
<div style={{ width: 200, borderRight: '1px solid #f0f0f0' }}>
<Menu
mode="inline"
selectedKeys={[activeKey]}
style={{ height: '100%', borderRight: 0, paddingTop: 16 }}
items={items}
onClick={({ key }) => navigate(`/system/model/${key}`)}
/>
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
<Outlet />
</div>
</Card>
</div>
);
};
export default ModelSettingsLayout;

View File

@ -1,11 +1,8 @@
import React, { useState, useEffect } from 'react'; 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 { import {
RobotOutlined, RobotOutlined,
AudioOutlined, AudioOutlined,
SettingOutlined,
SafetyCertificateOutlined,
InfoCircleOutlined,
CheckCircleOutlined, CheckCircleOutlined,
ThunderboltOutlined, ThunderboltOutlined,
PlusOutlined, PlusOutlined,
@ -13,26 +10,28 @@ import {
DeleteOutlined, DeleteOutlined,
StarOutlined, StarOutlined,
StarFilled, StarFilled,
SaveOutlined
} from '@ant-design/icons'; } from '@ant-design/icons';
import { api } from '../api'; import { api } from '../../api';
import ListTable from '../components/ListTable/ListTable'; import ListTable from '../../components/ListTable/ListTable';
import DetailDrawer from '../components/DetailDrawer/DetailDrawer'; import DetailDrawer from '../../components/DetailDrawer/DetailDrawer';
const { Option } = Select; const { Option } = Select;
const { Title, Text } = Typography; const { Title, Text } = Typography;
const ModelManage: React.FC = () => { interface ModelListProps {
type: 'llm' | 'asr';
}
const ModelList: React.FC<ModelListProps> = ({ type }) => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [data, setData] = useState<any[]>([]); const [data, setData] = useState<any[]>([]);
const [vendors, setVendors] = useState<any[]>([]); const [vendors, setVendors] = useState<any[]>([]);
const [activeTab, setActiveTab] = useState('llm');
const [isDrawerVisible, setIsDrawerVisible] = useState(false); const [isDrawerVisible, setIsDrawerVisible] = useState(false);
const [editingItem, setEditingItem] = useState<any>(null); const [editingItem, setEditingItem] = useState<any>(null);
const [testLoading, setTestLoading] = useState(false); const [testLoading, setTestLoading] = useState(false);
const [form] = Form.useForm(); const [form] = Form.useForm();
const fetchModels = async (type: string) => { const fetchModels = async () => {
setLoading(true); setLoading(true);
try { try {
const res = await api.listAIModels(type); const res = await api.listAIModels(type);
@ -54,8 +53,8 @@ const ModelManage: React.FC = () => {
}; };
useEffect(() => { useEffect(() => {
fetchModels(activeTab); fetchModels();
}, [activeTab]); }, [type]);
useEffect(() => { useEffect(() => {
fetchVendors(); fetchVendors();
@ -65,10 +64,10 @@ const ModelManage: React.FC = () => {
setEditingItem(null); setEditingItem(null);
form.resetFields(); form.resetFields();
form.setFieldsValue({ form.setFieldsValue({
model_type: activeTab, model_type: type,
status: 1, status: 1,
is_default: data.length === 0, is_default: data.length === 0,
api_path: activeTab === 'llm' ? '/chat/completions' : '', api_path: type === 'llm' ? '/chat/completions' : '',
temperature: 0.7, temperature: 0.7,
top_p: 0.9 top_p: 0.9
}); });
@ -93,7 +92,7 @@ const ModelManage: React.FC = () => {
onOk: async () => { onOk: async () => {
await api.deleteAIModel(id); await api.deleteAIModel(id);
message.success('已删除'); message.success('已删除');
fetchModels(activeTab); fetchModels();
}, },
}); });
}; };
@ -102,7 +101,7 @@ const ModelManage: React.FC = () => {
try { try {
await api.updateAIModel(record.model_id, { ...record, is_default: true }); await api.updateAIModel(record.model_id, { ...record, is_default: true });
message.success(`${record.model_name} 已设为默认`); message.success(`${record.model_name} 已设为默认`);
fetchModels(activeTab); fetchModels();
} catch (e) { } catch (e) {
message.error('设置失败'); message.error('设置失败');
} }
@ -112,8 +111,8 @@ const ModelManage: React.FC = () => {
const values = await form.validateFields(); const values = await form.validateFields();
const payload = { const payload = {
...values, ...values,
model_type: activeTab, model_type: type,
config: activeTab === 'llm' ? { config: type === 'llm' ? {
temperature: values.temperature, temperature: values.temperature,
top_p: values.top_p top_p: values.top_p
} : {} } : {}
@ -126,7 +125,7 @@ const ModelManage: React.FC = () => {
await api.createAIModel(payload); await api.createAIModel(payload);
} }
setIsDrawerVisible(false); setIsDrawerVisible(false);
fetchModels(activeTab); fetchModels();
message.success('配置已保存'); message.success('配置已保存');
} catch (e: any) { } catch (e: any) {
message.error('保存失败: ' + e.message); message.error('保存失败: ' + e.message);
@ -192,12 +191,12 @@ const ModelManage: React.FC = () => {
<Form form={form} layout="vertical" style={{ padding: '0 8px' }}> <Form form={form} layout="vertical" style={{ padding: '0 8px' }}>
<div style={{ marginBottom: 24 }}> <div style={{ marginBottom: 24 }}>
<Space size="middle"> <Space size="middle">
<div style={{ background: activeTab === 'llm' ? '#e6f7ff' : '#fff7e6', padding: '12px', borderRadius: '8px' }}> <div style={{ background: type === 'llm' ? '#e6f7ff' : '#fff7e6', padding: '12px', borderRadius: '8px' }}>
{activeTab === 'llm' ? <RobotOutlined style={{ fontSize: '24px', color: '#1890ff' }} /> : <AudioOutlined style={{ fontSize: '24px', color: '#fa8c16' }} />} {type === 'llm' ? <RobotOutlined style={{ fontSize: '24px', color: '#1890ff' }} /> : <AudioOutlined style={{ fontSize: '24px', color: '#fa8c16' }} />}
</div> </div>
<div> <div>
<Title level={4} style={{ margin: 0 }}>{activeTab === 'llm' ? 'AI 总结模型配置' : '语音识别 (ASR) 配置'}</Title> <Title level={4} style={{ margin: 0 }}>{type === 'llm' ? 'AI 总结模型配置' : '语音识别 (ASR) 配置'}</Title>
<Text type="secondary">{activeTab === 'llm' ? '选择用于生成会议纪要的大语言模型' : '转录模型与参数配置'}</Text> <Text type="secondary">{type === 'llm' ? '选择用于生成会议纪要的大语言模型' : '转录模型与参数配置'}</Text>
</div> </div>
</Space> </Space>
</div> </div>
@ -227,7 +226,7 @@ const ModelManage: React.FC = () => {
<Input placeholder="https://api.example.com/v1" size="large" /> <Input placeholder="https://api.example.com/v1" size="large" />
</Form.Item> </Form.Item>
{activeTab === 'llm' && ( {type === 'llm' && (
<> <>
<Form.Item name="api_path" label="API PATH (OPTIONAL)"> <Form.Item name="api_path" label="API PATH (OPTIONAL)">
<Input placeholder="/chat/completions" size="large" /> <Input placeholder="/chat/completions" size="large" />
@ -258,7 +257,7 @@ const ModelManage: React.FC = () => {
</Form.Item> </Form.Item>
</Space> </Space>
{activeTab === 'llm' && ( {type === 'llm' && (
<div style={{ marginTop: 16 }}> <div style={{ marginTop: 16 }}>
<Button block size="large" icon={<CheckCircleOutlined />} onClick={handleTestConnection} loading={testLoading}> <Button block size="large" icon={<CheckCircleOutlined />} onClick={handleTestConnection} loading={testLoading}>
@ -268,56 +267,34 @@ const ModelManage: React.FC = () => {
</Form> </Form>
); );
return ( const getTitle = () => {
<div className="settings-container" style={{ padding: '24px', background: '#f5f7f9', minHeight: '100vh' }}> if (type === 'llm') {
<Card variant="borderless" styles={{ body: { padding: 0 } }} style={{ borderRadius: '12px', overflow: 'hidden' }}> return (
<Tabs <div>
activeKey={activeTab} <Title level={4} style={{margin: 0}}></Title>
onChange={setActiveTab} <Text type="secondary"> LLM </Text>
tabPosition="left" </div>
style={{ minHeight: 'calc(100vh - 100px)' }} );
className="system-settings-tabs" } else {
items={[ return (
{ key: 'general', label: <div style={{ padding: '8px 16px' }}><Space><SettingOutlined /></Space></div>, disabled: true }, <div>
{ <Title level={4} style={{margin: 0}}></Title>
key: 'llm', <Text type="secondary"> ASR </Text>
label: <div style={{ padding: '8px 16px' }}><Space><RobotOutlined />AI </Space></div>, </div>
children: ( );
<div style={{ padding: '40px' }}> }
<div style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> };
<div>
<Title level={4} style={{margin: 0}}></Title>
<Text type="secondary"> LLM </Text>
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}></Button>
</div>
<ListTable columns={columns} dataSource={data} rowKey="model_id" loading={loading} />
</div>
)
},
{
key: 'asr',
label: <div style={{ padding: '8px 16px' }}><Space><AudioOutlined /></Space></div>,
children: (
<div style={{ padding: '40px' }}>
<div style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Title level={4} style={{margin: 0}}></Title>
<Text type="secondary"> ASR </Text>
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}></Button>
</div>
<ListTable columns={columns} dataSource={data} rowKey="model_id" loading={loading} />
</div>
)
},
{ key: 'voiceprint', label: <div style={{ padding: '8px 16px' }}><Space><ThunderboltOutlined /></Space></div>, disabled: true },
{ key: 'security', label: <div style={{ padding: '8px 16px' }}><Space><SafetyCertificateOutlined /></Space></div>, disabled: true },
{ key: 'about', label: <div style={{ padding: '8px 16px' }}><Space><InfoCircleOutlined /></Space></div>, disabled: true },
]}
/>
</Card>
return (
<div style={{ padding: '40px' }}>
<div style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
{getTitle()}
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
{type === 'llm' ? '新增模型' : '新增配置'}
</Button>
</div>
<ListTable columns={columns} dataSource={data} rowKey="model_id" loading={loading} />
<DetailDrawer <DetailDrawer
visible={isDrawerVisible} visible={isDrawerVisible}
onClose={() => setIsDrawerVisible(false)} onClose={() => setIsDrawerVisible(false)}
@ -326,10 +303,10 @@ const ModelManage: React.FC = () => {
headerActions={[ headerActions={[
{ {
key: 'save', key: 'save',
label: activeTab === 'asr' ? '保存并重载 ASR' : '保存配置', label: type === 'asr' ? '保存并重载 ASR' : '保存配置',
type: 'primary', type: 'primary',
onClick: handleSave, onClick: handleSave,
style: activeTab === 'asr' ? { background: '#fa541c', borderColor: '#fa541c' } : {} style: type === 'asr' ? { background: '#fa541c', borderColor: '#fa541c' } : {}
} as any } as any
]} ]}
> >
@ -341,4 +318,4 @@ const ModelManage: React.FC = () => {
); );
}; };
export default ModelManage; export default ModelList;

View File

@ -1,8 +1,9 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; 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 { import {
PlusOutlined, EditOutlined, DeleteOutlined, AppstoreOutlined PlusOutlined, EditOutlined, DeleteOutlined, AppstoreOutlined,
CaretRightOutlined, CaretDownOutlined
} from "@ant-design/icons"; } from "@ant-design/icons";
import ListTable from "../components/ListTable/ListTable"; import ListTable from "../components/ListTable/ListTable";
import DetailDrawer from "../components/DetailDrawer/DetailDrawer"; import DetailDrawer from "../components/DetailDrawer/DetailDrawer";
@ -198,6 +199,24 @@ export default function PermissionTreePage() {
} }
], [perms]); ], [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 ( return (
<div className="page-wrapper" style={{ padding: 24 }}> <div className="page-wrapper" style={{ padding: 24 }}>
<PageHeader <PageHeader
@ -218,6 +237,24 @@ export default function PermissionTreePage() {
loading={loading} loading={loading}
pagination={false} pagination={false}
scroll={{ x: 1000 }} scroll={{ x: 1000 }}
expandable={{
expandIcon: ({ expanded, onExpand, record }) => {
if (record.children && record.children.length > 0) {
return expanded ? (
<CaretDownOutlined
onClick={e => onExpand(record, e)}
style={{ marginRight: 8, cursor: 'pointer', color: '#666', fontSize: '12px' }}
/>
) : (
<CaretRightOutlined
onClick={e => onExpand(record, e)}
style={{ marginRight: 8, cursor: 'pointer', color: '#666', fontSize: '12px' }}
/>
);
}
return <span style={{ marginRight: 8, display: 'inline-block', width: 14 }} />;
}
}}
/> />
</div> </div>
@ -232,8 +269,15 @@ export default function PermissionTreePage() {
width={450} width={450}
> >
<Form form={form} layout="vertical" style={{ padding: 24 }}> <Form form={form} layout="vertical" style={{ padding: 24 }}>
<Form.Item label="父级ID" name="parent_id"> <Form.Item label="上级菜单" name="parent_id">
<InputNumber disabled style={{ width: '100%' }} /> <TreeSelect
style={{ width: '100%' }}
dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
treeData={treeData}
placeholder="请选择上级菜单"
treeDefaultExpandAll
allowClear
/>
</Form.Item> </Form.Item>
<Form.Item label="权限名称" name="name" rules={[{ required: true, message: '请输入名称' }]}> <Form.Item label="权限名称" name="name" rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder="例如: 用户管理" /> <Input placeholder="例如: 用户管理" />

View File

@ -217,7 +217,7 @@ const PromptManage: React.FC = () => {
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
<Form.Item name="name" label="模板名称" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="name" label="模板名称" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="category" label="分类" rules={[{ required: true }]}> <Form.Item name="category" label="分类" rules={[{ required: true }]}>
<Select>{categories.map(c => <Option key={c.id} value={c.item_value}>{c.item_label}</Option>)}</Select> <Select>{categories.map(c => <Option key={c.item_value} value={c.item_value}>{c.item_label}</Option>)}</Select>
</Form.Item> </Form.Item>
<Form.Item name="content" label="Prompt 内容" rules={[{ required: true }]}><Input.TextArea rows={12} /></Form.Item> <Form.Item name="content" label="Prompt 内容" rules={[{ required: true }]}><Input.TextArea rows={12} /></Form.Item>
<Form.Item name="description" label="详细描述"><Input.TextArea rows={3} /></Form.Item> <Form.Item name="description" label="详细描述"><Input.TextArea rows={3} /></Form.Item>

View File

@ -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<any[]>([]);
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<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const timerRef = useRef<any>(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) => <Tag color="green">{s}</Tag> },
{
title: '操作',
key: 'action',
render: (_: any, record: any) => (
<Button type="link" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}>
</Button>
),
},
];
return (
<div style={{ padding: 24 }}>
<PageHeader
title="声纹档案"
description="管理说话人声纹数据,支持注册与验证"
extra={
<Space>
<Button onClick={() => setVerifyModalVisible(true)}></Button>
<Button type="primary" icon={<UserAddOutlined />} onClick={() => setModalVisible(true)}>
</Button>
</Space>
}
/>
<Card variant="borderless">
<Table
columns={columns}
dataSource={speakers}
rowKey="id"
loading={loading}
/>
</Card>
<Modal
title="注册新声纹"
open={modalVisible}
onCancel={() => setModalVisible(false)}
onOk={handleEnroll}
confirmLoading={loading}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
<Input placeholder="请输入姓名" />
</Form.Item>
<Form.Item label="语音采集">
<div style={{ textAlign: 'center', padding: 20, border: '1px dashed #d9d9d9', borderRadius: 8 }}>
{!recording ? (
<Button type="primary" shape="circle" icon={<AudioOutlined />} size="large" onClick={startRecording} />
) : (
<Button type="primary" danger shape="circle" icon={<CheckCircleOutlined />} size="large" onClick={stopRecording} />
)}
<div style={{ marginTop: 10 }}>
{recording ? `正在录音... ${recordingTime}s` : (chunksRef.current.length > 0 ? '录音完成' : '点击开始录音')}
</div>
</div>
</Form.Item>
</Form>
</Modal>
<Modal
title="声纹验证"
open={verifyModalVisible}
footer={null}
onCancel={() => setVerifyModalVisible(false)}
>
<div style={{ textAlign: 'center', padding: 20 }}>
<p></p>
{!recording ? (
<Button type="primary" shape="circle" icon={<AudioOutlined />} size="large" onClick={startRecording} />
) : (
<Button type="primary" danger shape="circle" icon={<CheckCircleOutlined />} size="large" onClick={async () => {
stopRecording();
// Small delay to ensure tracks stop
setTimeout(async () => {
if (chunksRef.current.length > 0) {
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
const file = new File([blob], 'verify.webm', { type: 'audio/webm' });
const formData = new FormData();
formData.append('file', file);
try {
const res: any = await api.verifySpeaker(formData);
Modal.success({
title: '验证结果',
content: `识别结果: ${res.name} (置信度: ${res.score})`,
});
} catch (e) {
message.error('验证失败');
}
}
}, 200);
}} />
)}
<div style={{ marginTop: 10 }}>
{recording ? `正在录音... ${recordingTime}s` : '点击录音验证'}
</div>
</div>
</Modal>
</div>
);
};
export default VoiceProfile;

View File

@ -11,11 +11,15 @@ import PermissionTreePage from "./pages/PermissionTree";
import LogManagePage from "./pages/LogManage"; import LogManagePage from "./pages/LogManage";
import PromptManagePage from "./pages/PromptManage"; import PromptManagePage from "./pages/PromptManage";
import GlobalPromptManagePage from "./pages/GlobalPromptManage"; 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 HistoryMeetingPage from "./pages/HistoryMeeting";
import MeetingDetailPage from "./pages/MeetingDetail"; import MeetingDetailPage from "./pages/MeetingDetail";
import MeetingReportPage from "./pages/MeetingReport"; import MeetingReportPage from "./pages/MeetingReport";
import TaskMonitorPage from "./pages/TaskMonitor"; 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 AppLayout from "./layout/AppLayout";
import { getAccessToken } from "./auth"; import { getAccessToken } from "./auth";
@ -45,8 +49,19 @@ export const router = createBrowserRouter([
{ path: "system/logs", element: <LogManagePage /> }, { path: "system/logs", element: <LogManagePage /> },
{ path: "system/tasks", element: <TaskMonitorPage /> }, { path: "system/tasks", element: <TaskMonitorPage /> },
{ path: "system/prompts", element: <GlobalPromptManagePage /> }, { path: "system/prompts", element: <GlobalPromptManagePage /> },
{ path: "system/model", element: <ModelManagePage /> }, {
path: "system/model",
element: <ModelSettingsLayout />,
children: [
{ index: true, element: <Navigate to="llm" replace /> },
{ path: "llm", element: <ModelList type="llm" /> },
{ path: "asr", element: <ModelList type="asr" /> },
],
},
{ path: "meeting/history", element: <HistoryMeetingPage /> }, { path: "meeting/history", element: <HistoryMeetingPage /> },
{ path: "meeting/new", element: <MeetingLivePage /> },
{ path: "ai-agent/hotwords", element: <HotwordsPage /> },
{ path: "setting/voice", element: <VoiceProfilePage /> },
{ path: "meeting/history/:id", element: <MeetingDetailPage /> }, { path: "meeting/history/:id", element: <MeetingDetailPage /> },
{ path: "meeting/report/:id", element: <MeetingReportPage /> }, { path: "meeting/report/:id", element: <MeetingReportPage /> },
{ path: "setting/prompts", element: <PromptManagePage /> }, { path: "setting/prompts", element: <PromptManagePage /> },

View File

@ -13,7 +13,10 @@ import {
GlobalOutlined, InsuranceOutlined, MedicineBoxOutlined, GlobalOutlined, InsuranceOutlined, MedicineBoxOutlined,
ProjectOutlined, ReadOutlined, RocketOutlined, ProjectOutlined, ReadOutlined, RocketOutlined,
SaveOutlined, ShopOutlined, ShoppingOutlined, SaveOutlined, ShopOutlined, ShoppingOutlined,
TagOutlined, TrophyOutlined, VideoCameraOutlined TagOutlined, TrophyOutlined, VideoCameraOutlined,
AudioOutlined, UnorderedListOutlined,
BulbOutlined, PartitionOutlined, RobotOutlined,
LaptopOutlined, HistoryOutlined, FileSearchOutlined
} from "@ant-design/icons"; } from "@ant-design/icons";
export const ICON_MAP: Record<string, React.ReactNode> = { export const ICON_MAP: Record<string, React.ReactNode> = {
@ -58,6 +61,21 @@ export const ICON_MAP: Record<string, React.ReactNode> = {
'tag': <TagOutlined />, 'tag': <TagOutlined />,
'trophy': <TrophyOutlined />, 'trophy': <TrophyOutlined />,
'video': <VideoCameraOutlined />, 'video': <VideoCameraOutlined />,
'audio': <AudioOutlined />,
'list': <UnorderedListOutlined />,
// DB Icon Mappings
'strategy': <BulbOutlined />,
'workspace': <PartitionOutlined />,
'ai': <RobotOutlined />,
'system': <SettingOutlined />, // Reusing setting or using another
'kb': <BookOutlined />,
'insight': <EyeOutlined />,
'meeting': <VideoCameraOutlined />,
'template': <FileTextOutlined />,
'history': <HistoryOutlined />,
'hot': <FireOutlined />,
'voice': <AudioOutlined />,
}; };
export const ICON_LIST = Object.keys(ICON_MAP).map(key => ({ export const ICON_LIST = Object.keys(ICON_MAP).map(key => ({

1
frontend/src/vite-env.d.ts vendored 100644
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -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"}

View File

@ -6,6 +6,13 @@ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
port: 5173, port: 5173,
host: "0.0.0.0",
proxy: {
"/api": {
target: "http://localhost:8001",
changeOrigin: true,
},
},
}, },
resolve: { resolve: {
alias: { alias: {

View File

@ -193,7 +193,7 @@
dependencies: dependencies:
"@babel/helper-plugin-utils" "^7.27.1" "@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" version "7.28.6"
resolved "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.28.6.tgz" resolved "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.28.6.tgz"
integrity sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA== 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" resolved "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz"
integrity sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w== 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: html-url-attributes@^3.0.0:
version "3.0.1" version "3.0.1"
resolved "https://registry.npmmirror.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz" 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" domutils "^3.1.0"
entities "^4.5.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: iconv-lite@^0.6.3, iconv-lite@0.6, iconv-lite@0.6.3:
version "0.6.3" version "0.6.3"
resolved "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz" 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" normalize-wheel "^1.0.1"
tslib "^2.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: react-is@^18.2.0:
version "18.3.1" version "18.3.1"
resolved "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz" resolved "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz"
@ -2561,7 +2584,7 @@ react-router@6.30.3:
dependencies: dependencies:
"@remix-run/router" "1.23.2" "@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" version "18.3.1"
resolved "https://registry.npmmirror.com/react/-/react-18.3.1.tgz" resolved "https://registry.npmmirror.com/react/-/react-18.3.1.tgz"
integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== 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" resolved "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
typescript@^5.6.3: typescript@^5, typescript@^5.6.3:
version "5.9.3" version "5.9.3"
resolved "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz" resolved "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz"
integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
@ -2979,6 +3002,11 @@ update-browserslist-db@^1.2.0:
escalade "^3.2.0" escalade "^3.2.0"
picocolors "^1.1.1" 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: vfile-location@^5.0.0:
version "5.0.3" version "5.0.3"
resolved "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz" resolved "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz"
@ -3014,6 +3042,11 @@ vfile@^6.0.0:
optionalDependencies: optionalDependencies:
fsevents "~2.3.3" 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: web-namespaces@^2.0.0:
version "2.0.1" version "2.0.1"
resolved "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz" resolved "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz"

73
start.sh 100755
View File

@ -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