feat: 收紧用户管理部门隔离并重构知识库共享权限
- 普通管理员创建用户时固定归属本部门,用户列表/访问选项/详情/更新/删除均限制在本部门 - share_config 改为三档:global(全局共享)、department(部门共享)、user(指定人可访问) - 部门共享必须包含当前用户部门,指定人可访问必须包含当前用户 - 前端 ShareConfigForm 重构为三档卡片选择+下拉菜单选人/选部门 - 补充部门隔离和共享权限过滤的集成测试
This commit is contained in:
parent
5693fdd4a7
commit
c342f3e1ee
@ -27,6 +27,6 @@
|
||||
- [ ] Config spacy model 的 load
|
||||
- [x] 在工作区的文件编辑的时候,保存和取消的按钮应该是悬浮在编辑框的右上角,而不是在 header 上面
|
||||
- [ ] default enable all build in tools / kbs / skills / mcps / subagents
|
||||
- [ ] 链接 Notion 和 feishu 目前来看,都是支持的
|
||||
- [ ] 知识库的权限调整,修改为三个等级,全局共享、部门共享(选择多个部门,默认是自己部门且必须包含自己部门)、指定人可访问(选择多个用户,默认是仅自己,可以添加其他人)。UI 上也需要调整,三个卡片不再是等宽,而是选中的会宽一点,并展示描述以及选择按钮,未选中的则是默认宽度,仅显示标题。对于选中的卡片除了展示描述、按钮之外,还包括“X 个部门可访问”、“X 个用户可访问”的信息展示。全局的就看是所有用户可访问。所以等级的字段配置也要重新设计,不需要考虑兼容,所有知识库都会重新构建。
|
||||
- [x] 链接 Notion 和 feishu 目前来看,都是支持的
|
||||
- [x] 知识库的权限调整,修改为三个等级,全局共享、部门共享(选择多个部门,默认是自己部门且必须包含自己部门)、指定人可访问(选择多个用户,默认是仅自己,可以添加其他人)。UI 上也需要调整,三个卡片不再是等宽,而是选中的会宽一点,并展示描述以及选择按钮,未选中的则是默认宽度,仅显示标题。对于选中的卡片除了展示描述、按钮之外,还包括“X 个部门可访问”、“X 个用户可访问”的信息展示。全局的就看是所有用户可访问。所以等级的字段配置也要重新设计,不需要考虑兼容,所有知识库都会重新构建。
|
||||
- [ ] databaseinfo 的重构,在左侧展示那个 tab 标签吧,将文件管理(filetable)以及右侧的那些图谱、检索、检索配置、评估之类的,都列为不同的 tab,进入之后默认激活的是 filetable。这样页面布局就好的多。作恶侧边栏除了这些 tab 之外,顶部是和 Skill Detail 那里的 header 一样,
|
||||
@ -9,6 +9,10 @@ from yuxi.utils import logger
|
||||
from yuxi.utils.datetime_utils import utc_isoformat
|
||||
|
||||
|
||||
DEFAULT_SHARE_CONFIG = {"access_level": "global", "department_ids": [], "user_uids": []}
|
||||
ACCESS_LEVELS = {"global", "department", "user"}
|
||||
|
||||
|
||||
class KnowledgeBaseManager:
|
||||
"""
|
||||
知识库管理器
|
||||
@ -150,6 +154,47 @@ class KnowledgeBaseManager:
|
||||
"""
|
||||
return await self._get_kb_for_database(db_id)
|
||||
|
||||
def _normalize_share_config(
|
||||
self,
|
||||
share_config: dict | None,
|
||||
*,
|
||||
user_uid: str | None = None,
|
||||
department_id: int | str | None = None,
|
||||
) -> dict:
|
||||
config = share_config or {}
|
||||
access_level = config.get("access_level") or "global"
|
||||
if access_level not in ACCESS_LEVELS:
|
||||
raise ValueError("无效的知识库权限等级")
|
||||
|
||||
if access_level == "global":
|
||||
return DEFAULT_SHARE_CONFIG.copy()
|
||||
|
||||
if access_level == "department":
|
||||
department_ids = self._normalize_department_ids(config.get("department_ids"))
|
||||
if department_id is not None:
|
||||
department_ids.append(int(department_id))
|
||||
department_ids = sorted(set(department_ids))
|
||||
if not department_ids:
|
||||
raise ValueError("部门共享至少需要选择一个部门")
|
||||
return {"access_level": "department", "department_ids": department_ids, "user_uids": []}
|
||||
|
||||
user_uids = self._normalize_user_uids(config.get("user_uids"))
|
||||
if user_uid:
|
||||
user_uids.append(str(user_uid))
|
||||
user_uids = sorted(set(user_uids))
|
||||
if not user_uids:
|
||||
raise ValueError("指定人可访问至少需要选择一个用户")
|
||||
return {"access_level": "user", "department_ids": [], "user_uids": user_uids}
|
||||
|
||||
def _normalize_department_ids(self, department_ids: list | None) -> list[int]:
|
||||
normalized = []
|
||||
for department_id in department_ids or []:
|
||||
normalized.append(int(department_id))
|
||||
return normalized
|
||||
|
||||
def _normalize_user_uids(self, user_uids: list | None) -> list[str]:
|
||||
return [uid for uid in (str(uid).strip() for uid in user_uids or []) if uid]
|
||||
|
||||
async def get_databases(self) -> dict:
|
||||
"""获取所有数据库信息"""
|
||||
from yuxi.repositories.knowledge_base_repository import KnowledgeBaseRepository
|
||||
@ -178,7 +223,7 @@ class KnowledgeBaseManager:
|
||||
continue
|
||||
|
||||
# 补充 share_config 和 additional_params
|
||||
db_info["share_config"] = row.share_config or {"is_shared": True, "accessible_departments": []}
|
||||
db_info["share_config"] = row.share_config or DEFAULT_SHARE_CONFIG.copy()
|
||||
db_info["additional_params"] = kb_instance.normalize_additional_params(row.additional_params)
|
||||
db_info["created_by"] = row.created_by
|
||||
all_databases.append(db_info)
|
||||
@ -205,28 +250,30 @@ class KnowledgeBaseManager:
|
||||
if kb is None:
|
||||
return False
|
||||
|
||||
share_config = kb.share_config or {}
|
||||
is_shared = share_config.get("is_shared", True)
|
||||
|
||||
# 如果是全员共享,则有权限
|
||||
if is_shared:
|
||||
user_uid = str(user.get("uid") or "")
|
||||
if user_uid and kb.created_by == user_uid:
|
||||
return True
|
||||
|
||||
# 检查部门权限
|
||||
user_department_id = user.get("department_id")
|
||||
accessible_departments = share_config.get("accessible_departments", [])
|
||||
share_config = kb.share_config or DEFAULT_SHARE_CONFIG.copy()
|
||||
access_level = share_config.get("access_level")
|
||||
|
||||
if user_department_id is None:
|
||||
return False
|
||||
if access_level == "global":
|
||||
return True
|
||||
|
||||
# 转换为整数进行比较(前端可能传递字符串,后端存储为整数)
|
||||
try:
|
||||
user_department_id = int(user_department_id)
|
||||
accessible_departments = [int(d) for d in accessible_departments]
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
if access_level == "department":
|
||||
user_department_id = user.get("department_id")
|
||||
if user_department_id is None:
|
||||
return False
|
||||
try:
|
||||
department_ids = [int(dept_id) for dept_id in share_config.get("department_ids") or []]
|
||||
return int(user_department_id) in department_ids
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
return user_department_id in accessible_departments
|
||||
if access_level == "user":
|
||||
return bool(user_uid and user_uid in (share_config.get("user_uids") or []))
|
||||
|
||||
return False
|
||||
|
||||
async def get_databases_by_uid(self, uid: str) -> dict:
|
||||
"""根据 uid 获取知识库列表"""
|
||||
@ -248,6 +295,7 @@ class KnowledgeBaseManager:
|
||||
user_info = user
|
||||
else:
|
||||
user_info = {
|
||||
"uid": user.uid,
|
||||
"role": user.role,
|
||||
"department_id": user.department_id,
|
||||
}
|
||||
@ -304,6 +352,7 @@ class KnowledgeBaseManager:
|
||||
llm_model_spec: str | None = None,
|
||||
share_config: dict | None = None,
|
||||
created_by: str | None = None,
|
||||
created_by_department_id: int | str | None = None,
|
||||
**kwargs,
|
||||
) -> dict:
|
||||
"""
|
||||
@ -317,6 +366,7 @@ class KnowledgeBaseManager:
|
||||
llm_model_spec: LLM 模型 spec
|
||||
share_config: 共享配置
|
||||
created_by: 创建者 uid
|
||||
created_by_department_id: 创建者部门 ID
|
||||
**kwargs: 其他配置参数
|
||||
|
||||
Returns:
|
||||
@ -330,9 +380,11 @@ class KnowledgeBaseManager:
|
||||
if await self.database_name_exists(database_name):
|
||||
raise ValueError(f"知识库名称 '{database_name}' 已存在,请使用其他名称")
|
||||
|
||||
# 默认共享配置
|
||||
if share_config is None:
|
||||
share_config = {"is_shared": True, "accessible_departments": []}
|
||||
share_config = self._normalize_share_config(
|
||||
share_config,
|
||||
user_uid=created_by,
|
||||
department_id=created_by_department_id,
|
||||
)
|
||||
|
||||
kb_instance = self._get_or_create_kb_instance(kb_type)
|
||||
kwargs = kb_instance.normalize_additional_params(kwargs)
|
||||
@ -444,7 +496,7 @@ class KnowledgeBaseManager:
|
||||
|
||||
# 添加数据库中的附加字段
|
||||
db_info["additional_params"] = kb_instance.normalize_additional_params(kb.additional_params)
|
||||
db_info["share_config"] = kb.share_config or {"is_shared": True, "accessible_departments": []}
|
||||
db_info["share_config"] = kb.share_config or DEFAULT_SHARE_CONFIG.copy()
|
||||
db_info["mindmap"] = kb.mindmap
|
||||
db_info["sample_questions"] = kb.sample_questions or []
|
||||
db_info["query_params"] = kb.query_params
|
||||
@ -622,6 +674,8 @@ class KnowledgeBaseManager:
|
||||
update_llm_model_spec: bool = False,
|
||||
additional_params: dict | None = None,
|
||||
share_config: dict | None = None,
|
||||
operator_uid: str | None = None,
|
||||
operator_department_id: int | str | None = None,
|
||||
) -> dict:
|
||||
"""更新数据库"""
|
||||
from yuxi.repositories.knowledge_base_repository import KnowledgeBaseRepository
|
||||
@ -655,7 +709,11 @@ class KnowledgeBaseManager:
|
||||
kb_instance.databases_meta[db_id]["metadata"] = merged_additional_params
|
||||
|
||||
if share_config is not None:
|
||||
update_data["share_config"] = share_config
|
||||
update_data["share_config"] = self._normalize_share_config(
|
||||
share_config,
|
||||
user_uid=operator_uid,
|
||||
department_id=operator_department_id,
|
||||
)
|
||||
|
||||
# 保存到数据库
|
||||
await kb_repo.update(db_id, update_data)
|
||||
|
||||
@ -87,6 +87,14 @@ class UserResponse(BaseModel):
|
||||
last_login: str | None = None
|
||||
|
||||
|
||||
class UserAccessOption(BaseModel):
|
||||
uid: str
|
||||
username: str
|
||||
role: str
|
||||
department_id: int | None = None
|
||||
department_name: str | None = None
|
||||
|
||||
|
||||
class InitializeAdmin(BaseModel):
|
||||
uid: str # 直接输入用户ID
|
||||
password: str
|
||||
@ -477,6 +485,11 @@ async def create_user(
|
||||
else:
|
||||
# 普通管理员创建用户时,自动继承该管理员的部门
|
||||
department_id = current_user.department_id
|
||||
if department_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="管理员必须属于部门才能创建用户",
|
||||
)
|
||||
# 非超级管理员不能指定部门
|
||||
if user_data.department_id is not None:
|
||||
raise HTTPException(
|
||||
@ -528,6 +541,41 @@ async def read_users(
|
||||
return users
|
||||
|
||||
|
||||
def _ensure_user_in_current_department(current_user: User, target_user: User) -> None:
|
||||
if current_user.role == "superadmin":
|
||||
return
|
||||
if target_user.department_id != current_user.department_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="只能管理本部门用户",
|
||||
)
|
||||
|
||||
|
||||
@auth.get("/users/access-options", response_model=list[UserAccessOption])
|
||||
async def read_user_access_options(
|
||||
skip: int = 0,
|
||||
limit: int = 1000,
|
||||
current_user: User = Depends(get_admin_user),
|
||||
):
|
||||
user_repo = UserRepository()
|
||||
if current_user.role == "superadmin":
|
||||
users_with_dept = await user_repo.list_with_department(skip=skip, limit=limit)
|
||||
else:
|
||||
users_with_dept = await user_repo.list_with_department(
|
||||
skip=skip, limit=limit, department_id=current_user.department_id
|
||||
)
|
||||
return [
|
||||
{
|
||||
"uid": user.uid,
|
||||
"username": user.username,
|
||||
"role": user.role,
|
||||
"department_id": user.department_id,
|
||||
"department_name": dept_name,
|
||||
}
|
||||
for user, dept_name in users_with_dept
|
||||
]
|
||||
|
||||
|
||||
# 路由:获取特定用户信息(管理员权限)
|
||||
@auth.get("/users/{user_id}", response_model=UserResponse)
|
||||
async def read_user(user_id: int, current_user: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
|
||||
@ -538,6 +586,7 @@ async def read_user(user_id: int, current_user: User = Depends(get_admin_user),
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="用户不存在",
|
||||
)
|
||||
_ensure_user_in_current_department(current_user, user)
|
||||
return user.to_dict()
|
||||
|
||||
|
||||
@ -570,6 +619,8 @@ async def update_user(
|
||||
detail="用户不存在",
|
||||
)
|
||||
|
||||
_ensure_user_in_current_department(current_user, user)
|
||||
|
||||
# 检查权限
|
||||
if user.role == "superadmin" and current_user.role != "superadmin":
|
||||
raise HTTPException(
|
||||
@ -584,6 +635,18 @@ async def update_user(
|
||||
detail="不能降级超级管理员账户",
|
||||
)
|
||||
|
||||
if current_user.role == "admin":
|
||||
if user.role != "user":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="管理员只能修改普通用户账户",
|
||||
)
|
||||
if user_data.role is not None and user_data.role != "user":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="管理员只能将用户角色设置为普通用户",
|
||||
)
|
||||
|
||||
# 更新信息
|
||||
update_details = []
|
||||
|
||||
@ -664,6 +727,8 @@ async def delete_user(
|
||||
detail="用户不存在",
|
||||
)
|
||||
|
||||
_ensure_user_in_current_department(current_user, user)
|
||||
|
||||
# 不能删除超级管理员账户
|
||||
if user.role == "superadmin":
|
||||
raise HTTPException(
|
||||
@ -671,6 +736,12 @@ async def delete_user(
|
||||
detail="不能删除超级管理员账户",
|
||||
)
|
||||
|
||||
if current_user.role == "admin" and user.role != "user":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="管理员只能删除普通用户账户",
|
||||
)
|
||||
|
||||
# 检查是否是部门的唯一管理员
|
||||
if user.role == "admin" and current_user.role != "superadmin":
|
||||
result = await db.execute(
|
||||
@ -849,7 +920,7 @@ async def impersonate_user(
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"uid": target_user.id,
|
||||
"user_id": target_user.id,
|
||||
"username": target_user.username,
|
||||
"uid": target_user.uid,
|
||||
"phone_number": target_user.phone_number,
|
||||
|
||||
@ -190,6 +190,7 @@ async def create_database(
|
||||
llm_model_spec=llm_model_spec,
|
||||
share_config=share_config,
|
||||
created_by=current_user.uid,
|
||||
created_by_department_id=current_user.department_id,
|
||||
**additional_params,
|
||||
)
|
||||
|
||||
@ -276,6 +277,8 @@ async def update_database_info(
|
||||
update_llm_model_spec=update_llm_model_spec,
|
||||
additional_params=additional_params,
|
||||
share_config=data.share_config,
|
||||
operator_uid=current_user.uid,
|
||||
operator_department_id=current_user.department_id,
|
||||
)
|
||||
return {"message": "更新成功", "database": database}
|
||||
except Exception as e:
|
||||
|
||||
@ -36,6 +36,7 @@ class UpdateWorkspaceFileContentRequest(BaseModel):
|
||||
async def _ensure_knowledge_read_access(current_user: User, db_id: str) -> None:
|
||||
allowed = await knowledge_base.check_accessible(
|
||||
{
|
||||
"uid": current_user.uid,
|
||||
"role": current_user.role,
|
||||
"department_id": current_user.department_id,
|
||||
},
|
||||
|
||||
@ -11,6 +11,68 @@ import pytest
|
||||
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
|
||||
|
||||
|
||||
async def _require_superadmin(test_client, headers):
|
||||
response = await test_client.get("/api/auth/me", headers=headers)
|
||||
assert response.status_code == 200, response.text
|
||||
if response.json()["role"] != "superadmin":
|
||||
pytest.fail("This test requires TEST_USERNAME to be a superadmin account.")
|
||||
|
||||
|
||||
async def _create_department_with_admin(test_client, headers, label: str) -> dict:
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
admin_uid = f"adm{label}_{suffix}"
|
||||
admin_password = f"Pw!{suffix}"
|
||||
response = await test_client.post(
|
||||
"/api/departments",
|
||||
json={
|
||||
"name": f"pytest_{label}_{suffix}",
|
||||
"description": "pytest managed department",
|
||||
"admin_uid": admin_uid,
|
||||
"admin_password": admin_password,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
|
||||
login_response = await test_client.post(
|
||||
"/api/auth/token",
|
||||
data={"username": admin_uid, "password": admin_password},
|
||||
)
|
||||
assert login_response.status_code == 200, login_response.text
|
||||
|
||||
login_payload = login_response.json()
|
||||
return {
|
||||
"department": response.json(),
|
||||
"admin_id": login_payload["user_id"],
|
||||
"admin_headers": {"Authorization": f"Bearer {login_payload['access_token']}"},
|
||||
}
|
||||
|
||||
|
||||
async def _create_user(test_client, headers, label: str, role: str = "user", department_id: int | None = None) -> dict:
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
payload = {
|
||||
"username": f"u{label}_{suffix}",
|
||||
"password": f"Pw!{suffix}",
|
||||
"role": role,
|
||||
}
|
||||
if department_id is not None:
|
||||
payload["department_id"] = department_id
|
||||
|
||||
response = await test_client.post("/api/auth/users", json=payload, headers=headers)
|
||||
assert response.status_code == 200, response.text
|
||||
return response.json()
|
||||
|
||||
|
||||
async def _cleanup_user(test_client, headers, user_id: int) -> None:
|
||||
response = await test_client.delete(f"/api/auth/users/{user_id}", headers=headers)
|
||||
assert response.status_code in {200, 404}, response.text
|
||||
|
||||
|
||||
async def _cleanup_department(test_client, headers, department_id: int) -> None:
|
||||
response = await test_client.delete(f"/api/departments/{department_id}", headers=headers)
|
||||
assert response.status_code in {200, 404}, response.text
|
||||
|
||||
|
||||
async def test_login_with_invalid_credentials(test_client):
|
||||
response = await test_client.post("/api/auth/token", data={"username": "invalid", "password": "invalid"})
|
||||
assert response.status_code == 401
|
||||
@ -25,9 +87,7 @@ async def test_user_is_locked_after_repeated_failed_logins(test_client, standard
|
||||
assert response.status_code == 401, response.text
|
||||
assert response.json()["detail"] == "用户名或密码错误"
|
||||
|
||||
locked_response = await test_client.post(
|
||||
"/api/auth/token", data={"username": uid, "password": "wrong-password"}
|
||||
)
|
||||
locked_response = await test_client.post("/api/auth/token", data={"username": uid, "password": "wrong-password"})
|
||||
assert locked_response.status_code == 423, locked_response.text
|
||||
assert "X-Lock-Remaining" in locked_response.headers
|
||||
assert "账户已被锁定" in locked_response.json()["detail"]
|
||||
@ -47,7 +107,7 @@ async def test_admin_can_login_and_fetch_profile(test_client, admin_headers):
|
||||
data = profile_response.json()
|
||||
assert data["role"] in {"admin", "superadmin"}
|
||||
assert data["username"]
|
||||
assert data["user_id"]
|
||||
assert data["id"]
|
||||
|
||||
|
||||
async def test_admin_can_create_and_delete_user(test_client, admin_headers):
|
||||
@ -71,6 +131,96 @@ async def test_admin_can_create_and_delete_user(test_client, admin_headers):
|
||||
assert delete_payload["message"] == "用户已删除"
|
||||
|
||||
|
||||
async def test_department_admin_is_limited_to_own_department_users(test_client, admin_headers):
|
||||
await _require_superadmin(test_client, admin_headers)
|
||||
|
||||
user_ids: list[int] = []
|
||||
admin_ids: list[int] = []
|
||||
department_ids: list[int] = []
|
||||
|
||||
try:
|
||||
dept_a = await _create_department_with_admin(test_client, admin_headers, "a")
|
||||
dept_b = await _create_department_with_admin(test_client, admin_headers, "b")
|
||||
department_a = dept_a["department"]
|
||||
department_b = dept_b["department"]
|
||||
department_ids.extend([department_a["id"], department_b["id"]])
|
||||
admin_ids.extend([dept_a["admin_id"], dept_b["admin_id"]])
|
||||
|
||||
user_a = await _create_user(test_client, dept_a["admin_headers"], "a")
|
||||
user_b = await _create_user(test_client, dept_b["admin_headers"], "b")
|
||||
superadmin_created_user = await _create_user(test_client, admin_headers, "s", department_id=department_b["id"])
|
||||
user_ids.extend([user_a["id"], user_b["id"], superadmin_created_user["id"]])
|
||||
|
||||
assert user_a["department_id"] == department_a["id"]
|
||||
assert superadmin_created_user["department_id"] == department_b["id"]
|
||||
|
||||
forbidden_create = await test_client.post(
|
||||
"/api/auth/users",
|
||||
json={
|
||||
"username": f"ux_{uuid.uuid4().hex[:8]}",
|
||||
"password": "routerTest123!",
|
||||
"role": "user",
|
||||
"department_id": department_b["id"],
|
||||
},
|
||||
headers=dept_a["admin_headers"],
|
||||
)
|
||||
assert forbidden_create.status_code == 403, forbidden_create.text
|
||||
|
||||
list_response = await test_client.get("/api/auth/users", headers=dept_a["admin_headers"])
|
||||
assert list_response.status_code == 200, list_response.text
|
||||
listed_users = list_response.json()
|
||||
listed_user_ids = {user["id"] for user in listed_users}
|
||||
assert user_a["id"] in listed_user_ids
|
||||
assert user_b["id"] not in listed_user_ids
|
||||
assert all(user["department_id"] == department_a["id"] for user in listed_users)
|
||||
|
||||
options_response = await test_client.get("/api/auth/users/access-options", headers=dept_a["admin_headers"])
|
||||
assert options_response.status_code == 200, options_response.text
|
||||
access_options = options_response.json()
|
||||
option_uids = {user["uid"] for user in access_options}
|
||||
assert user_a["uid"] in option_uids
|
||||
assert user_b["uid"] not in option_uids
|
||||
assert all(user["department_id"] == department_a["id"] for user in access_options)
|
||||
|
||||
superadmin_list_response = await test_client.get("/api/auth/users", headers=admin_headers)
|
||||
assert superadmin_list_response.status_code == 200, superadmin_list_response.text
|
||||
superadmin_user_ids = {user["id"] for user in superadmin_list_response.json()}
|
||||
assert user_a["id"] in superadmin_user_ids
|
||||
assert user_b["id"] in superadmin_user_ids
|
||||
|
||||
own_read = await test_client.get(f"/api/auth/users/{user_a['id']}", headers=dept_a["admin_headers"])
|
||||
assert own_read.status_code == 200, own_read.text
|
||||
|
||||
cross_read = await test_client.get(f"/api/auth/users/{user_b['id']}", headers=dept_a["admin_headers"])
|
||||
assert cross_read.status_code == 403, cross_read.text
|
||||
|
||||
cross_update = await test_client.put(
|
||||
f"/api/auth/users/{user_b['id']}",
|
||||
json={"username": f"ub_{uuid.uuid4().hex[:8]}"},
|
||||
headers=dept_a["admin_headers"],
|
||||
)
|
||||
assert cross_update.status_code == 403, cross_update.text
|
||||
|
||||
role_escalation = await test_client.put(
|
||||
f"/api/auth/users/{user_a['id']}", json={"role": "admin"}, headers=dept_a["admin_headers"]
|
||||
)
|
||||
assert role_escalation.status_code == 403, role_escalation.text
|
||||
|
||||
cross_delete = await test_client.delete(f"/api/auth/users/{user_b['id']}", headers=dept_a["admin_headers"])
|
||||
assert cross_delete.status_code == 403, cross_delete.text
|
||||
|
||||
own_delete = await test_client.delete(f"/api/auth/users/{user_a['id']}", headers=dept_a["admin_headers"])
|
||||
assert own_delete.status_code == 200, own_delete.text
|
||||
user_ids.remove(user_a["id"])
|
||||
finally:
|
||||
for user_id in user_ids:
|
||||
await _cleanup_user(test_client, admin_headers, user_id)
|
||||
for admin_id in admin_ids:
|
||||
await _cleanup_user(test_client, admin_headers, admin_id)
|
||||
for department_id in department_ids:
|
||||
await _cleanup_department(test_client, admin_headers, department_id)
|
||||
|
||||
|
||||
async def test_invalid_token_is_rejected(test_client):
|
||||
headers = {"Authorization": "Bearer not-a-real-token"}
|
||||
response = await test_client.get("/api/auth/me", headers=headers)
|
||||
|
||||
@ -20,6 +20,94 @@ def _assert_forbidden_response(response):
|
||||
assert isinstance(payload["detail"], str)
|
||||
|
||||
|
||||
async def _create_test_department(test_client, admin_headers, prefix="pytest_dept"):
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
admin_uid = f"deptadmin_{suffix}"
|
||||
response = await test_client.post(
|
||||
"/api/departments",
|
||||
json={
|
||||
"name": f"{prefix}_{suffix}",
|
||||
"description": "pytest department",
|
||||
"admin_uid": admin_uid,
|
||||
"admin_password": f"Pw!{suffix}",
|
||||
},
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
payload = response.json()
|
||||
payload["admin_uid"] = admin_uid
|
||||
return payload
|
||||
|
||||
|
||||
async def _create_test_user(test_client, admin_headers, department_id):
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
password = f"Pw!{suffix}"
|
||||
response = await test_client.post(
|
||||
"/api/auth/users",
|
||||
json={
|
||||
"username": f"pytest_user_{suffix}",
|
||||
"password": password,
|
||||
"role": "user",
|
||||
"department_id": department_id,
|
||||
},
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
user = response.json()
|
||||
|
||||
login_response = await test_client.post(
|
||||
"/api/auth/token",
|
||||
data={"username": user["uid"], "password": password},
|
||||
)
|
||||
assert login_response.status_code == 200, login_response.text
|
||||
return {"user": user, "headers": {"Authorization": f"Bearer {login_response.json()['access_token']}"}}
|
||||
|
||||
|
||||
async def _delete_user_by_id(test_client, admin_headers, user_id):
|
||||
response = await test_client.delete(f"/api/auth/users/{user_id}", headers=admin_headers)
|
||||
assert response.status_code in (200, 404), response.text
|
||||
|
||||
|
||||
async def _find_user_id_by_uid(test_client, admin_headers, uid):
|
||||
response = await test_client.get("/api/auth/users", headers=admin_headers)
|
||||
assert response.status_code == 200, response.text
|
||||
for user in response.json():
|
||||
if user["uid"] == uid:
|
||||
return user["id"]
|
||||
return None
|
||||
|
||||
|
||||
async def _delete_department_with_admin(test_client, admin_headers, department):
|
||||
admin_user_id = await _find_user_id_by_uid(test_client, admin_headers, department["admin_uid"])
|
||||
if admin_user_id:
|
||||
await _delete_user_by_id(test_client, admin_headers, admin_user_id)
|
||||
response = await test_client.delete(f"/api/departments/{department['id']}", headers=admin_headers)
|
||||
assert response.status_code in (200, 404), response.text
|
||||
|
||||
|
||||
async def _create_test_database(test_client, admin_headers, share_config=None):
|
||||
response = await test_client.post(
|
||||
"/api/knowledge/databases",
|
||||
json={
|
||||
"database_name": f"pytest_acl_{uuid.uuid4().hex[:8]}",
|
||||
"description": "Knowledge permission test",
|
||||
"embedding_model_spec": "siliconflow-cn:Pro/BAAI/bge-m3",
|
||||
"kb_type": "milvus",
|
||||
"additional_params": {},
|
||||
"share_config": share_config,
|
||||
},
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
return response.json()
|
||||
|
||||
|
||||
async def _accessible_db_ids(test_client, headers):
|
||||
response = await test_client.get("/api/knowledge/databases/accessible", headers=headers)
|
||||
assert response.status_code == 200, response.text
|
||||
return {item["db_id"] for item in response.json().get("databases", [])}
|
||||
|
||||
|
||||
async def test_admin_can_manage_knowledge_databases(test_client, admin_headers, knowledge_database):
|
||||
db_id = knowledge_database["db_id"]
|
||||
|
||||
@ -395,6 +483,96 @@ async def test_get_accessible_databases(test_client, admin_headers, knowledge_da
|
||||
assert knowledge_database["db_id"] in db_ids
|
||||
|
||||
|
||||
async def test_create_database_defaults_to_global_share_config(test_client, admin_headers):
|
||||
database = await _create_test_database(test_client, admin_headers)
|
||||
db_id = database["db_id"]
|
||||
try:
|
||||
assert database["share_config"] == {"access_level": "global", "department_ids": [], "user_uids": []}
|
||||
finally:
|
||||
await test_client.delete(f"/api/knowledge/databases/{db_id}", headers=admin_headers)
|
||||
|
||||
|
||||
async def test_department_share_config_filters_accessible_databases(test_client, admin_headers):
|
||||
department_a = await _create_test_department(test_client, admin_headers, "pytest_dept_a")
|
||||
department_b = await _create_test_department(test_client, admin_headers, "pytest_dept_b")
|
||||
user_a = user_b = None
|
||||
database = None
|
||||
|
||||
try:
|
||||
user_a = await _create_test_user(test_client, admin_headers, department_a["id"])
|
||||
user_b = await _create_test_user(test_client, admin_headers, department_b["id"])
|
||||
database = await _create_test_database(
|
||||
test_client,
|
||||
admin_headers,
|
||||
{"access_level": "department", "department_ids": [department_a["id"]], "user_uids": []},
|
||||
)
|
||||
|
||||
saved_config = database["share_config"]
|
||||
assert saved_config["access_level"] == "department"
|
||||
assert department_a["id"] in saved_config["department_ids"]
|
||||
|
||||
assert database["db_id"] in await _accessible_db_ids(test_client, user_a["headers"])
|
||||
assert database["db_id"] not in await _accessible_db_ids(test_client, user_b["headers"])
|
||||
finally:
|
||||
if database:
|
||||
await test_client.delete(f"/api/knowledge/databases/{database['db_id']}", headers=admin_headers)
|
||||
if user_a:
|
||||
await _delete_user_by_id(test_client, admin_headers, user_a["user"]["id"])
|
||||
if user_b:
|
||||
await _delete_user_by_id(test_client, admin_headers, user_b["user"]["id"])
|
||||
await _delete_department_with_admin(test_client, admin_headers, department_a)
|
||||
await _delete_department_with_admin(test_client, admin_headers, department_b)
|
||||
|
||||
|
||||
async def test_user_share_config_filters_accessible_databases(test_client, admin_headers):
|
||||
department_a = await _create_test_department(test_client, admin_headers, "pytest_dept_a")
|
||||
department_b = await _create_test_department(test_client, admin_headers, "pytest_dept_b")
|
||||
user_a = user_b = None
|
||||
database = None
|
||||
|
||||
try:
|
||||
user_a = await _create_test_user(test_client, admin_headers, department_a["id"])
|
||||
user_b = await _create_test_user(test_client, admin_headers, department_b["id"])
|
||||
database = await _create_test_database(
|
||||
test_client,
|
||||
admin_headers,
|
||||
{"access_level": "user", "department_ids": [], "user_uids": [user_a["user"]["uid"]]},
|
||||
)
|
||||
|
||||
saved_config = database["share_config"]
|
||||
assert saved_config["access_level"] == "user"
|
||||
assert user_a["user"]["uid"] in saved_config["user_uids"]
|
||||
|
||||
assert database["db_id"] in await _accessible_db_ids(test_client, user_a["headers"])
|
||||
assert database["db_id"] not in await _accessible_db_ids(test_client, user_b["headers"])
|
||||
finally:
|
||||
if database:
|
||||
await test_client.delete(f"/api/knowledge/databases/{database['db_id']}", headers=admin_headers)
|
||||
if user_a:
|
||||
await _delete_user_by_id(test_client, admin_headers, user_a["user"]["id"])
|
||||
if user_b:
|
||||
await _delete_user_by_id(test_client, admin_headers, user_b["user"]["id"])
|
||||
await _delete_department_with_admin(test_client, admin_headers, department_a)
|
||||
await _delete_department_with_admin(test_client, admin_headers, department_b)
|
||||
|
||||
|
||||
async def test_user_access_options_include_all_departments_for_admin(test_client, admin_headers):
|
||||
department = await _create_test_department(test_client, admin_headers, "pytest_access_options")
|
||||
user = None
|
||||
|
||||
try:
|
||||
user = await _create_test_user(test_client, admin_headers, department["id"])
|
||||
response = await test_client.get("/api/auth/users/access-options", headers=admin_headers)
|
||||
assert response.status_code == 200, response.text
|
||||
uids = {item["uid"] for item in response.json()}
|
||||
assert user["user"]["uid"] in uids
|
||||
assert department["admin_uid"] in uids
|
||||
finally:
|
||||
if user:
|
||||
await _delete_user_by_id(test_client, admin_headers, user["user"]["id"])
|
||||
await _delete_department_with_admin(test_client, admin_headers, department)
|
||||
|
||||
|
||||
async def test_get_knowledge_base_types(test_client, admin_headers):
|
||||
"""测试获取支持的知识库类型"""
|
||||
response = await test_client.get("/api/knowledge/types", headers=admin_headers)
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
* 认证相关 API
|
||||
*/
|
||||
|
||||
import { apiAdminGet } from './base'
|
||||
|
||||
async function parseErrorDetail(response, fallbackMessage) {
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
|
||||
@ -57,6 +59,10 @@ async function getOIDCLoginUrl(redirectPath = '/') {
|
||||
* department_name: string | null
|
||||
* }>}
|
||||
*/
|
||||
async function getUserAccessOptions() {
|
||||
return apiAdminGet('/api/auth/users/access-options')
|
||||
}
|
||||
|
||||
async function exchangeOIDCCode(code) {
|
||||
const response = await fetch('/api/auth/oidc/exchange-code', {
|
||||
method: 'POST',
|
||||
@ -77,5 +83,6 @@ async function exchangeOIDCCode(code) {
|
||||
export const authApi = {
|
||||
getOIDCConfig,
|
||||
getOIDCLoginUrl,
|
||||
getUserAccessOptions,
|
||||
exchangeOIDCCode
|
||||
}
|
||||
|
||||
@ -39,7 +39,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 编辑对话框 -->
|
||||
<a-modal v-model:open="editModalVisible" title="编辑知识库信息">
|
||||
<a-modal v-model:open="editModalVisible" title="编辑知识库信息" width="700px">
|
||||
<template #footer>
|
||||
<a-button danger @click="deleteDatabase" style="margin-right: auto; margin-left: 0">
|
||||
<template #icon>
|
||||
@ -140,11 +140,11 @@
|
||||
<!-- 非编辑状态下显示共享配置信息 -->
|
||||
<a-form-item v-else-if="database.share_config" label="共享设置" name="share_config_readonly">
|
||||
<div class="share-config-readonly">
|
||||
<a-tag :color="database.share_config.is_shared !== false ? 'green' : 'blue'">
|
||||
{{ database.share_config.is_shared !== false ? '全员共享' : '指定部门' }}
|
||||
<a-tag :color="shareConfigDisplay.color">
|
||||
{{ shareConfigDisplay.label }}
|
||||
</a-tag>
|
||||
<span v-if="database.share_config.is_shared === false" class="dept-names">
|
||||
{{ getAccessibleDeptNames() }}
|
||||
<span class="access-names">
|
||||
{{ shareConfigDisplay.detail }}
|
||||
</span>
|
||||
</div>
|
||||
</a-form-item>
|
||||
@ -162,6 +162,7 @@ import { message } from 'ant-design-vue'
|
||||
import { LeftOutlined, QuestionCircleOutlined } from '@ant-design/icons-vue'
|
||||
import { Pencil, Trash2, Copy } from 'lucide-vue-next'
|
||||
import { departmentApi } from '@/apis/department_api'
|
||||
import { authApi } from '@/apis/auth_api'
|
||||
import AiTextarea from '@/components/AiTextarea.vue'
|
||||
import ShareConfigForm from '@/components/ShareConfigForm.vue'
|
||||
|
||||
@ -174,10 +175,9 @@ const isDifyKb = computed(() => database.value?.kb_type === 'dify')
|
||||
const isNotionKb = computed(() => database.value?.kb_type === 'notion')
|
||||
const isReadOnlyConnector = computed(() => isDifyKb.value || isNotionKb.value)
|
||||
|
||||
// 部门列表(用于显示部门名称)
|
||||
const departments = ref([])
|
||||
const users = ref([])
|
||||
|
||||
// 加载部门列表
|
||||
const loadDepartments = async () => {
|
||||
try {
|
||||
const res = await departmentApi.getDepartments()
|
||||
@ -188,21 +188,57 @@ const loadDepartments = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化时加载部门
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
users.value = await authApi.getUserAccessOptions()
|
||||
} catch (e) {
|
||||
console.error('加载用户列表失败:', e)
|
||||
users.value = []
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDepartments()
|
||||
loadUsers()
|
||||
})
|
||||
|
||||
// 获取可访问的部门名称
|
||||
const getAccessibleDeptNames = () => {
|
||||
const deptIds = database.value?.share_config?.accessible_departments || []
|
||||
if (deptIds.length === 0) return '无'
|
||||
return deptIds
|
||||
.map((id) => {
|
||||
const dept = departments.value.find((d) => d.id === id)
|
||||
return dept?.name || `部门${id}`
|
||||
})
|
||||
.join('、')
|
||||
const shareConfigDisplay = computed(() => {
|
||||
const shareConfig = database.value?.share_config || { access_level: 'global' }
|
||||
if (shareConfig.access_level === 'department') {
|
||||
const departmentIds = shareConfig.department_ids || []
|
||||
const names = departmentIds.map((id) => getDepartmentName(id)).join('、') || '无'
|
||||
return {
|
||||
color: 'blue',
|
||||
label: '部门共享',
|
||||
detail: `${departmentIds.length} 个部门可访问:${names}`
|
||||
}
|
||||
}
|
||||
|
||||
if (shareConfig.access_level === 'user') {
|
||||
const userUids = shareConfig.user_uids || []
|
||||
const names = userUids.map((uid) => getUserName(uid)).join('、') || '无'
|
||||
return {
|
||||
color: 'purple',
|
||||
label: '指定人可访问',
|
||||
detail: `${userUids.length} 个用户可访问:${names}`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
color: 'green',
|
||||
label: '全局共享',
|
||||
detail: '所有用户可访问'
|
||||
}
|
||||
})
|
||||
|
||||
const getDepartmentName = (id) => {
|
||||
const dept = departments.value.find((item) => Number(item.id) === Number(id))
|
||||
return dept?.name || `部门${id}`
|
||||
}
|
||||
|
||||
const getUserName = (uid) => {
|
||||
const user = users.value.find((item) => item.uid === uid)
|
||||
return user?.username || uid
|
||||
}
|
||||
|
||||
// 是否可以编辑共享配置
|
||||
@ -306,30 +342,15 @@ const handleEditSubmit = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 从 ShareConfigForm 组件直接获取当前值
|
||||
let finalIsShared = true
|
||||
let finalDeptIds = []
|
||||
|
||||
if (shareConfigFormRef.value) {
|
||||
const formConfig = shareConfigFormRef.value.config
|
||||
finalIsShared = formConfig.is_shared
|
||||
finalDeptIds = formConfig.accessible_department_ids || []
|
||||
}
|
||||
|
||||
console.log(
|
||||
'[handleEditSubmit] 直接从组件获取 - is_shared:',
|
||||
finalIsShared,
|
||||
'dept_ids:',
|
||||
JSON.stringify(finalDeptIds)
|
||||
)
|
||||
|
||||
const formConfig = shareConfigFormRef.value?.config || { access_level: 'global' }
|
||||
const updateData = {
|
||||
name: editForm.name,
|
||||
description: editForm.description,
|
||||
additional_params: {},
|
||||
share_config: {
|
||||
is_shared: finalIsShared,
|
||||
accessible_departments: finalIsShared ? [] : finalDeptIds
|
||||
access_level: formConfig.access_level,
|
||||
department_ids: formConfig.access_level === 'department' ? formConfig.department_ids || [] : [],
|
||||
user_uids: formConfig.access_level === 'user' ? formConfig.user_uids || [] : []
|
||||
}
|
||||
}
|
||||
|
||||
@ -402,7 +423,7 @@ const deleteDatabase = () => {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.dept-names {
|
||||
.access-names {
|
||||
font-size: 13px;
|
||||
color: var(--gray-600);
|
||||
}
|
||||
|
||||
@ -1,68 +1,126 @@
|
||||
<template>
|
||||
<div class="share-config-form">
|
||||
<div class="share-config-content">
|
||||
<div class="share-mode-cards" role="radiogroup" aria-label="共享设置">
|
||||
<button
|
||||
v-for="option in shareModeOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
role="radio"
|
||||
class="share-mode-card"
|
||||
:class="{ active: config.is_shared === option.value }"
|
||||
:aria-checked="config.is_shared === option.value"
|
||||
:tabindex="config.is_shared === option.value ? 0 : -1"
|
||||
@click="setShareMode(option.value)"
|
||||
@keydown.enter.prevent="setShareMode(option.value)"
|
||||
@keydown.space.prevent="setShareMode(option.value)"
|
||||
>
|
||||
<div class="card-icon-wrapper" aria-hidden="true">
|
||||
<component :is="option.icon" class="card-icon" :size="20" />
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="share-mode-cards" :class="`active-${config.access_level}`" role="radiogroup" aria-label="共享设置">
|
||||
<div
|
||||
v-for="option in shareModeOptions"
|
||||
:key="option.value"
|
||||
role="radio"
|
||||
class="share-mode-card"
|
||||
:class="{ active: config.access_level === option.value }"
|
||||
:aria-checked="config.access_level === option.value"
|
||||
:tabindex="config.access_level === option.value ? 0 : -1"
|
||||
@click="setAccessLevel(option.value)"
|
||||
@keydown.enter.prevent="setAccessLevel(option.value)"
|
||||
@keydown.space.prevent="setAccessLevel(option.value)"
|
||||
>
|
||||
<div class="card-main">
|
||||
<div class="card-header">
|
||||
<div class="card-icon-wrapper" aria-hidden="true">
|
||||
<component :is="option.icon" class="card-icon" :size="20" />
|
||||
</div>
|
||||
<div class="card-title">{{ option.title }}</div>
|
||||
<div class="card-description">{{ option.description }}</div>
|
||||
<div
|
||||
v-if="config.access_level === option.value && option.value !== 'global'"
|
||||
class="card-action"
|
||||
@click.stop
|
||||
>
|
||||
<a-dropdown :trigger="['click']" placement="bottomRight" overlay-class-name="share-selection-popover">
|
||||
<a-button
|
||||
size="small"
|
||||
class="select-action lucide-icon-btn"
|
||||
:aria-label="option.value === 'department' ? '选择部门' : '选择用户'"
|
||||
>
|
||||
<UserPlus class="select-action-icon" :size="14" />
|
||||
<span class="access-count">{{ getAccessCount(option.value) }}</span>
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<div class="selection-dropdown" @mousedown.stop @click.stop>
|
||||
<div class="selection-dropdown-header">
|
||||
<div class="selection-dropdown-title">
|
||||
{{ option.value === 'department' ? '可访问部门' : '可访问用户' }}
|
||||
</div>
|
||||
<div class="selection-dropdown-subtitle">
|
||||
{{ getAccessSummary(option.value) }}
|
||||
</div>
|
||||
</div>
|
||||
<a-input
|
||||
v-model:value="selectionSearch[option.value]"
|
||||
size="small"
|
||||
allow-clear
|
||||
class="selection-search"
|
||||
:placeholder="option.value === 'department' ? '搜索部门' : '搜索用户'"
|
||||
@mousedown.stop
|
||||
@click.stop
|
||||
/>
|
||||
<div v-if="getSelectionOptions(option.value).length" class="selection-list">
|
||||
<div
|
||||
v-for="item in getSelectionOptions(option.value)"
|
||||
:key="item.value"
|
||||
role="checkbox"
|
||||
:aria-checked="isSelected(option.value, item.value)"
|
||||
:tabindex="item.disabled ? -1 : 0"
|
||||
class="selection-item"
|
||||
:class="{ selected: isSelected(option.value, item.value), locked: item.disabled }"
|
||||
@mousedown.stop
|
||||
@click.stop="!item.disabled && toggleSelection(option.value, item.value, !isSelected(option.value, item.value))"
|
||||
@keydown.enter.prevent="!item.disabled && toggleSelection(option.value, item.value, !isSelected(option.value, item.value))"
|
||||
@keydown.space.prevent="!item.disabled && toggleSelection(option.value, item.value, !isSelected(option.value, item.value))"
|
||||
>
|
||||
<span class="selection-item-content">
|
||||
<a-checkbox
|
||||
:checked="isSelected(option.value, item.value)"
|
||||
:disabled="item.disabled"
|
||||
@click.stop
|
||||
@change="toggleSelection(option.value, item.value, $event.target.checked)"
|
||||
/>
|
||||
<span class="selection-label">{{ item.label }}</span>
|
||||
</span>
|
||||
<span v-if="item.disabled" class="selection-required">必选</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="selection-empty">暂无可选项</div>
|
||||
</div>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="!config.is_shared" class="compact-dept-selection">
|
||||
<div class="dept-selection-header">
|
||||
<div class="dept-selection-label">可访问部门</div>
|
||||
<div class="dept-selection-hint">请选择可访问该知识库的部门</div>
|
||||
<div class="card-description">{{ option.description }}</div>
|
||||
</div>
|
||||
<a-select
|
||||
v-model:value="config.accessible_department_ids"
|
||||
mode="multiple"
|
||||
placeholder="请选择部门"
|
||||
size="small"
|
||||
class="full-width dept-selection-input"
|
||||
:options="departmentOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch, onMounted } from 'vue'
|
||||
import { Globe, Building2 } from 'lucide-vue-next'
|
||||
import { ref, reactive, computed, watch, onMounted, nextTick } from 'vue'
|
||||
import { Globe, Building2, Users, UserPlus } from 'lucide-vue-next'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { departmentApi } from '@/apis/department_api'
|
||||
import { authApi } from '@/apis/auth_api'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const departments = ref([])
|
||||
const users = ref([])
|
||||
const syncingFromProps = ref(false)
|
||||
|
||||
const shareModeOptions = [
|
||||
{
|
||||
value: true,
|
||||
title: '公开给团队',
|
||||
description: '团队内所有成员都可以访问这个知识库',
|
||||
value: 'global',
|
||||
title: '全局共享',
|
||||
description: '所有用户都可以访问',
|
||||
icon: Globe
|
||||
},
|
||||
{
|
||||
value: false,
|
||||
title: '仅指定部门',
|
||||
description: '只有选中的部门成员可以访问这个知识库',
|
||||
value: 'department',
|
||||
title: '部门共享',
|
||||
description: '选中的部门成员可以访问',
|
||||
icon: Building2
|
||||
},
|
||||
{
|
||||
value: 'user',
|
||||
title: '指定人可访问',
|
||||
description: '选中的用户可以访问',
|
||||
icon: Users
|
||||
}
|
||||
]
|
||||
|
||||
@ -71,11 +129,11 @@ const props = defineProps({
|
||||
type: Object,
|
||||
required: true,
|
||||
default: () => ({
|
||||
is_shared: true,
|
||||
accessible_department_ids: []
|
||||
access_level: 'global',
|
||||
department_ids: [],
|
||||
user_uids: []
|
||||
})
|
||||
},
|
||||
// 是否自动选中当前用户所在部门
|
||||
autoSelectUserDept: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
@ -84,145 +142,228 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
// 本地状态,直接从 props 初始化
|
||||
const config = reactive({
|
||||
is_shared: true,
|
||||
accessible_department_ids: []
|
||||
access_level: 'global',
|
||||
department_ids: [],
|
||||
user_uids: []
|
||||
})
|
||||
|
||||
// 初始化 config
|
||||
const initConfig = () => {
|
||||
// 后端返回的是 accessible_departments,前端使用 accessible_department_ids
|
||||
const sourceDepts =
|
||||
props.modelValue.accessible_department_ids ?? props.modelValue.accessible_departments ?? []
|
||||
config.is_shared = props.modelValue.is_shared ?? true
|
||||
config.accessible_department_ids = sourceDepts.map((id) => Number(id))
|
||||
console.log('[ShareConfigForm] initConfig:', JSON.stringify(config))
|
||||
}
|
||||
|
||||
// 只在组件挂载时初始化一次
|
||||
onMounted(() => {
|
||||
initConfig()
|
||||
const selectionSearch = reactive({
|
||||
department: '',
|
||||
user: ''
|
||||
})
|
||||
|
||||
// 监听本地 config 变化,同步到父组件
|
||||
watch(
|
||||
config,
|
||||
(newVal) => {
|
||||
console.log('[ShareConfigForm] config 变化,emit:', JSON.stringify(newVal))
|
||||
emit('update:modelValue', {
|
||||
is_shared: newVal.is_shared,
|
||||
accessible_department_ids: newVal.accessible_department_ids
|
||||
})
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
const currentDepartmentId = computed(() => {
|
||||
if (!userStore.departmentId) return null
|
||||
return Number(userStore.departmentId)
|
||||
})
|
||||
|
||||
const setShareMode = (isShared) => {
|
||||
if (config.is_shared === isShared) {
|
||||
return
|
||||
}
|
||||
config.is_shared = isShared
|
||||
}
|
||||
const currentUserUid = computed(() => userStore.uid || '')
|
||||
|
||||
// 监听共享模式变化
|
||||
watch(
|
||||
() => config.is_shared,
|
||||
(newVal) => {
|
||||
if (!newVal && props.autoSelectUserDept && config.accessible_department_ids.length === 0) {
|
||||
// 切换到指定部门模式且未选中任何部门时,默认选中当前用户所在部门
|
||||
tryAutoSelectUserDept()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 尝试自动选中用户所在部门
|
||||
const tryAutoSelectUserDept = () => {
|
||||
const userDeptId = userStore.departmentId
|
||||
if (userDeptId) {
|
||||
const deptExists = departments.value.find((d) => d.id === userDeptId)
|
||||
if (deptExists) {
|
||||
// 确保存储为数字类型(a-select 返回的是字符串)
|
||||
config.accessible_department_ids = [Number(userDeptId)]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 监听用户部门变化(处理时序问题:departmentId 可能在组件 mounted 后才获取到)
|
||||
watch(
|
||||
() => userStore.departmentId,
|
||||
(newDeptId) => {
|
||||
if (
|
||||
props.autoSelectUserDept &&
|
||||
!config.is_shared &&
|
||||
config.accessible_department_ids.length === 0 &&
|
||||
newDeptId
|
||||
) {
|
||||
tryAutoSelectUserDept()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 部门选项
|
||||
const departmentOptions = computed(() =>
|
||||
departments.value.map((dept) => ({
|
||||
label: dept.name,
|
||||
value: Number(dept.id)
|
||||
departments.value.map((dept) => {
|
||||
const value = Number(dept.id)
|
||||
return {
|
||||
label: dept.name,
|
||||
value,
|
||||
disabled: value === currentDepartmentId.value
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const userOptions = computed(() =>
|
||||
users.value.map((user) => ({
|
||||
label: user.department_name ? `${user.username}(${user.department_name})` : user.username,
|
||||
value: user.uid,
|
||||
disabled: user.uid === currentUserUid.value
|
||||
}))
|
||||
)
|
||||
|
||||
// 加载部门列表
|
||||
const normalizeDepartmentIds = (ids) =>
|
||||
Array.from(new Set((ids || []).map((id) => Number(id)).filter((id) => Number.isFinite(id))))
|
||||
|
||||
const normalizeUserUids = (uids) =>
|
||||
Array.from(new Set((uids || []).map((uid) => String(uid).trim()).filter(Boolean)))
|
||||
|
||||
const ensureCurrentDepartment = () => {
|
||||
if (!props.autoSelectUserDept || !currentDepartmentId.value) return
|
||||
if (!config.department_ids.includes(currentDepartmentId.value)) {
|
||||
config.department_ids = [currentDepartmentId.value, ...config.department_ids]
|
||||
}
|
||||
}
|
||||
|
||||
const ensureCurrentUser = () => {
|
||||
if (!currentUserUid.value) return
|
||||
if (!config.user_uids.includes(currentUserUid.value)) {
|
||||
config.user_uids = [currentUserUid.value, ...config.user_uids]
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeActiveConfig = () => {
|
||||
if (config.access_level === 'global') {
|
||||
config.department_ids = []
|
||||
config.user_uids = []
|
||||
return
|
||||
}
|
||||
|
||||
if (config.access_level === 'department') {
|
||||
config.department_ids = normalizeDepartmentIds(config.department_ids)
|
||||
config.user_uids = []
|
||||
ensureCurrentDepartment()
|
||||
return
|
||||
}
|
||||
|
||||
config.department_ids = []
|
||||
config.user_uids = normalizeUserUids(config.user_uids)
|
||||
ensureCurrentUser()
|
||||
}
|
||||
|
||||
const initConfig = () => {
|
||||
syncingFromProps.value = true
|
||||
const accessLevel = ['global', 'department', 'user'].includes(props.modelValue?.access_level)
|
||||
? props.modelValue.access_level
|
||||
: 'global'
|
||||
config.access_level = accessLevel
|
||||
config.department_ids = normalizeDepartmentIds(props.modelValue?.department_ids)
|
||||
config.user_uids = normalizeUserUids(props.modelValue?.user_uids)
|
||||
normalizeActiveConfig()
|
||||
nextTick(() => {
|
||||
syncingFromProps.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const emitConfig = () => {
|
||||
emit('update:modelValue', {
|
||||
access_level: config.access_level,
|
||||
department_ids: config.access_level === 'department' ? normalizeDepartmentIds(config.department_ids) : [],
|
||||
user_uids: config.access_level === 'user' ? normalizeUserUids(config.user_uids) : []
|
||||
})
|
||||
}
|
||||
|
||||
const setAccessLevel = (accessLevel) => {
|
||||
if (config.access_level === accessLevel) return
|
||||
config.access_level = accessLevel
|
||||
normalizeActiveConfig()
|
||||
}
|
||||
|
||||
const getAccessSummary = (accessLevel) => {
|
||||
if (accessLevel === 'global') return '所有用户可访问'
|
||||
if (accessLevel === 'department') return `${config.department_ids.length} 个部门可访问`
|
||||
if (accessLevel === 'user' && config.user_uids.length === 1) return '仅自己可访问'
|
||||
return `${config.user_uids.length} 个用户可访问`
|
||||
}
|
||||
|
||||
const getAccessCount = (accessLevel) => {
|
||||
if (accessLevel === 'department') return config.department_ids.length
|
||||
if (accessLevel === 'user') return config.user_uids.length
|
||||
return ''
|
||||
}
|
||||
|
||||
const getSelectionOptions = (accessLevel) => {
|
||||
const options = accessLevel === 'department' ? departmentOptions.value : userOptions.value
|
||||
const query = selectionSearch[accessLevel]?.trim().toLowerCase()
|
||||
if (!query) return options
|
||||
return options.filter((item) => item.label.toLowerCase().includes(query))
|
||||
}
|
||||
|
||||
const isSelected = (accessLevel, value) => {
|
||||
if (accessLevel === 'department') return config.department_ids.includes(Number(value))
|
||||
if (accessLevel === 'user') return config.user_uids.includes(String(value))
|
||||
return false
|
||||
}
|
||||
|
||||
const toggleSelection = (accessLevel, value, checked) => {
|
||||
if (accessLevel === 'department') {
|
||||
const departmentId = Number(value)
|
||||
const selected = checked
|
||||
? [...config.department_ids, departmentId]
|
||||
: config.department_ids.filter((id) => id !== departmentId)
|
||||
config.department_ids = normalizeDepartmentIds(selected)
|
||||
ensureCurrentDepartment()
|
||||
return
|
||||
}
|
||||
|
||||
const uid = String(value)
|
||||
const selected = checked ? [...config.user_uids, uid] : config.user_uids.filter((item) => item !== uid)
|
||||
config.user_uids = normalizeUserUids(selected)
|
||||
ensureCurrentUser()
|
||||
}
|
||||
|
||||
const loadDepartments = async () => {
|
||||
try {
|
||||
const res = await departmentApi.getDepartments()
|
||||
departments.value = res.departments || res || []
|
||||
|
||||
// 如果需要,自动选中用户所在部门
|
||||
if (
|
||||
props.autoSelectUserDept &&
|
||||
!config.is_shared &&
|
||||
config.accessible_department_ids.length === 0
|
||||
) {
|
||||
tryAutoSelectUserDept()
|
||||
}
|
||||
if (config.access_level === 'department') ensureCurrentDepartment()
|
||||
} catch (e) {
|
||||
console.error('加载部门列表失败:', e)
|
||||
departments.value = []
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDepartments()
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
users.value = await authApi.getUserAccessOptions()
|
||||
if (config.access_level === 'user') ensureCurrentUser()
|
||||
} catch (e) {
|
||||
console.error('加载用户列表失败:', e)
|
||||
users.value = []
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => initConfig(),
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
config,
|
||||
() => {
|
||||
if (!syncingFromProps.value) emitConfig()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(currentDepartmentId, () => {
|
||||
if (config.access_level === 'department') ensureCurrentDepartment()
|
||||
})
|
||||
|
||||
watch(currentUserUid, () => {
|
||||
if (config.access_level === 'user') ensureCurrentUser()
|
||||
})
|
||||
|
||||
// 验证当前用户所在部门是否在可访问范围内
|
||||
// 返回 { valid: boolean, message: string }
|
||||
const validate = () => {
|
||||
// 全员共享模式不需要验证
|
||||
if (config.is_shared) {
|
||||
normalizeActiveConfig()
|
||||
|
||||
if (config.access_level === 'global') {
|
||||
return { valid: true, message: '' }
|
||||
}
|
||||
|
||||
// 指定部门模式,需要验证当前用户所在部门是否在列表中
|
||||
const userDeptId = userStore.departmentId
|
||||
if (!userDeptId) {
|
||||
return {
|
||||
valid: false,
|
||||
message: '您不属于任何部门,无法使用指定部门共享模式'
|
||||
if (config.access_level === 'department') {
|
||||
if (!currentDepartmentId.value) {
|
||||
return { valid: false, message: '您不属于任何部门,无法使用部门共享模式' }
|
||||
}
|
||||
if (!config.department_ids.includes(currentDepartmentId.value)) {
|
||||
return { valid: false, message: '您所在的部门必须在可访问部门范围内' }
|
||||
}
|
||||
return { valid: true, message: '' }
|
||||
}
|
||||
|
||||
if (!config.accessible_department_ids.includes(userDeptId)) {
|
||||
return {
|
||||
valid: false,
|
||||
message: '您所在的部门必须在可访问部门范围内'
|
||||
}
|
||||
if (!currentUserUid.value) {
|
||||
return { valid: false, message: '无法获取当前用户,无法使用指定人可访问模式' }
|
||||
}
|
||||
if (!config.user_uids.includes(currentUserUid.value)) {
|
||||
return { valid: false, message: '当前用户必须在可访问用户范围内' }
|
||||
}
|
||||
|
||||
return { valid: true, message: '' }
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
onMounted(() => {
|
||||
initConfig()
|
||||
loadDepartments()
|
||||
loadUsers()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
config,
|
||||
validate
|
||||
@ -231,150 +372,256 @@ defineExpose({
|
||||
|
||||
<style lang="less" scoped>
|
||||
.share-config-form {
|
||||
.share-config-content {
|
||||
.share-mode-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.share-mode-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 76px;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: 12px;
|
||||
background: var(--gray-0);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 180ms ease,
|
||||
background-color 180ms ease,
|
||||
box-shadow 180ms ease,
|
||||
transform 180ms ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
border-color: var(--main-color);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--main-20);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--main-color);
|
||||
background: linear-gradient(180deg, var(--main-10) 0%, var(--gray-0) 100%);
|
||||
box-shadow:
|
||||
0 0 0 1px var(--main-20),
|
||||
0 10px 24px rgb(0 0 0 / 6%);
|
||||
}
|
||||
}
|
||||
|
||||
.card-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.card-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.card-icon-wrapper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 10px;
|
||||
background: var(--gray-50);
|
||||
transition:
|
||||
background-color 180ms ease,
|
||||
box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.share-mode-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
.card-icon {
|
||||
color: var(--gray-500);
|
||||
transition: color 180ms ease;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.share-mode-card.active .card-icon-wrapper {
|
||||
background: var(--main-0);
|
||||
box-shadow: inset 0 0 0 1px var(--main-20);
|
||||
}
|
||||
|
||||
.share-mode-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 76px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: 10px;
|
||||
background: var(--gray-0);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
.share-mode-card.active .card-icon {
|
||||
color: var(--main-color);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
border-color: var(--main-color);
|
||||
}
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--gray-800);
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--main-20);
|
||||
}
|
||||
.card-description {
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--gray-600);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--main-color);
|
||||
background: var(--main-10);
|
||||
box-shadow: 0 0 0 1px var(--main-20);
|
||||
}
|
||||
.access-count {
|
||||
color: var(--main-color);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.card-icon-wrapper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 10px;
|
||||
background: var(--gray-50);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
.select-action {
|
||||
width: 44px;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
|
||||
.card-icon {
|
||||
color: var(--gray-500);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
color: var(--main-color);
|
||||
}
|
||||
|
||||
.card-content {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
flex: 1;
|
||||
}
|
||||
.select-action-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--gray-800);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
&.active .card-icon-wrapper {
|
||||
background: var(--main-0);
|
||||
}
|
||||
|
||||
&.active .card-icon {
|
||||
color: var(--main-color);
|
||||
}
|
||||
|
||||
.card-description {
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--gray-600);
|
||||
}
|
||||
}
|
||||
|
||||
.dept-selection {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.compact-dept-selection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--gray-150);
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(180deg, var(--gray-0) 0%, var(--gray-50) 100%);
|
||||
}
|
||||
|
||||
.dept-selection-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.dept-selection-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--gray-800);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.dept-selection-hint {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--gray-600);
|
||||
}
|
||||
|
||||
.dept-selection-input {
|
||||
:deep(.ant-select-selector) {
|
||||
min-height: 36px;
|
||||
padding: 3px 8px !important;
|
||||
border-radius: 8px !important;
|
||||
border-color: var(--gray-200) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
&:deep(.ant-select-focused .ant-select-selector),
|
||||
&:deep(.ant-select-selector:hover) {
|
||||
border-color: var(--main-color) !important;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.share-mode-card,
|
||||
.card-icon-wrapper,
|
||||
.card-icon {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<style lang="less">
|
||||
.share-selection-popover {
|
||||
.selection-dropdown {
|
||||
width: 280px;
|
||||
max-height: 340px;
|
||||
padding: 8px;
|
||||
overflow: hidden auto;
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: 14px;
|
||||
background: var(--gray-0);
|
||||
box-shadow:
|
||||
0 14px 36px rgb(0 0 0 / 12%),
|
||||
0 2px 8px rgb(0 0 0 / 6%);
|
||||
}
|
||||
|
||||
.selection-dropdown-header {
|
||||
padding: 8px 10px 10px;
|
||||
margin-bottom: 4px;
|
||||
border-bottom: 1px solid var(--gray-100);
|
||||
}
|
||||
|
||||
.selection-dropdown-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--gray-900);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.selection-dropdown-subtitle {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.selection-search {
|
||||
margin: 8px;
|
||||
width: calc(100% - 16px);
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.selection-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.selection-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 38px;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 9px;
|
||||
color: var(--gray-800);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 160ms ease,
|
||||
color 160ms ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: var(--main-10);
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
&.locked {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.selection-item-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.selection-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.selection-required {
|
||||
flex-shrink: 0;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--gray-100);
|
||||
color: var(--gray-500);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.selection-empty {
|
||||
display: block;
|
||||
padding: 14px 0;
|
||||
font-size: 13px;
|
||||
color: var(--gray-600);
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -150,7 +150,7 @@
|
||||
<!-- 共享配置 -->
|
||||
<div class="form-section compact-section">
|
||||
<h3 class="section-title">共享设置</h3>
|
||||
<ShareConfigForm v-model="shareConfig" :auto-select-user-dept="true" />
|
||||
<ShareConfigForm ref="shareConfigFormRef" v-model="shareConfig" :auto-select-user-dept="true" />
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
@ -270,12 +270,15 @@ const state = reactive({
|
||||
openNewDatabaseModel: false
|
||||
})
|
||||
|
||||
// 共享配置状态(用于提交数据)
|
||||
const shareConfig = ref({
|
||||
is_shared: true,
|
||||
accessible_department_ids: []
|
||||
const createDefaultShareConfig = () => ({
|
||||
access_level: 'global',
|
||||
department_ids: [],
|
||||
user_uids: []
|
||||
})
|
||||
|
||||
const shareConfig = ref(createDefaultShareConfig())
|
||||
const shareConfigFormRef = ref(null)
|
||||
|
||||
const chunkPresetOptions = CHUNK_PRESET_OPTIONS.map(({ label, value }) => ({ label, value }))
|
||||
|
||||
const createEmptyDatabaseForm = () => ({
|
||||
@ -341,11 +344,7 @@ const resetNewDatabase = () => {
|
||||
Object.assign(newDatabase, createEmptyDatabaseForm())
|
||||
newDatabase.kb_type = kbTypes.value[0] || ''
|
||||
resetCreateParamValues()
|
||||
// 重置共享配置
|
||||
shareConfig.value = {
|
||||
is_shared: true,
|
||||
accessible_department_ids: []
|
||||
}
|
||||
shareConfig.value = createDefaultShareConfig()
|
||||
}
|
||||
|
||||
const cancelCreateDatabase = () => {
|
||||
@ -406,12 +405,10 @@ const buildRequestData = () => {
|
||||
requestData.additional_params.chunk_preset_id = newDatabase.chunk_preset_id || 'general'
|
||||
}
|
||||
|
||||
// 添加共享配置
|
||||
requestData.share_config = {
|
||||
is_shared: shareConfig.value.is_shared,
|
||||
accessible_departments: shareConfig.value.is_shared
|
||||
? []
|
||||
: shareConfig.value.accessible_department_ids || []
|
||||
access_level: shareConfig.value.access_level,
|
||||
department_ids: shareConfig.value.access_level === 'department' ? shareConfig.value.department_ids || [] : [],
|
||||
user_uids: shareConfig.value.access_level === 'user' ? shareConfig.value.user_uids || [] : []
|
||||
}
|
||||
|
||||
// 根据类型添加特定配置
|
||||
@ -445,6 +442,14 @@ const handleCreateDatabase = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (shareConfigFormRef.value) {
|
||||
const validation = shareConfigFormRef.value.validate()
|
||||
if (!validation.valid) {
|
||||
message.warning(validation.message)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const requestData = buildRequestData()
|
||||
try {
|
||||
await databaseStore.createDatabase(requestData)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user