feat: 收紧用户管理部门隔离并重构知识库共享权限

- 普通管理员创建用户时固定归属本部门,用户列表/访问选项/详情/更新/删除均限制在本部门
- share_config 改为三档:global(全局共享)、department(部门共享)、user(指定人可访问)
- 部门共享必须包含当前用户部门,指定人可访问必须包含当前用户
- 前端 ShareConfigForm 重构为三档卡片选择+下拉菜单选人/选部门
- 补充部门隔离和共享权限过滤的集成测试
This commit is contained in:
Wenjie Zhang 2026-05-18 21:32:10 +08:00
parent 5693fdd4a7
commit c342f3e1ee
11 changed files with 1103 additions and 362 deletions

View File

@ -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 一样,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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