更新相关错误逻辑
parent
7f623d3e9f
commit
3738e14716
|
|
@ -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"}
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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"])
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()}`);
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
@ -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,6 +64,17 @@ 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) {
|
||||||
|
|
@ -102,76 +115,82 @@ 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">
|
||||||
|
{logo ? (
|
||||||
|
logo
|
||||||
|
) : (
|
||||||
<div className="logo-container">
|
<div className="logo-container">
|
||||||
<div className="nexdocus-logo">
|
<div className="nexdocus-logo">
|
||||||
<div className="logo-icon-wrapper">
|
<div className="logo-icon-wrapper">
|
||||||
<FileTextOutlined className="logo-file-icon" />
|
<FileTextOutlined className="logo-file-icon" />
|
||||||
<div className="logo-badge">N</div>
|
<div className="logo-badge">AI</div>
|
||||||
</div>
|
</div>
|
||||||
{!collapsed && <span className="logo-text">{platformName}</span>}
|
{!collapsed && <span className="logo-text">{platformName}</span>}
|
||||||
</div>
|
</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 ? (
|
||||||
|
<Tooltip title={user?.name} placement="right">
|
||||||
|
<div className="user-profile-card collapsed" onClick={onProfileClick}>
|
||||||
|
<Avatar src={user?.avatar} icon={<UserOutlined />} style={{ backgroundColor: token.colorPrimary }} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<div className="user-profile-card" onClick={onProfileClick}>
|
||||||
|
<Avatar src={user?.avatar} icon={<UserOutlined />} style={{ backgroundColor: token.colorPrimary }} />
|
||||||
|
<div className="user-info">
|
||||||
|
<div className="user-name">{user?.name}</div>
|
||||||
|
<div className="user-role">{user?.role}</div>
|
||||||
|
</div>
|
||||||
|
{onLogout && (
|
||||||
|
<Tooltip title="退出登录">
|
||||||
<div
|
<div
|
||||||
className="user-info"
|
className="logout-btn"
|
||||||
onClick={onProfileClick}
|
onClick={(e) => {
|
||||||
style={{ cursor: onProfileClick ? 'pointer' : 'default' }}
|
e.stopPropagation();
|
||||||
|
onLogout();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Avatar
|
|
||||||
size={collapsed ? 32 : 40}
|
|
||||||
src={user?.avatar}
|
|
||||||
style={{ backgroundColor: '#1677ff' }}
|
|
||||||
>
|
|
||||||
{user?.name?.[0]?.toUpperCase() || 'U'}
|
|
||||||
</Avatar>
|
|
||||||
{!collapsed && (
|
|
||||||
<div className="user-details">
|
|
||||||
<div className="user-name">{user?.name || 'User'}</div>
|
|
||||||
<div className="user-role">{user?.role || 'Admin'}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{!collapsed && (
|
|
||||||
<div className="logout-btn" onClick={onLogout} title="退出登录">
|
|
||||||
<LogoutOutlined />
|
<LogoutOutlined />
|
||||||
</div>
|
</div>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Sider>
|
</Sider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 || {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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),
|
|
||||||
path: child.path || ""
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
}, [menus]);
|
|
||||||
|
|
||||||
const activeKey = useMemo(() => {
|
// Example codes: 'dashboard', 'workspace', 'system'
|
||||||
return location.pathname;
|
// Map code to translation key
|
||||||
}, [location.pathname]);
|
const codeMap: Record<string, string> = {
|
||||||
|
'home': 'menu.home',
|
||||||
const onNavigate = (key: string, item: SidebarItem) => {
|
'dashboard': 'menu.dashboard',
|
||||||
if (item.path) {
|
'workspace': 'menu.workspace',
|
||||||
navigate(item.path);
|
'meeting.realtime': 'menu.realtimeMeeting',
|
||||||
} else {
|
'meeting.history': 'menu.historyMeeting',
|
||||||
navigate(key);
|
'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 handleLogout = async () => {
|
if (codeMap[code]) {
|
||||||
const token = getRefreshToken();
|
return t(codeMap[code]);
|
||||||
if (token) {
|
|
||||||
try {
|
|
||||||
await api.logout(token);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const menuGroups: SidebarGroup[] = useMemo(() => {
|
||||||
|
return menus.map((group) => ({
|
||||||
|
title: translateMenuName(group.name, group.code),
|
||||||
|
items: (group.children || []).map((child) => ({
|
||||||
|
key: child.path || child.code,
|
||||||
|
label: translateMenuName(child.name, child.code),
|
||||||
|
icon: getIcon(child.icon),
|
||||||
|
path: child.path
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
}, [menus, t]);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
clearTokens();
|
clearTokens();
|
||||||
navigate("/login");
|
navigate("/login");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout className="app-layout-root">
|
<Layout style={{ minHeight: "100vh" }}>
|
||||||
{/* Sidebar on the left, full height */}
|
|
||||||
<ModernSidebar
|
<ModernSidebar
|
||||||
collapsed={collapsed}
|
|
||||||
onCollapse={setCollapsed}
|
|
||||||
logo={null}
|
|
||||||
platformName={platformName}
|
platformName={platformName}
|
||||||
menuGroups={menuGroups}
|
|
||||||
activeKey={activeKey}
|
|
||||||
onNavigate={onNavigate}
|
|
||||||
user={{
|
user={{
|
||||||
name: displayName,
|
name: displayName,
|
||||||
role: username,
|
role: userRole,
|
||||||
avatar: resolveUrl(avatar)
|
avatar: avatar || undefined
|
||||||
}}
|
}}
|
||||||
|
menuGroups={menuGroups}
|
||||||
|
collapsed={collapsed}
|
||||||
|
onCollapse={setCollapsed}
|
||||||
|
activeKey={location.pathname}
|
||||||
|
onNavigate={(key) => navigate(key)}
|
||||||
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={{
|
||||||
|
margin: '24px 16px',
|
||||||
|
padding: 24,
|
||||||
|
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 />
|
<Outlet />
|
||||||
</Content>
|
</Layout.Content>
|
||||||
</Layout>
|
|
||||||
</Layout>
|
</Layout>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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": "模型管理"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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' ? (
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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,55 +267,33 @@ const ModelManage: React.FC = () => {
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getTitle = () => {
|
||||||
|
if (type === 'llm') {
|
||||||
return (
|
return (
|
||||||
<div className="settings-container" style={{ padding: '24px', background: '#f5f7f9', minHeight: '100vh' }}>
|
|
||||||
<Card variant="borderless" styles={{ body: { padding: 0 } }} style={{ borderRadius: '12px', overflow: 'hidden' }}>
|
|
||||||
<Tabs
|
|
||||||
activeKey={activeTab}
|
|
||||||
onChange={setActiveTab}
|
|
||||||
tabPosition="left"
|
|
||||||
style={{ minHeight: 'calc(100vh - 100px)' }}
|
|
||||||
className="system-settings-tabs"
|
|
||||||
items={[
|
|
||||||
{ key: 'general', label: <div style={{ padding: '8px 16px' }}><Space><SettingOutlined />通用设置</Space></div>, disabled: true },
|
|
||||||
{
|
|
||||||
key: 'llm',
|
|
||||||
label: <div style={{ padding: '8px 16px' }}><Space><RobotOutlined />AI 模型</Space></div>,
|
|
||||||
children: (
|
|
||||||
<div style={{ padding: '40px' }}>
|
|
||||||
<div style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<div>
|
<div>
|
||||||
<Title level={4} style={{margin: 0}}>大模型配置列表</Title>
|
<Title level={4} style={{margin: 0}}>大模型配置列表</Title>
|
||||||
<Text type="secondary">配置并管理用于会议总结的 LLM 服务</Text>
|
<Text type="secondary">配置并管理用于会议总结的 LLM 服务</Text>
|
||||||
</div>
|
</div>
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>新增模型</Button>
|
);
|
||||||
</div>
|
} else {
|
||||||
<ListTable columns={columns} dataSource={data} rowKey="model_id" loading={loading} />
|
return (
|
||||||
</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>
|
<div>
|
||||||
<Title level={4} style={{margin: 0}}>语音转译模型列表</Title>
|
<Title level={4} style={{margin: 0}}>语音转译模型列表</Title>
|
||||||
<Text type="secondary">管理用于音频转文字的 ASR 服务</Text>
|
<Text type="secondary">管理用于音频转文字的 ASR 服务</Text>
|
||||||
</div>
|
</div>
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>新增配置</Button>
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
</div>
|
||||||
<ListTable columns={columns} dataSource={data} rowKey="model_id" loading={loading} />
|
<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>
|
|
||||||
|
|
||||||
<DetailDrawer
|
<DetailDrawer
|
||||||
visible={isDrawerVisible}
|
visible={isDrawerVisible}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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="例如: 用户管理" />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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 /> },
|
||||||
|
|
|
||||||
|
|
@ -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 => ({
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue