mirror of
https://github.com/dataelement/bisheng.git
synced 2026-09-01 15:32:50 +08:00
Merge branch 'feat/2.6.0-beta4' of github.com:dataelement/bisheng into feat/2.6.0-beta4
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
# 会话点赞点踩反馈 PRD(产品视角)
|
||||
|
||||
> 面向产品 / 业务的需求说明,大白话、金字塔结构:先结论,再问题,再要做的事、怎么存。
|
||||
> 研发验收口径(EARS 句式)另出 `features/v2.6.0/{NNN}-message-feedback/spec.md`,与本文一一对应、不冲突。
|
||||
> **本阶段只出文档,不写代码。**
|
||||
|
||||
---
|
||||
|
||||
## 一、结论先行
|
||||
|
||||
**目前只有「应用会话」能对 AI 回答点赞/点踩,另外四个 AI 问答界面(含「深度思考」答案)只能复制/朗读,用户无法表达"这条答案好不好",我们也拿不到质量反馈。本次给这四个界面统一补上点赞/点踩(点踩可填原因),并让反馈全部落库、能一张表取数分析。改动很小——因为四个界面里三个早就具备存储与接口,只有灵思需要补一列。**
|
||||
|
||||
| | 界面 | 现状 | 本次要做的事 | 用户 / 数据侧拿到的结果 |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 工作台**日常模式** | 只有复制/朗读 | 工具栏加点赞/点踩,点踩填原因 | 能反馈、反馈进库可分析 |
|
||||
| 2 | 工作台**任务模式 / 灵思**(深度思考) | 无任何反馈按钮 | 任务完成区加点赞/点踩,点踩填原因 | 同上(唯一需后端补一列的界面) |
|
||||
| 3 | **知识空间 AI 问答**(知源) | 只有复制/朗读 | 工具栏加点赞/点踩,点踩填原因 | 同上 |
|
||||
| 4 | **频道订阅 AI 问答**(知源) | 只有复制/朗读 | 工具栏加点赞/点踩,点踩填原因 | 同上 |
|
||||
|
||||
> 一句话:**不是从零做一套反馈系统,而是把「应用会话」已有的成熟机制铺到另外四个界面;三个界面直接复用,灵思补一列并汇总进同一张分析表。**
|
||||
|
||||
---
|
||||
|
||||
## 二、为什么要做(问题)
|
||||
|
||||
- **用户侧**:这四个界面用得很多(日常问答、深度任务、知识库问答、频道文章问答),但答案好坏用户"没处表达"。应用会话有赞踩、这四个没有,体验割裂。
|
||||
- **产品/运营侧**:拿不到"哪些答案被点赞、哪些被点踩、点踩原因是什么"的数据,无法评估各界面/各模型的回答质量,也没法据此调优。
|
||||
- **深度思考**:带"已深度思考"的答案恰恰是最该被评价的(推理长、易出错),现在同样无法反馈。
|
||||
|
||||
---
|
||||
|
||||
## 三、要做哪些事(功能范围)
|
||||
|
||||
1. **四个界面统一加点赞、点踩两个按钮**,位置与现有"复制/朗读"并列,风格一致;赞与踩互斥、可再次点击取消。
|
||||
2. **点踩弹窗填原因**(沿用应用会话的"填写反馈"弹窗体验),原因文本也落库。
|
||||
3. **深度思考按整条答案评价**:赞踩落在整条回答上(思考过程 + 正文一体),不对思考块单独设按钮——与应用会话行为一致。
|
||||
4. **反馈全部落库、且方便取数分析**:数据结构合理,能从数据库直接统计各界面/各会话的赞踩情况(见第四节)。
|
||||
5. **刷新后保持状态**:已点过的赞/踩,刷新页面后仍高亮显示。
|
||||
|
||||
**本次不做(Non-goals)**
|
||||
- 审计/后台页面不新增"按赞踩筛选"的界面能力(用户已明确暂不要求,仅保证数据可取)。
|
||||
- 不改应用会话现有反馈逻辑(保持不变)。
|
||||
- 只读分享页不提供赞踩(匿名访客不交互)。
|
||||
|
||||
---
|
||||
|
||||
## 四、数据怎么存、怎么取数(数据结构说明)
|
||||
|
||||
> 核心诉求是"结构合理 + 好分析"。做法是**逐条明细存在各自答案行、会话级汇总统一进一张表**。
|
||||
|
||||
| 界面 | 每条答案的赞踩存哪 | 点踩原因存哪 | 会话级汇总 |
|
||||
|---|---|---|---|
|
||||
| 日常 / 知源 / 频道 | `chatmessage.liked`(0未评/1赞/2踩,**已有**) | `chatmessage.remark`(**已有**) | `message_session.like/dislike`(**已有**) |
|
||||
| 灵思任务 | `linsight_session_version.liked`(**本次新增一列**) | `linsight_session_version.execute_feedback`(**已有**) | `message_session.like/dislike`(本次让灵思也汇总进来) |
|
||||
|
||||
**为什么这样"好分析":**
|
||||
- `message_session` 这张表已经按 `flow_type` 区分界面(日常=15 / 灵思=20 / 频道=25 / 知识空间=30)。
|
||||
把灵思也汇总进它之后,**它就成了覆盖全部界面的单一分析表**——按 `flow_type` 分组即可拿到各界面赞踩量。
|
||||
- 要看逐条明细,就查各自答案行的 `liked`,都能 join 回 `message_session` 带出界面类型、租户、用户。
|
||||
- 既有分析函数可直接复用(`static_msg_liked`、`app_list_group_by_chat_id`、会话反馈过滤)。
|
||||
|
||||
**取数示例(说明"方便"):**
|
||||
- 各界面赞踩总量:`SELECT flow_type, SUM(like), SUM(dislike) FROM message_session GROUP BY flow_type;`
|
||||
- 逐条被踩明细 + 原因:查 `chatmessage`(liked=2 取 remark)与 `linsight_session_version`(liked=2 取 execute_feedback)。
|
||||
|
||||
---
|
||||
|
||||
## 五、交互与体验
|
||||
|
||||
- **按钮位置**:与"复制/朗读"同一行工具栏,悬停出现;灵思在"任务完成"结果区下方新增一行。
|
||||
- **互斥与取消**:点赞后再点赞=取消;赞↔踩互斥切换。
|
||||
- **点踩弹窗**:点踩后弹出"填写原因"输入框(复用应用会话的弹窗与文案),提交后记录原因。
|
||||
- **状态保持**:刷新/重进会话后,已赞/已踩保持高亮(后端在历史里回传赞踩状态)。
|
||||
- **一致性**:四个界面视觉、交互与应用会话统一,用户零学习成本。
|
||||
|
||||
---
|
||||
|
||||
## 六、范围、分期与风险
|
||||
|
||||
- **一次性全上**(不分期):三个界面复用成熟机制、几乎零后端改动;灵思单独补列。
|
||||
- **主要工作量在灵思**:新增 `liked` 列需一条数据库迁移,**须兼容 MySQL 与达梦 DM8 双库**(按仓库既有迁移规范,幂等 + 手工核对 DM8)。
|
||||
- **需确认的技术点(研发阶段处理)**:确保三个 chatmessage 界面的"历史接口"回传 `liked`、"实时答案"带上答案 id(否则新答案未刷新时点不了赞、或刷新后不高亮)。
|
||||
- **风险低**:不触碰权限/多租户核心;灵思写入按 `session_id` 主键定位,无跨租户风险。
|
||||
|
||||
---
|
||||
|
||||
## 七、验收要点(对应研发 spec,供后续 EARS 化)
|
||||
|
||||
1. 四个界面均可点赞/点踩,赞踩互斥且可取消。
|
||||
2. 点踩弹窗可填原因并成功保存(chatmessage→remark,灵思→execute_feedback)。
|
||||
3. 刷新后赞踩状态正确回显。
|
||||
4. 赞踩写库正确:chatmessage/linsight 各自 `liked` 落值,`message_session` 汇总计数增减正确(含赞→踩切换)。
|
||||
5. `SELECT flow_type, SUM(like), SUM(dislike) FROM message_session GROUP BY flow_type` 能看到四个界面(含灵思 flow_type=20)的赞踩汇总。
|
||||
6. 只读分享页不出现赞踩按钮;应用会话原有反馈行为不变。
|
||||
@@ -94,6 +94,40 @@ def _llm_api_key_hash(config: dict | None) -> str | None:
|
||||
return hashlib.sha256(key.encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def _coerce_model_id(value: Any) -> int | None:
|
||||
if value is None or isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
model_id = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return model_id if model_id > 0 else None
|
||||
|
||||
|
||||
def _workbench_model_ref_values(config: WorkbenchModelConfig) -> list[Any]:
|
||||
return [
|
||||
*(one.id for one in (config.models or [])),
|
||||
config.linsight_default_model_id,
|
||||
getattr(config.embedding_model, "id", None),
|
||||
getattr(config.asr_model, "id", None),
|
||||
getattr(config.tts_model, "id", None),
|
||||
getattr(config.chat_title_llm, "id", None),
|
||||
]
|
||||
|
||||
|
||||
def _allowed_system_model_owner(
|
||||
model_tenant_id: int | None,
|
||||
target_tenant_id: int,
|
||||
*,
|
||||
inherited_from_root: bool,
|
||||
) -> bool:
|
||||
if inherited_from_root:
|
||||
return model_tenant_id == ROOT_TENANT_ID
|
||||
if model_tenant_id == target_tenant_id:
|
||||
return True
|
||||
return target_tenant_id != ROOT_TENANT_ID and model_tenant_id == ROOT_TENANT_ID
|
||||
|
||||
|
||||
async def _write_llm_audit(
|
||||
login_user: "UserPayload",
|
||||
action: str,
|
||||
@@ -261,6 +295,83 @@ class LLMService:
|
||||
)
|
||||
return model_cls(**(json.loads(value) if value else {}))
|
||||
|
||||
@classmethod
|
||||
async def _sanitize_workbench_config_refs(
|
||||
cls,
|
||||
config: WorkbenchModelConfig,
|
||||
target_tenant_id: int,
|
||||
*,
|
||||
inherited_from_root: bool,
|
||||
) -> WorkbenchModelConfig:
|
||||
model_ids = {
|
||||
model_id
|
||||
for model_id in (_coerce_model_id(value) for value in _workbench_model_ref_values(config))
|
||||
if model_id is not None
|
||||
}
|
||||
if not model_ids:
|
||||
return cls._filter_workbench_config(config, set())
|
||||
|
||||
with bypass_tenant_filter():
|
||||
rows = await LLMDao.aget_model_by_ids(list(model_ids))
|
||||
allowed_ids = {
|
||||
row.id
|
||||
for row in rows
|
||||
if _allowed_system_model_owner(
|
||||
row.tenant_id,
|
||||
target_tenant_id,
|
||||
inherited_from_root=inherited_from_root,
|
||||
)
|
||||
}
|
||||
return cls._filter_workbench_config(config, allowed_ids)
|
||||
|
||||
@classmethod
|
||||
def _sanitize_workbench_config_refs_sync(
|
||||
cls,
|
||||
config: WorkbenchModelConfig,
|
||||
target_tenant_id: int,
|
||||
*,
|
||||
inherited_from_root: bool,
|
||||
) -> WorkbenchModelConfig:
|
||||
model_ids = {
|
||||
model_id
|
||||
for model_id in (_coerce_model_id(value) for value in _workbench_model_ref_values(config))
|
||||
if model_id is not None
|
||||
}
|
||||
if not model_ids:
|
||||
return cls._filter_workbench_config(config, set())
|
||||
|
||||
with bypass_tenant_filter():
|
||||
rows = LLMDao.get_model_by_ids(list(model_ids))
|
||||
allowed_ids = {
|
||||
row.id
|
||||
for row in rows
|
||||
if _allowed_system_model_owner(
|
||||
row.tenant_id,
|
||||
target_tenant_id,
|
||||
inherited_from_root=inherited_from_root,
|
||||
)
|
||||
}
|
||||
return cls._filter_workbench_config(config, allowed_ids)
|
||||
|
||||
@staticmethod
|
||||
def _filter_workbench_config(
|
||||
config: WorkbenchModelConfig,
|
||||
allowed_ids: set[int],
|
||||
) -> WorkbenchModelConfig:
|
||||
def is_allowed(value: Any) -> bool:
|
||||
model_id = _coerce_model_id(value)
|
||||
return model_id is not None and model_id in allowed_ids
|
||||
|
||||
if config.models is not None:
|
||||
config.models = [one for one in config.models if is_allowed(one.id)]
|
||||
if not is_allowed(config.linsight_default_model_id):
|
||||
config.linsight_default_model_id = None
|
||||
for field_name in ("embedding_model", "asr_model", "tts_model", "chat_title_llm"):
|
||||
ws_model = getattr(config, field_name)
|
||||
if ws_model is not None and not is_allowed(ws_model.id):
|
||||
setattr(config, field_name, None)
|
||||
return config
|
||||
|
||||
@classmethod
|
||||
async def get_all_llm(
|
||||
cls,
|
||||
@@ -1308,18 +1419,31 @@ class LLMService:
|
||||
cls,
|
||||
tenant_id: int | None = None,
|
||||
) -> tuple[WorkbenchModelConfig, bool, bool]:
|
||||
return await cls._aget_typed_with_meta(
|
||||
target = _resolve_tenant_id(tenant_id)
|
||||
config, inherited, blocked = await cls._aget_typed_with_meta(
|
||||
ConfigKeyEnum.LINSIGHT_LLM,
|
||||
WorkbenchModelConfig,
|
||||
tenant_id,
|
||||
target,
|
||||
)
|
||||
config = await cls._sanitize_workbench_config_refs(
|
||||
config,
|
||||
target,
|
||||
inherited_from_root=inherited,
|
||||
)
|
||||
return config, inherited, blocked
|
||||
|
||||
@classmethod
|
||||
def get_workbench_llm_sync(cls, tenant_id: int | None = None) -> WorkbenchModelConfig:
|
||||
return cls._get_typed_sync(
|
||||
ConfigKeyEnum.LINSIGHT_LLM,
|
||||
WorkbenchModelConfig,
|
||||
tenant_id,
|
||||
target = _resolve_tenant_id(tenant_id)
|
||||
value, inherited, _ = TenantSystemModelConfigDao.resolve(
|
||||
tenant_id=target,
|
||||
key=ConfigKeyEnum.LINSIGHT_LLM.value,
|
||||
)
|
||||
config = WorkbenchModelConfig(**(json.loads(value) if value else {}))
|
||||
return cls._sanitize_workbench_config_refs_sync(
|
||||
config,
|
||||
target,
|
||||
inherited_from_root=inherited,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -9,9 +9,9 @@ derivation → leaf status check → JWT signing.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from typing import AsyncIterator, List, Optional, Tuple
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -29,10 +29,10 @@ from bisheng.core.context.tenant import (
|
||||
set_current_tenant_id,
|
||||
)
|
||||
from bisheng.database.constants import (
|
||||
AdminRole,
|
||||
DefaultRole,
|
||||
USER_DISABLE_SOURCE_GATEWAY,
|
||||
USER_DISABLE_SOURCE_ORG_SYNC,
|
||||
AdminRole,
|
||||
DefaultRole,
|
||||
)
|
||||
from bisheng.database.models.audit_log import AuditLogDao
|
||||
from bisheng.database.models.department import DepartmentDao, UserDepartmentDao
|
||||
@@ -73,14 +73,19 @@ from bisheng.user.domain.models.user_role import UserRoleDao
|
||||
from bisheng.user.domain.services.auth import AuthJwt, LoginUser
|
||||
from bisheng.user.domain.services.user import UserService
|
||||
|
||||
_USER_LOCK_KEY = 'user:sso_lock:{external_user_id}'
|
||||
_USER_LOCK_KEY = "user:sso_lock:{external_user_id}"
|
||||
|
||||
|
||||
class LoginSyncService:
|
||||
SOURCE = DEFAULT_SSO_SYNC_SOURCE
|
||||
|
||||
#: Seeded guest department (临时访客), see ``init_data._init_default_root_department``.
|
||||
#: Used as the department fallback for SSO users whose payload carries no HR
|
||||
#: department, mirroring self-registration (``UserService.user_register``).
|
||||
GUEST_DEPT_ID = "BS@guest"
|
||||
|
||||
@staticmethod
|
||||
def _disable_source_for_row(row_source: str, want_delete: int) -> Optional[str]:
|
||||
def _disable_source_for_row(row_source: str, want_delete: int) -> str | None:
|
||||
if want_delete != 1:
|
||||
return None
|
||||
if row_source == WECOM_SOURCE:
|
||||
@@ -91,12 +96,10 @@ class LoginSyncService:
|
||||
async def execute(
|
||||
cls,
|
||||
payload: LoginSyncRequest,
|
||||
request_ip: str = '',
|
||||
request_ip: str = "",
|
||||
row_source: str = DEFAULT_SSO_SYNC_SOURCE,
|
||||
) -> LoginSyncResponse:
|
||||
ttl = int(
|
||||
getattr(settings.sso_sync, 'user_lock_ttl_seconds', 30) or 30
|
||||
)
|
||||
ttl = int(getattr(settings.sso_sync, "user_lock_ttl_seconds", 30) or 30)
|
||||
lock_key = _USER_LOCK_KEY.format(
|
||||
external_user_id=payload.external_user_id,
|
||||
)
|
||||
@@ -104,11 +107,12 @@ class LoginSyncService:
|
||||
async with _acquire_user_lock(lock_key, ttl=ttl) as acquired:
|
||||
if not acquired:
|
||||
raise SsoUserLockBusyError.http_exception(
|
||||
f'another SSO login for {payload.external_user_id} is '
|
||||
f'in progress'
|
||||
f"another SSO login for {payload.external_user_id} is in progress"
|
||||
)
|
||||
return await cls._execute_locked(
|
||||
payload, request_ip, row_source,
|
||||
payload,
|
||||
request_ip,
|
||||
row_source,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -123,18 +127,14 @@ class LoginSyncService:
|
||||
try:
|
||||
# --- parent chain (enabled + disabled WeCom users share binding) ---
|
||||
if payload.primary_dept_external_id:
|
||||
all_exts = [payload.primary_dept_external_id] + list(
|
||||
payload.secondary_dept_external_ids or []
|
||||
)
|
||||
all_exts = [payload.primary_dept_external_id] + list(payload.secondary_dept_external_ids or [])
|
||||
ext_to_dept = await DeptUpsertService.assert_parent_chain_exists(
|
||||
all_exts,
|
||||
source=row_source,
|
||||
)
|
||||
primary_dept = ext_to_dept[payload.primary_dept_external_id]
|
||||
secondary_depts = [
|
||||
ext_to_dept[e]
|
||||
for e in (payload.secondary_dept_external_ids or [])
|
||||
if e in ext_to_dept
|
||||
ext_to_dept[e] for e in (payload.secondary_dept_external_ids or []) if e in ext_to_dept
|
||||
]
|
||||
else:
|
||||
primary_dept = None
|
||||
@@ -147,7 +147,9 @@ class LoginSyncService:
|
||||
)
|
||||
|
||||
user, full_department_override = await cls._upsert_user(
|
||||
payload, request_ip=request_ip, row_source=row_source,
|
||||
payload,
|
||||
request_ip=request_ip,
|
||||
row_source=row_source,
|
||||
)
|
||||
|
||||
if full_department_override:
|
||||
@@ -159,11 +161,11 @@ class LoginSyncService:
|
||||
)
|
||||
elif primary_dept is not None:
|
||||
await cls._ensure_primary(
|
||||
user.user_id, primary_dept.id, row_source=row_source,
|
||||
)
|
||||
reconcile_secondary = (
|
||||
'secondary_dept_external_ids' in payload.model_fields_set
|
||||
user.user_id,
|
||||
primary_dept.id,
|
||||
row_source=row_source,
|
||||
)
|
||||
reconcile_secondary = "secondary_dept_external_ids" in payload.model_fields_set
|
||||
await cls._ensure_secondaries(
|
||||
user.user_id,
|
||||
[d.id for d in secondary_depts],
|
||||
@@ -185,21 +187,32 @@ class LoginSyncService:
|
||||
return LoginSyncResponse(
|
||||
user_id=int(user.user_id or 0),
|
||||
leaf_tenant_id=ROOT_TENANT_ID,
|
||||
token='',
|
||||
token="",
|
||||
)
|
||||
|
||||
leaf_tenant = await UserTenantSyncService.sync_user(
|
||||
user.user_id, trigger=UserTenantSyncTrigger.LOGIN,
|
||||
# Guest fallback: an SSO user with no HR department (payload
|
||||
# carried none and no membership survived above) joins the guest
|
||||
# department, mirroring self-registration so the account is never
|
||||
# left department-less. Runs after the disable short-circuit so
|
||||
# disabled placeholder users are not given a guest membership.
|
||||
await cls._reconcile_guest_membership(
|
||||
user.user_id,
|
||||
row_source=row_source,
|
||||
)
|
||||
|
||||
if leaf_tenant.status != 'active':
|
||||
leaf_tenant = await UserTenantSyncService.sync_user(
|
||||
user.user_id,
|
||||
trigger=UserTenantSyncTrigger.LOGIN,
|
||||
)
|
||||
|
||||
if leaf_tenant.status != "active":
|
||||
logger.warning(
|
||||
'F014 login blocked: user %s leaf tenant %s status=%s',
|
||||
user.user_id, leaf_tenant.id, leaf_tenant.status,
|
||||
)
|
||||
raise SsoTenantDisabledError.http_exception(
|
||||
f'tenant {leaf_tenant.id} status={leaf_tenant.status}'
|
||||
"F014 login blocked: user %s leaf tenant %s status=%s",
|
||||
user.user_id,
|
||||
leaf_tenant.id,
|
||||
leaf_tenant.status,
|
||||
)
|
||||
raise SsoTenantDisabledError.http_exception(f"tenant {leaf_tenant.id} status={leaf_tenant.status}")
|
||||
|
||||
guard = await UserService._reject_login_if_user_has_no_usable_access(user)
|
||||
if guard is not None:
|
||||
@@ -207,6 +220,7 @@ class LoginSyncService:
|
||||
UserNoRoleForLoginError,
|
||||
UserNoWebMenuForLoginError,
|
||||
)
|
||||
|
||||
if guard.status_code == UserNoRoleForLoginError.Code:
|
||||
raise UserNoRoleForLoginError()
|
||||
raise UserNoWebMenuForLoginError()
|
||||
@@ -214,7 +228,8 @@ class LoginSyncService:
|
||||
auth_jwt = AuthJwt()
|
||||
token_version = await UserDao.aget_token_version(user.user_id)
|
||||
access_token = LoginUser.create_access_token(
|
||||
user, auth_jwt,
|
||||
user,
|
||||
auth_jwt,
|
||||
tenant_id=leaf_tenant.id,
|
||||
token_version=token_version,
|
||||
)
|
||||
@@ -237,7 +252,7 @@ class LoginSyncService:
|
||||
payload: LoginSyncRequest,
|
||||
request_ip: str,
|
||||
row_source: str,
|
||||
) -> Tuple[User, bool]:
|
||||
) -> tuple[User, bool]:
|
||||
ext = payload.external_user_id
|
||||
attrs = payload.user_attrs
|
||||
full_department_override = False
|
||||
@@ -245,7 +260,7 @@ class LoginSyncService:
|
||||
if user is None:
|
||||
legacy = await UserDao.aget_by_external_id(ext)
|
||||
if legacy is not None:
|
||||
if int(getattr(legacy, 'delete', 0) or 0) == 1:
|
||||
if int(getattr(legacy, "delete", 0) or 0) == 1:
|
||||
# Do not re-adopt disabled rows unless the sync payload
|
||||
# explicitly states account state (e.g. WeCom enable/disable).
|
||||
if payload.account_disabled is None:
|
||||
@@ -259,7 +274,7 @@ class LoginSyncService:
|
||||
else:
|
||||
legacy.source = row_source
|
||||
write_migration_audit = True
|
||||
full_department_override = old_source == 'local'
|
||||
full_department_override = old_source == "local"
|
||||
user = legacy
|
||||
cls._apply_user_attrs(user, attrs)
|
||||
cls._touch_user_sync_time(user)
|
||||
@@ -270,13 +285,13 @@ class LoginSyncService:
|
||||
operator_id=0,
|
||||
operator_tenant_id=ROOT_TENANT_ID,
|
||||
action=TenantAuditAction.USER_SOURCE_MIGRATED.value,
|
||||
target_type='user',
|
||||
target_type="user",
|
||||
target_id=str(legacy.user_id),
|
||||
metadata={
|
||||
'old_source': old_source,
|
||||
'new_source': row_source,
|
||||
'external_id': ext,
|
||||
'via': 'sso_realtime',
|
||||
"old_source": old_source,
|
||||
"new_source": row_source,
|
||||
"external_id": ext,
|
||||
"via": "sso_realtime",
|
||||
},
|
||||
ip_address=request_ip,
|
||||
)
|
||||
@@ -284,12 +299,12 @@ class LoginSyncService:
|
||||
new_delete = 1 if payload.account_disabled is True else 0
|
||||
ds = cls._disable_source_for_row(row_source, new_delete)
|
||||
new_user = User(
|
||||
user_name=(attrs.name.strip() if attrs.name else '') or ext,
|
||||
user_name=(attrs.name.strip() if attrs.name else "") or ext,
|
||||
email=cls._normalize_contact_field(attrs.email),
|
||||
phone_number=cls._normalize_contact_field(attrs.phone),
|
||||
external_id=ext,
|
||||
source=row_source,
|
||||
password='',
|
||||
password="",
|
||||
delete=new_delete,
|
||||
disable_source=ds,
|
||||
)
|
||||
@@ -310,26 +325,31 @@ class LoginSyncService:
|
||||
# Old (migrated) users avoid this because F011's backfill
|
||||
# set is_active=1 for them.
|
||||
from bisheng.database.models.tenant import UserTenantDao
|
||||
|
||||
activated = await UserTenantDao.aactivate_user_tenant(
|
||||
user.user_id, ROOT_TENANT_ID,
|
||||
user.user_id,
|
||||
ROOT_TENANT_ID,
|
||||
)
|
||||
logger.info(
|
||||
'SSO new user created with active user_tenant: '
|
||||
'user_id=%s external_id=%s source=%s tenant_id=%s',
|
||||
user.user_id, ext, row_source, activated.tenant_id,
|
||||
"SSO new user created with active user_tenant: "
|
||||
"user_id=%s external_id=%s source=%s tenant_id=%s",
|
||||
user.user_id,
|
||||
ext,
|
||||
row_source,
|
||||
activated.tenant_id,
|
||||
)
|
||||
except Exception as e: # pragma: no cover — rare integrity race
|
||||
logger.error(
|
||||
'F014 could not create SSO user %s: %s', ext, e,
|
||||
)
|
||||
raise SsoCrossSourceUserError.http_exception(
|
||||
f'failed to create user for external_id={ext}: {e}'
|
||||
"F014 could not create SSO user %s: %s",
|
||||
ext,
|
||||
e,
|
||||
)
|
||||
raise SsoCrossSourceUserError.http_exception(f"failed to create user for external_id={ext}: {e}")
|
||||
else:
|
||||
# WeCom (and Gateway) send explicit ``account_disabled``; when False,
|
||||
# the row below must flip ``delete`` back to 0. Unconditional forbid
|
||||
# here blocked re-enable after 企微禁用 → 再启用 (delete stayed 1).
|
||||
if int(getattr(user, 'delete', 0) or 0) == 1:
|
||||
if int(getattr(user, "delete", 0) or 0) == 1:
|
||||
if payload.account_disabled is None:
|
||||
raise UserForbiddenError.http_exception()
|
||||
cls._apply_user_attrs(user, attrs)
|
||||
@@ -339,17 +359,17 @@ class LoginSyncService:
|
||||
# Gateway org sync: optional explicit account enable/disable
|
||||
if payload.account_disabled is not None:
|
||||
want = 1 if payload.account_disabled else 0
|
||||
if int(getattr(user, 'delete', 0) or 0) != want:
|
||||
if int(getattr(user, "delete", 0) or 0) != want:
|
||||
user.delete = want
|
||||
user.disable_source = cls._disable_source_for_row(row_source, want)
|
||||
await UserDao.aupdate_user(user)
|
||||
|
||||
if int(getattr(user, 'delete', 0) or 0) == 1 and payload.account_disabled is not True:
|
||||
if int(getattr(user, "delete", 0) or 0) == 1 and payload.account_disabled is not True:
|
||||
raise UserForbiddenError.http_exception()
|
||||
return user, full_department_override
|
||||
|
||||
@staticmethod
|
||||
def _normalize_contact_field(val: Optional[str]) -> Optional[str]:
|
||||
def _normalize_contact_field(val: str | None) -> str | None:
|
||||
"""Strip; empty string → None. ``None`` means omit (do not overwrite in apply)."""
|
||||
if val is None:
|
||||
return None
|
||||
@@ -381,9 +401,66 @@ class LoginSyncService:
|
||||
# Helper: UserDepartment primary + secondary management.
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
async def _reconcile_guest_membership(
|
||||
cls,
|
||||
user_id: int,
|
||||
*,
|
||||
row_source: str,
|
||||
) -> None:
|
||||
"""Keep guest-department membership consistent with the invariant
|
||||
"a user belongs to the guest department iff they have no other
|
||||
department".
|
||||
|
||||
Mirrors self-registration (``UserService.user_register``): an SSO user
|
||||
whose payload carried no HR department — and who has no surviving
|
||||
membership — is placed in the guest department (临时访客) as primary, so
|
||||
the account is never left department-less. Conversely, once a real
|
||||
department has been assigned (e.g. by a later org-sync that demotes the
|
||||
guest placeholder to a secondary row), the guest membership is vacated.
|
||||
|
||||
Guest is a ``source='local'`` department mounted under the root tenant,
|
||||
so making it primary keeps the leaf tenant at ``ROOT_TENANT_ID`` — the
|
||||
same tenant a department-less SSO user already resolved to. Idempotent
|
||||
and best-effort: a missing guest department is logged and skipped, never
|
||||
fatal to login.
|
||||
"""
|
||||
guest = await DepartmentDao.aget_by_dept_id(cls.GUEST_DEPT_ID)
|
||||
if guest is None or getattr(guest, "status", "") != "active":
|
||||
logger.warning(
|
||||
"guest department {} missing/inactive; skip SSO guest fallback for user {}",
|
||||
cls.GUEST_DEPT_ID,
|
||||
user_id,
|
||||
)
|
||||
return
|
||||
|
||||
guest_id = int(guest.id)
|
||||
memberships = await UserDepartmentDao.aget_user_departments(user_id)
|
||||
has_guest = any(int(m.department_id) == guest_id for m in memberships)
|
||||
has_real = any(int(m.department_id) != guest_id for m in memberships)
|
||||
|
||||
if not memberships:
|
||||
# Orphan → join the guest department as primary. Track the row as a
|
||||
# bisheng-internal placeholder (source='local'), matching
|
||||
# self-registration, so provider-scoped reconcile never touches it.
|
||||
await UserDepartmentDao.aadd_member(
|
||||
user_id,
|
||||
guest_id,
|
||||
is_primary=1,
|
||||
source="local",
|
||||
)
|
||||
await cls._sync_department_member_tuples(user_id, [guest_id])
|
||||
elif has_guest and has_real:
|
||||
# A real department now exists → vacate the guest placeholder.
|
||||
await cls._remove_department_membership(user_id, guest_id)
|
||||
|
||||
@classmethod
|
||||
async def _ensure_primary(
|
||||
cls, user_id: int, dept_id: int, *, row_source: str,
|
||||
cls,
|
||||
user_id: int,
|
||||
dept_id: int,
|
||||
*,
|
||||
row_source: str,
|
||||
) -> None:
|
||||
"""Make (user_id, dept_id) the primary department, demoting any
|
||||
previous primary to ``is_primary=0``. Idempotent."""
|
||||
@@ -395,16 +472,23 @@ class LoginSyncService:
|
||||
# Demote old primary in place instead of deleting to preserve
|
||||
# membership history; F012 sync_user reads only the flag.
|
||||
await UserDepartmentDao.aset_primary_flag(
|
||||
user_id, current.department_id, is_primary=0,
|
||||
user_id,
|
||||
current.department_id,
|
||||
is_primary=0,
|
||||
)
|
||||
existing = await UserDepartmentDao.aget_membership(user_id, dept_id)
|
||||
if existing is not None:
|
||||
await UserDepartmentDao.aset_primary_flag(
|
||||
user_id, dept_id, is_primary=1,
|
||||
user_id,
|
||||
dept_id,
|
||||
is_primary=1,
|
||||
)
|
||||
else:
|
||||
await UserDepartmentDao.aadd_member(
|
||||
user_id, dept_id, is_primary=1, source=row_source,
|
||||
user_id,
|
||||
dept_id,
|
||||
is_primary=1,
|
||||
source=row_source,
|
||||
)
|
||||
await cls._sync_department_member_tuples(user_id, [dept_id])
|
||||
|
||||
@@ -412,16 +496,14 @@ class LoginSyncService:
|
||||
async def _replace_departments_full(
|
||||
cls,
|
||||
user_id: int,
|
||||
primary_dept_id: Optional[int],
|
||||
primary_dept_id: int | None,
|
||||
secondary_dept_ids: list[int],
|
||||
*,
|
||||
row_source: str,
|
||||
) -> None:
|
||||
"""Replace all department memberships from the imported payload."""
|
||||
desired_secondary_ids = [
|
||||
int(did)
|
||||
for did in secondary_dept_ids
|
||||
if did is not None and int(did) != int(primary_dept_id or 0)
|
||||
int(did) for did in secondary_dept_ids if did is not None and int(did) != int(primary_dept_id or 0)
|
||||
]
|
||||
desired_dept_ids: list[int] = []
|
||||
if primary_dept_id is not None:
|
||||
@@ -430,9 +512,7 @@ class LoginSyncService:
|
||||
desired_dept_ids = list(dict.fromkeys(desired_dept_ids))
|
||||
|
||||
current_memberships = await UserDepartmentDao.aget_user_departments(user_id)
|
||||
current_dept_ids = list(dict.fromkeys(
|
||||
int(row.department_id) for row in current_memberships
|
||||
))
|
||||
current_dept_ids = list(dict.fromkeys(int(row.department_id) for row in current_memberships))
|
||||
|
||||
await cls._replace_department_scoped_roles(
|
||||
user_id,
|
||||
@@ -445,11 +525,17 @@ class LoginSyncService:
|
||||
|
||||
if primary_dept_id is not None:
|
||||
await UserDepartmentDao.aadd_member(
|
||||
user_id, int(primary_dept_id), is_primary=1, source=row_source,
|
||||
user_id,
|
||||
int(primary_dept_id),
|
||||
is_primary=1,
|
||||
source=row_source,
|
||||
)
|
||||
for department_id in desired_secondary_ids:
|
||||
await UserDepartmentDao.aadd_member(
|
||||
user_id, int(department_id), is_primary=0, source=row_source,
|
||||
user_id,
|
||||
int(department_id),
|
||||
is_primary=0,
|
||||
source=row_source,
|
||||
)
|
||||
|
||||
await cls._sync_department_member_tuples(user_id, desired_dept_ids)
|
||||
@@ -473,9 +559,9 @@ class LoginSyncService:
|
||||
revoke_role_ids = {
|
||||
int(role.id)
|
||||
for role in role_rows
|
||||
if getattr(role, 'id', None) is not None
|
||||
and int(getattr(role, 'department_id', 0) or 0) in revoke_scope
|
||||
and int(role.id) != AdminRole
|
||||
if getattr(role, "id", None) is not None
|
||||
and int(getattr(role, "department_id", 0) or 0) in revoke_scope
|
||||
and int(role.id) != AdminRole
|
||||
}
|
||||
target_role_ids -= revoke_role_ids
|
||||
|
||||
@@ -484,7 +570,7 @@ class LoginSyncService:
|
||||
default_role_ids = {
|
||||
int(role_id)
|
||||
for dept in dept_rows
|
||||
for role_id in (getattr(dept, 'default_role_ids', None) or [])
|
||||
for role_id in (getattr(dept, "default_role_ids", None) or [])
|
||||
if role_id is not None and int(role_id) != AdminRole
|
||||
}
|
||||
target_role_ids.update(default_role_ids)
|
||||
@@ -504,7 +590,9 @@ class LoginSyncService:
|
||||
|
||||
@classmethod
|
||||
async def _sync_department_member_tuples(
|
||||
cls, user_id: int, dept_ids: list[int],
|
||||
cls,
|
||||
user_id: int,
|
||||
dept_ids: list[int],
|
||||
) -> None:
|
||||
"""Best-effort OpenFGA department membership repair for SSO login.
|
||||
|
||||
@@ -523,7 +611,7 @@ class LoginSyncService:
|
||||
async def _sync_department_admin_tuples(
|
||||
cls,
|
||||
user_id: int,
|
||||
admin_dept_external_ids: Optional[List[str]],
|
||||
admin_dept_external_ids: list[str] | None,
|
||||
*,
|
||||
row_source: str,
|
||||
) -> None:
|
||||
@@ -544,32 +632,33 @@ class LoginSyncService:
|
||||
depts = await DepartmentDao.aget_by_ids(dept_ids) if dept_ids else []
|
||||
dept_by_id = {int(d.id): d for d in depts if d.id is not None}
|
||||
|
||||
reconcile_dept_ids: List[int] = []
|
||||
reconcile_dept_ids: list[int] = []
|
||||
for row in memberships:
|
||||
dept = dept_by_id.get(int(row.department_id))
|
||||
if dept is None or getattr(dept, 'source', '') != row_source:
|
||||
if dept is None or getattr(dept, "source", "") != row_source:
|
||||
continue
|
||||
ext_raw = getattr(dept, 'external_id', None)
|
||||
ext_raw = getattr(dept, "external_id", None)
|
||||
if not ext_raw or not str(ext_raw).strip():
|
||||
continue
|
||||
reconcile_dept_ids.append(int(dept.id))
|
||||
|
||||
grants = await DepartmentAdminGrantDao.aget_by_user_and_departments(
|
||||
user_id, reconcile_dept_ids,
|
||||
user_id,
|
||||
reconcile_dept_ids,
|
||||
)
|
||||
grant_by_dept = {int(g.department_id): g for g in grants}
|
||||
|
||||
ops = []
|
||||
upsert_sso_dept_ids: List[int] = []
|
||||
delete_grant_dept_ids: List[int] = []
|
||||
upsert_sso_dept_ids: list[int] = []
|
||||
delete_grant_dept_ids: list[int] = []
|
||||
|
||||
for row in memberships:
|
||||
dept = dept_by_id.get(int(row.department_id))
|
||||
if dept is None:
|
||||
continue
|
||||
if getattr(dept, 'source', '') != row_source:
|
||||
if getattr(dept, "source", "") != row_source:
|
||||
continue
|
||||
ext_raw = getattr(dept, 'external_id', None)
|
||||
ext_raw = getattr(dept, "external_id", None)
|
||||
if not ext_raw:
|
||||
continue
|
||||
ext_key = str(ext_raw).strip()
|
||||
@@ -579,27 +668,15 @@ class LoginSyncService:
|
||||
marker = grant_by_dept.get(did)
|
||||
|
||||
if ext_key in want:
|
||||
if getattr(dept, 'status', '') != 'active':
|
||||
if getattr(dept, "status", "") != "active":
|
||||
continue
|
||||
if (
|
||||
marker is not None
|
||||
and getattr(marker, 'grant_source', '')
|
||||
== DEPARTMENT_ADMIN_GRANT_SOURCE_MANUAL
|
||||
):
|
||||
if marker is not None and getattr(marker, "grant_source", "") == DEPARTMENT_ADMIN_GRANT_SOURCE_MANUAL:
|
||||
continue
|
||||
ops.extend(
|
||||
DepartmentChangeHandler.on_admin_set(did, [user_id])
|
||||
)
|
||||
ops.extend(DepartmentChangeHandler.on_admin_set(did, [user_id]))
|
||||
upsert_sso_dept_ids.append(did)
|
||||
else:
|
||||
if (
|
||||
marker is not None
|
||||
and getattr(marker, 'grant_source', '')
|
||||
== DEPARTMENT_ADMIN_GRANT_SOURCE_SSO
|
||||
):
|
||||
ops.extend(
|
||||
DepartmentChangeHandler.on_admin_removed(did, [user_id])
|
||||
)
|
||||
if marker is not None and getattr(marker, "grant_source", "") == DEPARTMENT_ADMIN_GRANT_SOURCE_SSO:
|
||||
ops.extend(DepartmentChangeHandler.on_admin_removed(did, [user_id]))
|
||||
delete_grant_dept_ids.append(did)
|
||||
|
||||
if ops:
|
||||
@@ -607,7 +684,9 @@ class LoginSyncService:
|
||||
|
||||
for did in dict.fromkeys(upsert_sso_dept_ids):
|
||||
await DepartmentAdminGrantDao.aupsert(
|
||||
user_id, did, DEPARTMENT_ADMIN_GRANT_SOURCE_SSO,
|
||||
user_id,
|
||||
did,
|
||||
DEPARTMENT_ADMIN_GRANT_SOURCE_SSO,
|
||||
)
|
||||
for did in dict.fromkeys(delete_grant_dept_ids):
|
||||
await DepartmentAdminGrantDao.adelete(user_id, did)
|
||||
@@ -619,7 +698,8 @@ class LoginSyncService:
|
||||
)
|
||||
|
||||
await DepartmentKnowledgeSpaceService.cleanup_removed_department_admins(
|
||||
department_id=did, user_ids=[user_id],
|
||||
department_id=did,
|
||||
user_ids=[user_id],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -642,10 +722,9 @@ class LoginSyncService:
|
||||
) -> None:
|
||||
"""Remove a department membership and its FGA/admin markers."""
|
||||
await UserDepartmentDao.aremove_member(user_id, department_id)
|
||||
ops = (
|
||||
DepartmentChangeHandler.on_member_removed(department_id, user_id)
|
||||
+ DepartmentChangeHandler.on_admin_removed(department_id, [user_id])
|
||||
)
|
||||
ops = DepartmentChangeHandler.on_member_removed(
|
||||
department_id, user_id
|
||||
) + DepartmentChangeHandler.on_admin_removed(department_id, [user_id])
|
||||
await DepartmentChangeHandler.execute_async(ops)
|
||||
await DepartmentAdminGrantDao.adelete(user_id, department_id)
|
||||
# Clear the derived knowledge-space binding (space_channel_member row +
|
||||
@@ -655,7 +734,8 @@ class LoginSyncService:
|
||||
)
|
||||
|
||||
await DepartmentKnowledgeSpaceService.cleanup_removed_department_admins(
|
||||
department_id=department_id, user_ids=[user_id],
|
||||
department_id=department_id,
|
||||
user_ids=[user_id],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -668,9 +748,9 @@ class LoginSyncService:
|
||||
) -> None:
|
||||
"""Drop secondary rows for ``source=row_source`` departments not in ``want``."""
|
||||
memberships = await UserDepartmentDao.aget_user_departments(user_id)
|
||||
to_drop: List[int] = []
|
||||
to_drop: list[int] = []
|
||||
for row in memberships:
|
||||
if int(getattr(row, 'is_primary', 0) or 0) != 0:
|
||||
if int(getattr(row, "is_primary", 0) or 0) != 0:
|
||||
continue
|
||||
did = int(row.department_id)
|
||||
if did in want_secondary_ids:
|
||||
@@ -685,7 +765,7 @@ class LoginSyncService:
|
||||
dept = dept_by_id.get(did)
|
||||
if dept is None:
|
||||
continue
|
||||
if getattr(dept, 'source', '') != row_source:
|
||||
if getattr(dept, "source", "") != row_source:
|
||||
continue
|
||||
await cls._remove_sso_secondary_membership(user_id, did)
|
||||
|
||||
@@ -711,18 +791,24 @@ class LoginSyncService:
|
||||
want_ids = {int(x) for x in dept_ids if x is not None}
|
||||
if reconcile_remove:
|
||||
await cls._reconcile_remove_sso_secondary_memberships(
|
||||
user_id, want_ids, row_source=row_source,
|
||||
user_id,
|
||||
want_ids,
|
||||
row_source=row_source,
|
||||
)
|
||||
if not dept_ids:
|
||||
return
|
||||
existing_rows = await UserDepartmentDao.aget_memberships_in_depts(
|
||||
user_id, dept_ids,
|
||||
user_id,
|
||||
dept_ids,
|
||||
)
|
||||
existing_ids = {row.department_id for row in existing_rows}
|
||||
to_add = [d for d in dept_ids if d not in existing_ids]
|
||||
for dept_id in to_add:
|
||||
await UserDepartmentDao.aadd_member(
|
||||
user_id, dept_id, is_primary=0, source=row_source,
|
||||
user_id,
|
||||
dept_id,
|
||||
is_primary=0,
|
||||
source=row_source,
|
||||
)
|
||||
await cls._sync_department_member_tuples(user_id, dept_ids)
|
||||
|
||||
@@ -731,9 +817,11 @@ class LoginSyncService:
|
||||
# Module-level helper: Redis SETNX-based per-user login lock.
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _acquire_user_lock(
|
||||
lock_key: str, ttl: int = 30,
|
||||
lock_key: str,
|
||||
ttl: int = 30,
|
||||
) -> AsyncIterator[bool]:
|
||||
"""SETNX + TTL in a single Redis roundtrip (``SET key value NX EX ttl``).
|
||||
|
||||
@@ -750,12 +838,16 @@ async def _acquire_user_lock(
|
||||
# Atomic SETNX + EX — avoids the two-step (setnx + expire) race
|
||||
# where a crash between the two leaves a TTL-less lock.
|
||||
result = await redis.async_connection.set(
|
||||
lock_key, b'1', nx=True, ex=ttl,
|
||||
lock_key,
|
||||
b"1",
|
||||
nx=True,
|
||||
ex=ttl,
|
||||
)
|
||||
acquired = bool(result)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
'F014 Redis lock acquire failed (%s); proceeding without lock', e,
|
||||
"F014 Redis lock acquire failed (%s); proceeding without lock",
|
||||
e,
|
||||
)
|
||||
acquired = True
|
||||
redis = None
|
||||
@@ -766,4 +858,4 @@ async def _acquire_user_lock(
|
||||
try:
|
||||
await redis.adelete(lock_key)
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.warning('F014 Redis lock release failed: %s', e)
|
||||
logger.warning("F014 Redis lock release failed: %s", e)
|
||||
|
||||
@@ -14,6 +14,7 @@ The helpers must:
|
||||
so callers see a deterministic "deleted" error rather than a leaked
|
||||
cross-tenant row
|
||||
"""
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -354,6 +355,46 @@ async def test_validate_accepts_string_model_ids_from_ws_model():
|
||||
await avalidate_system_model_refs(['7', None, ''], target_tenant_id=ROOT_TENANT_ID)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workbench_getter_sanitizes_inherited_stale_model_refs():
|
||||
"""Inherited Root workbench config must not echo deleted or Child-owned
|
||||
model ids back to the UI; otherwise the next POST repeats the stale
|
||||
payload and fails write-side validation."""
|
||||
from bisheng.llm.domain.schemas import WSModel
|
||||
from bisheng.llm.domain.services.llm import LLMService
|
||||
|
||||
payload = {
|
||||
"models": [
|
||||
WSModel(id="10", name="root-model").model_dump(),
|
||||
WSModel(id="20", name="child-model").model_dump(),
|
||||
WSModel(id="30", name="deleted-model").model_dump(),
|
||||
],
|
||||
"linsight_default_model_id": "20",
|
||||
"embedding_model": WSModel(id="10", name="root-embedding").model_dump(),
|
||||
"asr_model": WSModel(id="30", name="deleted-asr").model_dump(),
|
||||
"tts_model": WSModel(id="20", name="child-tts").model_dump(),
|
||||
}
|
||||
root_row = MagicMock(id=10, tenant_id=ROOT_TENANT_ID)
|
||||
child_row = MagicMock(id=20, tenant_id=5)
|
||||
|
||||
with patch(
|
||||
'bisheng.llm.domain.services.llm.TenantSystemModelConfigDao.aresolve',
|
||||
new=AsyncMock(return_value=(json.dumps(payload), True, False)),
|
||||
), patch(
|
||||
'bisheng.llm.domain.services.llm.LLMDao.aget_model_by_ids',
|
||||
new=AsyncMock(return_value=[root_row, child_row]),
|
||||
):
|
||||
config, inherited, blocked = await LLMService.aget_workbench_llm_with_meta(tenant_id=5)
|
||||
|
||||
assert inherited is True
|
||||
assert blocked is False
|
||||
assert [one.id for one in (config.models or [])] == ["10"]
|
||||
assert config.linsight_default_model_id is None
|
||||
assert config.embedding_model and config.embedding_model.id == "10"
|
||||
assert config.asr_model is None
|
||||
assert config.tts_model is None
|
||||
|
||||
|
||||
# --- LLMService.update_*_llm setter integration -----------------------------
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -123,7 +123,7 @@ blue: { 50:'rgb(var(--brand-50) / <alpha-value>)', ... 900:'rgb(var(--brand-900)
|
||||
| `ListWebLinkIllustration` | 列表网页链接 | |
|
||||
| `CrawlingIllustration` | 爬取中 | mask uid 已唯一化 |
|
||||
| `SuccessIllustration` | 成功态 | **跟随品牌主题**(用户已确认,非固定语义成功绿) |
|
||||
| `SystemMaintenanceIllustration` | 系统维护 | 数据库+扳手;无 mask;2026-06-24 新增,组件已建未接槽位 |
|
||||
| `SystemMaintenanceIllustration` | 系统维护 | 放大镜+小虫(2026-06 换过图);无 mask;用于 `SystemMaintenanceOverlay`(后端 500 全屏维护弹层)。含 6 档绿,按明度归到 illus-500/300/100 |
|
||||
|
||||
**通用空状态已替换(2026-06-24)**:10 处通用 `assets/channel/empty.png` 的 `<img>` 已换成 `<EmptyStateIllustration className="size-[120px] mb-X opacity-90" />`(去掉对内联 SVG 无意义的 `object-contain`):ChannelMemberManagementPanel、ChannelMemberDialog、KnowledgeSpaceMemberManagementPanel、KnowledgeSpaceMemberDialog、ChannelSquare、Subscription/index、knowledge/index、KnowledgeSquare、SpaceDetail/index、apps/AppEmptyState。
|
||||
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import request from "~/api/request";
|
||||
import {
|
||||
applyMenuAccessApi,
|
||||
decideApprovalTaskApi,
|
||||
getApprovalInstanceDetailApi,
|
||||
getMyApprovalTaskDetailApi,
|
||||
listApprovalRequestsApi,
|
||||
listMyApprovalRequestsApi,
|
||||
listMyApprovalTasksApi,
|
||||
revokeMenuAccessGrantApi,
|
||||
withdrawApprovalInstanceApi,
|
||||
} from "./approval";
|
||||
|
||||
jest.mock("~/api/request", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
paramsSerializer: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockGet = request.get as jest.Mock;
|
||||
const mockPost = request.post as jest.Mock;
|
||||
|
||||
describe("approval api", () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset();
|
||||
mockPost.mockReset();
|
||||
});
|
||||
|
||||
it("uses repeated query params for legacy approval request status arrays", async () => {
|
||||
mockGet.mockResolvedValue({ data: [], total: 0 });
|
||||
|
||||
await listApprovalRequestsApi({
|
||||
space_id: 1,
|
||||
statuses: ["pending_review", "rejected", "finalize_failed"],
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
});
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith("/api/v1/approval/requests", {
|
||||
params: {
|
||||
space_id: 1,
|
||||
statuses: ["pending_review", "rejected", "finalize_failed"],
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
},
|
||||
paramsSerializer: request.paramsSerializer,
|
||||
});
|
||||
});
|
||||
|
||||
it("unwraps my-task list payloads from approval center", async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
status_code: 200,
|
||||
data: { data: [{ task_id: 11, business_name: "知识库订阅" }], total: 1 },
|
||||
});
|
||||
|
||||
await expect(listMyApprovalTasksApi()).resolves.toEqual({
|
||||
data: [{ task_id: 11, business_name: "知识库订阅" }],
|
||||
total: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("loads task detail from approval center endpoint", async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
status_code: 200,
|
||||
data: { task_id: 11, status: "pending" },
|
||||
});
|
||||
|
||||
await expect(getMyApprovalTaskDetailApi(11)).resolves.toEqual({
|
||||
task_id: 11,
|
||||
status: "pending",
|
||||
});
|
||||
});
|
||||
|
||||
it("submits task decisions to approval center endpoint", async () => {
|
||||
mockPost.mockResolvedValue({
|
||||
status_code: 200,
|
||||
data: { task_id: 11, status: "approved" },
|
||||
});
|
||||
|
||||
await expect(decideApprovalTaskApi(11, { action: "approve", comment: "ok" })).resolves.toEqual({
|
||||
task_id: 11,
|
||||
status: "approved",
|
||||
});
|
||||
expect(mockPost).toHaveBeenCalledWith("/api/v1/approval/tasks/11/decision", {
|
||||
action: "approve",
|
||||
comment: "ok",
|
||||
});
|
||||
});
|
||||
|
||||
it("unwraps my-request list payloads from approval center", async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
status_code: 200,
|
||||
data: { data: [{ instance_id: 21, business_name: "频道订阅" }], total: 1 },
|
||||
});
|
||||
|
||||
await expect(listMyApprovalRequestsApi()).resolves.toEqual({
|
||||
data: [{ instance_id: 21, business_name: "频道订阅" }],
|
||||
total: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("loads approval instance detail", async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
status_code: 200,
|
||||
data: { instance_id: 21, status: "approved" },
|
||||
});
|
||||
|
||||
await expect(getApprovalInstanceDetailApi(21)).resolves.toEqual({
|
||||
instance_id: 21,
|
||||
status: "approved",
|
||||
});
|
||||
});
|
||||
|
||||
it("submits withdraw and revoke grant actions", async () => {
|
||||
mockPost
|
||||
.mockResolvedValueOnce({
|
||||
status_code: 200,
|
||||
data: { instance_id: 21, status: "withdrawn" },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status_code: 200,
|
||||
data: { instance_id: 21, revoked_keys: ["knowledge"] },
|
||||
});
|
||||
|
||||
await expect(withdrawApprovalInstanceApi(21, { reason: "cancel" })).resolves.toEqual({
|
||||
instance_id: 21,
|
||||
status: "withdrawn",
|
||||
});
|
||||
await expect(revokeMenuAccessGrantApi(21, { reason: "cleanup" })).resolves.toEqual({
|
||||
instance_id: 21,
|
||||
revoked_keys: ["knowledge"],
|
||||
});
|
||||
|
||||
expect(mockPost).toHaveBeenNthCalledWith(1, "/api/v1/approval/instances/21/withdraw", {
|
||||
reason: "cancel",
|
||||
});
|
||||
expect(mockPost).toHaveBeenNthCalledWith(2, "/api/v1/approval/menu-access/21/revoke-grant", {
|
||||
reason: "cleanup",
|
||||
});
|
||||
});
|
||||
|
||||
it("submits menu access applications", async () => {
|
||||
mockPost.mockResolvedValue({
|
||||
status_code: 200,
|
||||
data: { decision: "pending", instance_id: 31 },
|
||||
});
|
||||
|
||||
await expect(applyMenuAccessApi({
|
||||
menu_key: "knowledge_space",
|
||||
menu_name: "知识库",
|
||||
})).resolves.toEqual({
|
||||
decision: "pending",
|
||||
instance_id: 31,
|
||||
});
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith("/api/v1/approval/menu-access/apply", {
|
||||
menu_key: "knowledge_space",
|
||||
menu_name: "知识库",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,162 +0,0 @@
|
||||
import request from "~/api/request";
|
||||
import {
|
||||
authorizeChannelApi,
|
||||
canEditChannelSettings,
|
||||
canManageChannelPermissions,
|
||||
ChannelRole,
|
||||
getChannelsApi,
|
||||
getChannelGrantSubjectsUsersApi,
|
||||
getChannelPermissionsApi,
|
||||
SortType,
|
||||
} from "./channels";
|
||||
|
||||
jest.mock("~/api/request", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockGet = request.get as jest.Mock;
|
||||
const mockPost = request.post as jest.Mock;
|
||||
|
||||
describe("channel permission APIs", () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset();
|
||||
mockPost.mockReset();
|
||||
});
|
||||
|
||||
it("uses channel manager permissions endpoint", async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
status_code: 200,
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
subject_type: "user",
|
||||
subject_id: 2,
|
||||
subject_name: "Alice",
|
||||
relation: "viewer",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await expect(getChannelPermissionsApi("channel-1")).resolves.toHaveLength(1);
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
"/api/v1/channel/manager/channel-1/permissions",
|
||||
{ skip403Redirect: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("uses channel manager authorize endpoint", async () => {
|
||||
mockPost.mockResolvedValue({ status_code: 200, data: null });
|
||||
|
||||
await authorizeChannelApi("channel-1", {
|
||||
grants: [{ subject_type: "user", subject_id: 2, relation: "viewer" }],
|
||||
revokes: [],
|
||||
});
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
"/api/v1/channel/manager/channel-1/authorize",
|
||||
{
|
||||
grants: [{ subject_type: "user", subject_id: 2, relation: "viewer" }],
|
||||
revokes: [],
|
||||
},
|
||||
{ skip403Redirect: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("uses channel manager grant subjects endpoint", async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
status_code: 200,
|
||||
data: { data: [{ user_id: 2, user_name: "Alice" }] },
|
||||
});
|
||||
|
||||
await expect(
|
||||
getChannelGrantSubjectsUsersApi(
|
||||
"channel-1",
|
||||
{ keyword: "ali", page: 2, page_size: 50 },
|
||||
{ signal: undefined },
|
||||
),
|
||||
).resolves.toEqual([{ user_id: 2, user_name: "Alice" }]);
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
"/api/v1/channel/manager/channel-1/grant-subjects/users",
|
||||
{
|
||||
params: { keyword: "ali", page: 2, page_size: 50 },
|
||||
skip403Redirect: true,
|
||||
signal: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("maps channel relation ahead of legacy user role", async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "channel-1",
|
||||
name: "资讯频道",
|
||||
source_list: [],
|
||||
visibility: "public",
|
||||
is_released: true,
|
||||
user_role: "member",
|
||||
relation: "editor",
|
||||
permission_ids: ["view_channel", "edit_channel"],
|
||||
is_pinned: false,
|
||||
create_time: "2026-05-28T00:00:00Z",
|
||||
latest_article_update_time: "2026-05-28T01:00:00Z",
|
||||
unread_count: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const channels = await getChannelsApi({
|
||||
type: "subscribed",
|
||||
sortBy: SortType.RECENT_UPDATE,
|
||||
});
|
||||
|
||||
expect(channels[0].role).toBe("editor");
|
||||
expect(channels[0].permissionIds).toEqual(["view_channel", "edit_channel"]);
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
"/api/v1/channel/manager/my_channels",
|
||||
{
|
||||
params: {
|
||||
query_type: "followed",
|
||||
sort_by: SortType.RECENT_UPDATE,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("channel relation helpers", () => {
|
||||
it("allows editor to edit channel settings without managing permissions", () => {
|
||||
expect(canEditChannelSettings("owner")).toBe(true);
|
||||
expect(canEditChannelSettings("manager")).toBe(true);
|
||||
expect(canEditChannelSettings("editor")).toBe(true);
|
||||
expect(canEditChannelSettings(ChannelRole.CREATOR)).toBe(true);
|
||||
expect(canEditChannelSettings(ChannelRole.ADMIN)).toBe(true);
|
||||
expect(canEditChannelSettings("viewer")).toBe(false);
|
||||
expect(canEditChannelSettings(ChannelRole.MEMBER)).toBe(false);
|
||||
});
|
||||
|
||||
it("uses permission ids ahead of role for channel settings", () => {
|
||||
expect(canEditChannelSettings("manager", ["view_channel"])).toBe(false);
|
||||
expect(canEditChannelSettings("viewer", ["view_channel", "edit_channel"])).toBe(true);
|
||||
});
|
||||
|
||||
it("allows new owner/manager and legacy creator/admin to manage permissions", () => {
|
||||
expect(canManageChannelPermissions("owner")).toBe(true);
|
||||
expect(canManageChannelPermissions("manager")).toBe(true);
|
||||
expect(canManageChannelPermissions(ChannelRole.CREATOR)).toBe(true);
|
||||
expect(canManageChannelPermissions(ChannelRole.ADMIN)).toBe(true);
|
||||
expect(canManageChannelPermissions("editor")).toBe(false);
|
||||
expect(canManageChannelPermissions("viewer")).toBe(false);
|
||||
expect(canManageChannelPermissions(ChannelRole.MEMBER)).toBe(false);
|
||||
});
|
||||
|
||||
it("uses permission ids ahead of role for member management", () => {
|
||||
expect(canManageChannelPermissions("manager", ["view_channel", "edit_channel"])).toBe(false);
|
||||
expect(canManageChannelPermissions("viewer", ["view_channel", "manage_channel_user"])).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -133,6 +133,9 @@ export interface ChatMessage {
|
||||
references?: ReferenceSource[];
|
||||
citations?: ChatCitation[] | null;
|
||||
files?: any[];
|
||||
/** Persisted 点赞/点踩 verdict on this answer row: 0 none / 1 up / 2 down.
|
||||
Seeds the feedback buttons' highlight on history reload. */
|
||||
liked?: number;
|
||||
// --- v2.5 Agent-mode native fields ---
|
||||
/** One of question / agent_answer / agent_thinking / agent_tool_call / task / legacy answer. */
|
||||
category?: string;
|
||||
@@ -242,6 +245,7 @@ function mapAgentResponseItem(row: any): ChatMessage {
|
||||
category,
|
||||
files: Array.isArray(row.files) ? row.files : [],
|
||||
citations: Array.isArray(row.citations) ? row.citations : null,
|
||||
liked: row.liked,
|
||||
};
|
||||
|
||||
if (category === "question" && raw && typeof raw === "object") {
|
||||
@@ -490,6 +494,7 @@ export function parseStreamHistoryItem(raw: StreamHistoryItem): ChatMessage {
|
||||
createdAt: raw.create_time,
|
||||
error: false,
|
||||
flow_name: raw.flow_name,
|
||||
liked: raw.liked,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
import request from "~/api/request";
|
||||
import { batchDeleteApi, batchDownloadApi, createFolderApi, deleteFolderApi, getSquareSpacesApi, renameFolderApi, VisibilityType } from "./knowledge";
|
||||
|
||||
jest.mock("~/api/request", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
postMultiPart: jest.fn(),
|
||||
put: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockGet = request.get as jest.Mock;
|
||||
const mockPost = request.post as jest.Mock;
|
||||
const mockPostMultiPart = request.postMultiPart as jest.Mock;
|
||||
const mockPut = request.put as jest.Mock;
|
||||
const mockDelete = request.delete as jest.Mock;
|
||||
|
||||
describe("getSquareSpacesApi", () => {
|
||||
it("maps pending square items from is_pending when subscription_status is absent", async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
data: {
|
||||
total: 1,
|
||||
data: [
|
||||
{
|
||||
space: {
|
||||
id: 101,
|
||||
name: "Pending space",
|
||||
auth_type: VisibilityType.APPROVAL,
|
||||
user_id: 7,
|
||||
user_name: "owner",
|
||||
is_released: true,
|
||||
},
|
||||
is_pending: true,
|
||||
file_num: 3,
|
||||
follower_num: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getSquareSpacesApi();
|
||||
|
||||
expect(result.data[0]).toMatchObject({
|
||||
id: "101",
|
||||
isPending: true,
|
||||
isFollowed: false,
|
||||
squareStatus: "pending",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("subscribeSpaceApi", () => {
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset();
|
||||
});
|
||||
|
||||
it("returns backend subscription status", async () => {
|
||||
const { subscribeSpaceApi } = await import("./knowledge");
|
||||
mockPost.mockResolvedValue({
|
||||
status_code: 200,
|
||||
data: {
|
||||
status: "pending",
|
||||
space_id: 101,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(subscribeSpaceApi("101")).resolves.toEqual({
|
||||
status: "pending",
|
||||
spaceId: "101",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("unsubscribeSpaceApi", () => {
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset();
|
||||
});
|
||||
|
||||
it("returns backend response so callers can handle business status codes", async () => {
|
||||
const { unsubscribeSpaceApi } = await import("./knowledge");
|
||||
mockPost.mockResolvedValue({
|
||||
status_code: 18071,
|
||||
status_message: "本空间通过部门/用户组授权给你,暂无法退出",
|
||||
data: null,
|
||||
});
|
||||
|
||||
await expect(unsubscribeSpaceApi("101")).resolves.toEqual({
|
||||
status_code: 18071,
|
||||
status_message: "本空间通过部门/用户组授权给你,暂无法退出",
|
||||
data: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFolderApi", () => {
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset();
|
||||
});
|
||||
|
||||
it("rejects backend business errors", async () => {
|
||||
mockPost.mockResolvedValue({
|
||||
status_code: 19000,
|
||||
status_message: "Permission denied",
|
||||
data: null,
|
||||
});
|
||||
|
||||
await expect(createFolderApi("101", { name: "New folder" })).rejects.toThrow("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renameFolderApi", () => {
|
||||
beforeEach(() => {
|
||||
mockPut.mockReset();
|
||||
});
|
||||
|
||||
it("rejects backend business errors", async () => {
|
||||
mockPut.mockResolvedValue({
|
||||
status_code: 19000,
|
||||
status_message: "Permission denied",
|
||||
data: null,
|
||||
});
|
||||
|
||||
await expect(renameFolderApi("101", "202", "Renamed")).rejects.toThrow("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteFolderApi", () => {
|
||||
beforeEach(() => {
|
||||
mockDelete.mockReset();
|
||||
});
|
||||
|
||||
it("rejects backend business errors", async () => {
|
||||
mockDelete.mockResolvedValue({
|
||||
status_code: 19000,
|
||||
status_message: "Permission denied",
|
||||
data: null,
|
||||
});
|
||||
|
||||
await expect(deleteFolderApi("101", "202")).rejects.toThrow("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("batchDeleteApi", () => {
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset();
|
||||
});
|
||||
|
||||
it("rejects backend business errors", async () => {
|
||||
mockPost.mockResolvedValue({
|
||||
status_code: 19000,
|
||||
status_message: "Permission denied",
|
||||
data: null,
|
||||
});
|
||||
|
||||
await expect(batchDeleteApi("101", { folder_ids: [202] })).rejects.toThrow("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("batchDownloadApi", () => {
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset();
|
||||
});
|
||||
|
||||
it("rejects backend business errors", async () => {
|
||||
mockPost.mockResolvedValue({
|
||||
status_code: 19000,
|
||||
status_message: "Permission denied",
|
||||
data: null,
|
||||
});
|
||||
|
||||
await expect(batchDownloadApi("101", { folder_ids: [202] })).rejects.toThrow("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("uploadFileToServerApi", () => {
|
||||
beforeEach(() => {
|
||||
mockPostMultiPart.mockReset();
|
||||
});
|
||||
|
||||
it("rejects backend business errors", async () => {
|
||||
const { uploadFileToServerApi } = await import("./knowledge");
|
||||
mockPostMultiPart.mockResolvedValue({
|
||||
status_code: 19000,
|
||||
status_message: "Permission denied",
|
||||
data: null,
|
||||
});
|
||||
|
||||
await expect(uploadFileToServerApi("101", new File(["x"], "doc.txt"))).rejects.toThrow("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("addFilesApi", () => {
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset();
|
||||
});
|
||||
|
||||
it("rejects backend business errors", async () => {
|
||||
const { addFilesApi } = await import("./knowledge");
|
||||
mockPost.mockResolvedValue({
|
||||
status_code: 19000,
|
||||
status_message: "Permission denied",
|
||||
data: null,
|
||||
});
|
||||
|
||||
await expect(addFilesApi("101", { file_path: ["/tmp/doc.txt"] })).rejects.toThrow("Permission denied");
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,23 @@ export function startLinsight(versionId: string): Promise<any> {
|
||||
});
|
||||
}
|
||||
|
||||
// 灵思任务结果点赞/点踩(liked: 0 未评 / 1 赞 / 2 踩)。
|
||||
// 落库到 linsight_session_version.liked,并汇总进 message_session(后端待接入,见 PRD)。
|
||||
export function likeLinsightVersion(versionId: string, liked: number): Promise<any> {
|
||||
return request.post('/api/v1/linsight/workbench/feedback', {
|
||||
session_version_id: versionId,
|
||||
liked
|
||||
});
|
||||
}
|
||||
|
||||
// 灵思点踩原因,落库到 linsight_session_version.execute_feedback(后端待接入)。
|
||||
export function commentLinsightVersion(versionId: string, comment: string): Promise<any> {
|
||||
return request.post('/api/v1/linsight/workbench/feedback', {
|
||||
session_version_id: versionId,
|
||||
comment
|
||||
});
|
||||
}
|
||||
|
||||
// F035 多轮对话:在已完成的同一会话里追加新一轮(复用同一 session_version + agent thread,保留上下文)
|
||||
export function continueLinsight(session_version_id: string, question: string): Promise<any> {
|
||||
return request.post('/api/v1/linsight/workbench/continue', {
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import request from "~/api/request";
|
||||
import { authorizeResource } from "./permission";
|
||||
|
||||
jest.mock("~/api/request", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockPost = request.post as jest.Mock;
|
||||
|
||||
describe("permission API", () => {
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset();
|
||||
});
|
||||
|
||||
it("rejects business error envelopes from authorizeResource", async () => {
|
||||
mockPost.mockResolvedValue({
|
||||
status_code: 19000,
|
||||
status_message: "Permission denied",
|
||||
data: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
authorizeResource(
|
||||
"knowledge_space",
|
||||
"1",
|
||||
[{ subject_type: "user", subject_id: 2, relation: "viewer" }],
|
||||
[],
|
||||
),
|
||||
).rejects.toThrow("Permission denied");
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ import { ArrowDown } from "lucide-react";
|
||||
import { SendIcon } from "~/components/svg";
|
||||
import { Button, TextareaAutosize } from "~/components/ui";
|
||||
import SpeechToTextComponent from "~/components/Voice/SpeechToText";
|
||||
import { useContainerCompact, TOOLBAR_COMPACT_THRESHOLD } from "~/hooks";
|
||||
import { useGetWorkbenchModelsQuery } from "~/hooks/queries/data-provider";
|
||||
import InputFiles from "~/pages/appChat/components/InputFiles";
|
||||
import { useFileDropAndPaste } from "~/pages/appChat/useFileDropAndPaste";
|
||||
@@ -154,6 +155,10 @@ const AiChatInput = memo(
|
||||
// so reading it from bsConfig would silently fall back to 50MB.
|
||||
const envConfig = useRecoilValue(bishengConfState);
|
||||
|
||||
// Collapse toolbar labels to icons when the toolbar's own width (not the
|
||||
// viewport's) runs short — e.g. once the sidebar opens on a mid-size screen.
|
||||
const { ref: toolbarRef, compact: toolbarCompact } = useContainerCompact(TOOLBAR_COMPACT_THRESHOLD);
|
||||
|
||||
// F035 (PRD §4.1.3): daily "+ → 添加 Skill" picks a skill into the fresh
|
||||
// task session ('new'), then enters task mode (/linsight/new) where the
|
||||
// selection is refilled as a chip. Keyed 'new' to match the landing page.
|
||||
@@ -432,7 +437,7 @@ const AiChatInput = memo(
|
||||
|
||||
<div className="flex h-7 min-h-7 w-full min-w-0 items-center justify-between gap-1 touch-mobile:gap-0.5">
|
||||
{/* Toolbar:flex-1 + overflow-hidden,避免与右侧语音/发送横向重叠 */}
|
||||
<div className="input-bottom-left flex min-w-0 flex-1 items-center gap-1 touch-mobile:-ml-1 touch-mobile:gap-1 touch-mobile:pl-0 overflow-hidden">
|
||||
<div ref={toolbarRef} className="input-bottom-left flex min-w-0 flex-1 items-center gap-1 touch-mobile:-ml-1 touch-mobile:gap-1 touch-mobile:pl-0 overflow-hidden">
|
||||
{/* "+" menu — v2.5: combines file upload + knowledge space +
|
||||
org knowledge base. Renders in place of ChatKnowledge when
|
||||
agent mode is active (which is the v2.5 default). */}
|
||||
@@ -489,6 +494,7 @@ const AiChatInput = memo(
|
||||
variant="knowledge"
|
||||
config={bsConfig}
|
||||
disabled={!!disabled}
|
||||
compact={toolbarCompact}
|
||||
value={selectedOrgKbs}
|
||||
onChange={(val) => {
|
||||
onSelectedOrgKbsChange(val);
|
||||
@@ -504,11 +510,13 @@ const AiChatInput = memo(
|
||||
<AgentToolSelector
|
||||
availableTools={bsConfig.tools}
|
||||
disabled={toolsDisabled}
|
||||
compact={toolbarCompact}
|
||||
/>
|
||||
)}
|
||||
{tools && !agentMode && onSearchTypeChange && (
|
||||
<ChatToolDown
|
||||
config={bsConfig}
|
||||
compact={toolbarCompact}
|
||||
searchType={searchType}
|
||||
setSearchType={(type) => {
|
||||
onSearchTypeChange(type);
|
||||
@@ -528,6 +536,7 @@ const AiChatInput = memo(
|
||||
{taskMode && (
|
||||
<TaskModeToggle
|
||||
active
|
||||
compact={toolbarCompact}
|
||||
onClick={onToggleTaskMode ? onToggleTaskMode : () => navigate('/c/new')}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -83,6 +83,7 @@ function MessageTreeNode({
|
||||
onRegenerate,
|
||||
knowledgeChatLayout,
|
||||
allowExport,
|
||||
allowFeedback,
|
||||
onOpenCitationPanel,
|
||||
activeCitationMessageId,
|
||||
onPreviewFile,
|
||||
@@ -95,6 +96,7 @@ function MessageTreeNode({
|
||||
onPreviewFile?: (file: ArtifactFile) => void;
|
||||
knowledgeChatLayout?: boolean;
|
||||
allowExport?: boolean;
|
||||
allowFeedback?: boolean;
|
||||
onOpenCitationPanel?: (payload: CitationReferencesDesktopPayload) => void;
|
||||
activeCitationMessageId?: string | null;
|
||||
}) {
|
||||
@@ -140,6 +142,7 @@ function MessageTreeNode({
|
||||
setSiblingIdx={setSiblingIdx}
|
||||
knowledgeChatLayout={knowledgeChatLayout}
|
||||
allowExport={allowExport}
|
||||
allowFeedback={allowFeedback}
|
||||
onOpenCitationPanel={onOpenCitationPanel}
|
||||
activeCitationMessageId={activeCitationMessageId}
|
||||
onPreviewFile={onPreviewFile}
|
||||
@@ -154,6 +157,7 @@ function MessageTreeNode({
|
||||
onRegenerate={onRegenerate}
|
||||
knowledgeChatLayout={knowledgeChatLayout}
|
||||
allowExport={allowExport}
|
||||
allowFeedback={allowFeedback}
|
||||
onOpenCitationPanel={onOpenCitationPanel}
|
||||
activeCitationMessageId={activeCitationMessageId}
|
||||
onPreviewFile={onPreviewFile}
|
||||
@@ -283,6 +287,10 @@ export default function AiChatMessages({
|
||||
|
||||
const hasMessages = messages.length > 0;
|
||||
|
||||
// 点赞/点踩 is offered on every real chat surface; the read-only anonymous
|
||||
// share view (which carries a shareToken) opts out.
|
||||
const allowFeedback = !shareToken;
|
||||
|
||||
// --- Empty state ---
|
||||
if (!hasMessages && !isLoading && !hideEmptyState) {
|
||||
return (
|
||||
@@ -408,6 +416,7 @@ export default function AiChatMessages({
|
||||
}
|
||||
knowledgeChatLayout={knowledgeChatLayout}
|
||||
allowExport={allowExport}
|
||||
allowFeedback={allowFeedback}
|
||||
onOpenCitationPanel={onOpenCitationPanel}
|
||||
activeCitationMessageId={activeCitationMessageId}
|
||||
onPreviewFile={onPreviewFile}
|
||||
@@ -425,6 +434,7 @@ export default function AiChatMessages({
|
||||
onRegenerate={onRegenerate}
|
||||
knowledgeChatLayout={knowledgeChatLayout}
|
||||
allowExport={allowExport}
|
||||
allowFeedback={allowFeedback}
|
||||
onOpenCitationPanel={onOpenCitationPanel}
|
||||
activeCitationMessageId={activeCitationMessageId}
|
||||
onPreviewFile={onPreviewFile}
|
||||
|
||||
@@ -21,6 +21,8 @@ import { TaskTurnPanel } from "~/components/Linsight/Execution/TaskTurnPanel";
|
||||
import type { ArtifactFile } from "~/components/Linsight/Artifacts/artifactUtils";
|
||||
import { Avatar, AvatarImage, AvatarName } from "~/components/ui/Avatar";
|
||||
import { TextToSpeechButton } from "~/components/Voice/TextToSpeechButton";
|
||||
import { MessageFeedbackButtons } from "~/components/Chat/MessageFeedbackButtons";
|
||||
import { likeChatApi, disLikeCommentApi } from "~/api/apps";
|
||||
import { useGetBsConfig } from "~/hooks/queries/data-provider";
|
||||
import { useAuthContext } from "~/hooks";
|
||||
import { useMessageSelection } from "~/hooks/useMessageSelection";
|
||||
@@ -134,6 +136,9 @@ interface AiMessageBubbleProps {
|
||||
homepage/task chat opts in; the lightweight knowledge/file/article docks
|
||||
and the share view leave it off. */
|
||||
allowExport?: boolean;
|
||||
/** Show the 点赞/点踩 feedback buttons under assistant answers. Default true;
|
||||
the read-only anonymous share view passes false. */
|
||||
allowFeedback?: boolean;
|
||||
onOpenCitationPanel?: (payload: CitationReferencesDesktopPayload) => void;
|
||||
activeCitationMessageId?: string | null;
|
||||
/** F035: preview a task-turn document in the inline workspace panel (ChatView
|
||||
@@ -328,6 +333,7 @@ const AiMessageBubble = memo(
|
||||
setSiblingIdx,
|
||||
knowledgeChatLayout,
|
||||
allowExport,
|
||||
allowFeedback = true,
|
||||
onOpenCitationPanel,
|
||||
activeCitationMessageId,
|
||||
onPreviewFile,
|
||||
@@ -356,6 +362,7 @@ const AiMessageBubble = memo(
|
||||
setSiblingIdx={setSiblingIdx}
|
||||
knowledgeChatLayout={knowledgeChatLayout}
|
||||
allowExport={allowExport}
|
||||
allowFeedback={allowFeedback}
|
||||
onOpenCitationPanel={onOpenCitationPanel}
|
||||
activeCitationMessageId={activeCitationMessageId}
|
||||
onPreviewFile={onPreviewFile}
|
||||
@@ -483,6 +490,7 @@ function AssistantBubble({
|
||||
setSiblingIdx,
|
||||
knowledgeChatLayout,
|
||||
allowExport,
|
||||
allowFeedback = true,
|
||||
onOpenCitationPanel,
|
||||
activeCitationMessageId,
|
||||
onPreviewFile,
|
||||
@@ -496,6 +504,7 @@ function AssistantBubble({
|
||||
setSiblingIdx?: (idx: number) => void;
|
||||
knowledgeChatLayout?: boolean;
|
||||
allowExport?: boolean;
|
||||
allowFeedback?: boolean;
|
||||
onOpenCitationPanel?: (payload: CitationReferencesDesktopPayload) => void;
|
||||
activeCitationMessageId?: string | null;
|
||||
onPreviewFile?: (file: ArtifactFile) => void;
|
||||
@@ -736,6 +745,18 @@ function AssistantBubble({
|
||||
messageId={message.messageId || ""}
|
||||
text={regularContent}
|
||||
/>
|
||||
{/* 点赞/点踩 — the answer persists as a chatmessage row, so
|
||||
reuse the existing /liked + /chat/comment endpoints keyed
|
||||
by message_id. Hidden on the read-only share view. */}
|
||||
{allowFeedback && message.messageId && (
|
||||
<MessageFeedbackButtons
|
||||
liked={message.liked}
|
||||
onLike={(liked) => likeChatApi(message.messageId, liked)}
|
||||
onDislikeComment={(comment) =>
|
||||
disLikeCommentApi(message.messageId, comment)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -71,15 +71,18 @@ const AiModelSelect = memo(
|
||||
very long ones. `auto` (see SelectContent) keeps the popup
|
||||
from being forced to the trigger's width. No flash on open:
|
||||
the model list is already in memory via `options`. */}
|
||||
<SelectContent auto className="bg-white w-auto min-w-[100px] max-w-[280px]">
|
||||
<SelectContent auto className="bg-white w-auto min-w-[100px] max-w-[240px]">
|
||||
{uniqueOptions.map((opt) => (
|
||||
<SelectItem key={opt.id + ""} value={opt.id + ""} textValue={opt.displayName}>
|
||||
<div className="flex min-w-0 flex-col py-0.5">
|
||||
<span>{opt.displayName}</span>
|
||||
<div className="flex min-w-0 items-center py-0.5">
|
||||
<span className="shrink-0">{opt.displayName}</span>
|
||||
{opt.description && (
|
||||
<span className="mt-0.5 whitespace-normal break-words text-xs text-gray-400">
|
||||
{opt.description}
|
||||
</span>
|
||||
<>
|
||||
<span className="mx-1.5 h-3 w-px shrink-0 bg-[#E5E6EB]" />
|
||||
<span className="min-w-0 truncate text-xs font-normal text-[#999999]">
|
||||
{opt.description}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
||||
@@ -321,11 +321,11 @@ const ChatView = ({ id = '', index = 0, shareToken = '' }: { id?: string, index?
|
||||
landingResizeObserverRef.current = ro;
|
||||
}, []);
|
||||
|
||||
// Mobile only: the landing parent is `h-full` (= the visible scroll-area
|
||||
// height, header/banner already excluded). We center the welcome block at
|
||||
// its 50% mark and place the apps `parentH/2 + blockH/2 + 40px` from the top
|
||||
// — i.e. exactly 40px below the centered input — instead of `vh`, which on
|
||||
// mobile resolves below the visual center (layout-viewport relative).
|
||||
// H5 shell (≤767) only: the landing parent is a definite `h-full` box (= the
|
||||
// visible scroll-area height; MobileNav + Banner already excluded). We can't
|
||||
// use `vh` here, and a `%` paddingTop resolves against WIDTH, so the apps
|
||||
// offset is computed in px from this measured height: apps sit 40px below the
|
||||
// welcome block, whose center is pinned at 40% of the region.
|
||||
const landingParentObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const [landingParentHeight, setLandingParentHeight] = useState(0);
|
||||
const landingParentRef = useCallback((el: HTMLDivElement | null) => {
|
||||
@@ -341,6 +341,7 @@ const ChatView = ({ id = '', index = 0, shareToken = '' }: { id?: string, index?
|
||||
landingParentObserverRef.current = ro;
|
||||
}, []);
|
||||
|
||||
|
||||
// F035: task mode is a ROLE permission. The backend folds each role's
|
||||
// menu_ids into web_menu → client `user.plugins`; `linsight_task_mode` is the
|
||||
// workbench-home sub-capability toggled per role in the admin console. When
|
||||
@@ -586,11 +587,12 @@ const ChatView = ({ id = '', index = 0, shareToken = '' }: { id?: string, index?
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex flex-col relative',
|
||||
// Landing (non-messages) layout keeps auto height on desktop so the
|
||||
// vh-positioned welcome block + apps flow naturally. On mobile it
|
||||
// needs a definite height so the landing block's `min-h-full`
|
||||
// centering resolves against the visible scroll-area height.
|
||||
useMessagesLayout ? 'h-full' : 'touch-mobile:h-full'
|
||||
// Landing (non-messages) layout keeps auto height on the desktop
|
||||
// shell (≥768) so the vh-positioned welcome block + apps flow
|
||||
// naturally. The H5 shell (≤767) needs a definite height so the
|
||||
// landing block's `min-h-full` centering resolves against the
|
||||
// visible scroll-area height.
|
||||
useMessagesLayout ? 'h-full' : 'max-md:h-full'
|
||||
)}>
|
||||
{/* Content area: Split into Chat Main and Citation Sidebar */}
|
||||
{isLoading && conversationId !== 'new' ? (
|
||||
@@ -796,33 +798,26 @@ const ChatView = ({ id = '', index = 0, shareToken = '' }: { id?: string, index?
|
||||
{citationPanelElement}
|
||||
</div>
|
||||
) : (
|
||||
/* Landing page branch — welcome + input are pinned to viewport
|
||||
vertical center via absolute positioning (top:50% + translateY).
|
||||
Recommended apps sit exactly 40px below the input by using
|
||||
`paddingTop: calc(50vh + landingHalfHeight + 40px)`, where
|
||||
`landingBlockHeight` is measured live with a ResizeObserver.
|
||||
When total content exceeds viewport, the parent's
|
||||
overflow-y-auto handles scrolling — the centered block scrolls
|
||||
up with the document as expected.
|
||||
/* Landing page branch — the welcome block is pinned to the
|
||||
region's vertical center via absolute positioning, so its
|
||||
position is INDEPENDENT of whether recommended apps exist
|
||||
(apps just flow below it and scroll if they overflow).
|
||||
|
||||
Mobile (touch-mobile, ≤1023px) can NOT use `vh`: the MobileNav
|
||||
header + Banner sit above this scroll container, so `45vh`
|
||||
(layout-viewport relative) resolves below the visible center.
|
||||
Instead the parent gets a definite `h-full` (= the visible
|
||||
scroll-area height) so the welcome block sits at 45% of it
|
||||
(`top-[45%]`, slightly above true center — looks more
|
||||
balanced than dead-center), and the apps sit 40px below the
|
||||
input via a JS-measured offset (see landingParentHeight). */
|
||||
<div ref={landingParentRef} className="relative min-h-full touch-mobile:h-full">
|
||||
{/* Centered: welcome message + input. `top: 50vh` (viewport
|
||||
height), NOT `top: 50%` — the parent's effective height
|
||||
gets stretched by the apps' paddingTop below, so a
|
||||
percentage would resolve to a non-viewport midpoint.
|
||||
On mobile the parent is a fixed `h-full` box, so `top-[45%]`
|
||||
resolves against the visible height (45vh-equivalent). */}
|
||||
≥768 (desktop): center at 45vh; apps sit 40px below via
|
||||
`paddingTop: calc(45vh + halfBlock + 40px)`.
|
||||
|
||||
≤767 (H5 shell): can't use `vh` (MobileNav + Banner sit above
|
||||
this scroll container). The parent is a definite `h-full` box,
|
||||
so the block centers at 35% of it (`top-[35%]`) and the apps
|
||||
offset is computed in px from the measured region height
|
||||
(landingParentHeight) — a `%` paddingTop would resolve against
|
||||
width, not height. */
|
||||
<div ref={landingParentRef} className="relative min-h-full max-md:h-full">
|
||||
{/* Welcome message + input, absolutely centered. ≥768 at 45vh,
|
||||
≤767 at 40% of the definite-height region. */}
|
||||
<div
|
||||
ref={landingBlockRef}
|
||||
className="absolute inset-x-0 top-[45vh] -translate-y-1/2 touch-mobile:top-[45%]"
|
||||
className="absolute inset-x-0 top-[45vh] -translate-y-1/2 max-md:top-[35%]"
|
||||
>
|
||||
{/* F035 Track H (P5): daily/task mode switch removed —
|
||||
task mode is reached via the sidebar "new task" entry
|
||||
@@ -831,7 +826,7 @@ const ChatView = ({ id = '', index = 0, shareToken = '' }: { id?: string, index?
|
||||
|
||||
{/* Input area for landing page */}
|
||||
{!shareToken && (
|
||||
<div className="w-full max-w-[800px] mx-auto px-4 mt-10 touch-mobile:mt-2 touch-mobile:max-w-full pb-3">
|
||||
<div className="w-full max-w-[800px] mx-auto px-4 mt-10 max-md:max-w-full pb-3">
|
||||
<AiChatInput
|
||||
elevated
|
||||
disabled={!bsConfig?.models?.length || !!shareToken}
|
||||
@@ -867,16 +862,15 @@ const ChatView = ({ id = '', index = 0, shareToken = '' }: { id?: string, index?
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recommended apps: 40px below the centered block. The
|
||||
paddingTop pushes apps to (viewport midpoint) + (landing
|
||||
half-height) + 40px = (landing block bottom) + 40px.
|
||||
On mobile `vh` is replaced by the measured visible height
|
||||
(landingParentHeight) so the gap stays exactly 40px below
|
||||
the centered input rather than landing on the next screen. */}
|
||||
{/* Recommended apps: 40px below the centered block, so the
|
||||
block's own position never shifts when apps appear.
|
||||
offset = (block center) + (half block height) + 40px.
|
||||
≥768 block center = 45vh; ≤767 = 40% of the measured region
|
||||
height (px, since `vh`/`%`-padding don't work here). */}
|
||||
<div
|
||||
style={{
|
||||
paddingTop: isTouchLayout
|
||||
? `${landingParentHeight * 0.45 + landingBlockHeight / 2 + 40}px`
|
||||
paddingTop: isH5
|
||||
? `${landingParentHeight * 0.35 + landingBlockHeight / 2 + 40}px`
|
||||
: `calc(45vh + ${landingBlockHeight / 2 + 40}px)`,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface AvailableToolGroup {
|
||||
interface Props {
|
||||
availableTools: AvailableToolGroup[];
|
||||
disabled?: boolean;
|
||||
/** Toolbar out of room (see useContainerCompact): collapse label to icon. */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
function iconForGroup(group: AvailableToolGroup) {
|
||||
@@ -48,7 +50,7 @@ function iconForGroup(group: AvailableToolGroup) {
|
||||
return <Outlined.Hammer className="size-4 text-[#999]" />;
|
||||
}
|
||||
|
||||
export default function AgentToolSelector({ availableTools, disabled }: Props) {
|
||||
export default function AgentToolSelector({ availableTools, disabled, compact = false }: Props) {
|
||||
const localize = useLocalize();
|
||||
const [selected, setSelected] = useRecoilState(store.selectedAgentTools);
|
||||
const [initialized, setInitialized] = useRecoilState(store.agentToolsInitialized);
|
||||
@@ -129,11 +131,12 @@ export default function AgentToolSelector({ availableTools, disabled }: Props) {
|
||||
<div className="relative shrink-0">
|
||||
<ApiAppIcon size="15" className={cn("shrink-0", isActive ? "text-blue-500" : "text-[#999999]")} strokeWidth={1.5} />
|
||||
</div>
|
||||
{/* Mobile: collapse to icon + chevron only to save horizontal space. */}
|
||||
<span className="text-[14px] font-normal truncate min-w-0 max-w-[min(20vw,60px)] touch-mobile:hidden">
|
||||
{localize("com_tools_title")}
|
||||
{/* {isActive ? ` (${activeCount})` : ""} */}
|
||||
</span>
|
||||
{/* Compact: collapse to icon + chevron only to save horizontal space. */}
|
||||
{!compact && (
|
||||
<span className="text-[14px] font-normal truncate min-w-0 max-w-[min(20vw,60px)]">
|
||||
{localize("com_tools_title")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-white rounded-[8px] w-[200px] max-h-[320px] overflow-y-auto">
|
||||
|
||||
@@ -28,11 +28,14 @@ export const ChatToolDown = ({
|
||||
searchType,
|
||||
setSearchType,
|
||||
disabled,
|
||||
compact = false,
|
||||
}: {
|
||||
config?: BsConfig;
|
||||
searchType: string;
|
||||
setSearchType: (type: string) => void;
|
||||
disabled: boolean;
|
||||
/** Toolbar out of room (see useContainerCompact): collapse label to icon. */
|
||||
compact?: boolean;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
@@ -42,21 +45,25 @@ export const ChatToolDown = ({
|
||||
<Select disabled={disabled}>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"h-7 rounded-full px-2 data-[state=open]:border-blue-500 touch-mobile:px-1.5",
|
||||
"h-7 rounded-full px-2 data-[state=open]:border-blue-500",
|
||||
compact && "px-1.5",
|
||||
searchType === "netSearch" && "bg-blue-100"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-2 touch-mobile:gap-1",
|
||||
"flex items-center",
|
||||
compact ? "gap-1" : "gap-2",
|
||||
searchType === "netSearch" && "text-blue-600"
|
||||
)}
|
||||
>
|
||||
<Settings2Icon size="16" />
|
||||
{/* Mobile: collapse to icon + chevron only to save horizontal space. */}
|
||||
<span className="text-xs font-normal truncate min-w-0 max-w-[min(36vw,140px)] touch-mobile:max-w-[min(18vw,56px)] touch-mobile:hidden">
|
||||
{localize("com_tools_title")}
|
||||
</span>
|
||||
{/* Compact: collapse to icon + chevron only to save horizontal space. */}
|
||||
{!compact && (
|
||||
<span className="text-xs font-normal truncate min-w-0 max-w-[min(36vw,140px)]">
|
||||
{localize("com_tools_title")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-white rounded-[8px] w-52">
|
||||
@@ -124,10 +131,10 @@ export const LinsiTools = ({ tools, setTools }) => {
|
||||
|
||||
return (
|
||||
<Select>
|
||||
<SelectTrigger className="h-7 rounded-full px-2 bg-white dark:bg-transparent data-[state=open]:border-blue-500 touch-mobile:px-1.5">
|
||||
<div className={cn("flex gap-2 touch-mobile:gap-1", active && "text-blue-600")}>
|
||||
<SelectTrigger className="h-7 rounded-full px-2 bg-white dark:bg-transparent data-[state=open]:border-blue-500 max-md:px-1.5">
|
||||
<div className={cn("flex gap-2 max-md:gap-1", active && "text-blue-600")}>
|
||||
<Settings2Icon size="16" />
|
||||
<span className="text-xs font-normal truncate min-w-0 max-w-[min(36vw,140px)] touch-mobile:max-w-[min(18vw,56px)]">
|
||||
<span className="text-xs font-normal truncate min-w-0 max-w-[min(36vw,140px)] max-md:max-w-[min(18vw,56px)]">
|
||||
{localize("com_tools_title")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -318,6 +318,7 @@ export const ChatKnowledge = ({
|
||||
renderSkillSubmenu,
|
||||
taskModeActive = false,
|
||||
skillSelected = false,
|
||||
compact = false,
|
||||
}: {
|
||||
/** Controls the trigger button and which menu sections render:
|
||||
* - 'plus' → "+" trigger; file-upload + task-mode (+ optional add-skill) sections.
|
||||
@@ -342,6 +343,8 @@ export const ChatKnowledge = ({
|
||||
taskModeActive?: boolean;
|
||||
/** F035: tint the "添加技能" icon brand-blue once at least one skill is picked. */
|
||||
skillSelected?: boolean;
|
||||
/** Toolbar out of room (see useContainerCompact): collapse label to icon. */
|
||||
compact?: boolean;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const PAGE_SIZE = 20;
|
||||
@@ -608,9 +611,9 @@ export const ChatKnowledge = ({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Mobile: collapse to icon + chevron only to save horizontal
|
||||
space in the input toolbar. */}
|
||||
<span className="touch-mobile:hidden">{localize('com_ui_knowledge_space')}</span>
|
||||
{/* Compact: collapse to icon + chevron only to save
|
||||
horizontal space in the input toolbar. */}
|
||||
{!compact && <span>{localize('com_ui_knowledge_space')}</span>}
|
||||
<Outlined.Down size={16} className={cn("text-[#999] transition-transform duration-200", rootOpen && "rotate-180")} />
|
||||
</button>
|
||||
) : (
|
||||
|
||||
@@ -29,29 +29,29 @@ export default function Landing({ Header, isNew, hideSubtitle = false }: {
|
||||
return (
|
||||
<div className={`relative ${!isNew ? 'h-full' : ''}`}>
|
||||
<div className="absolute left-0 right-0">{Header != null ? Header : null}</div>
|
||||
<div className="flex h-full flex-col items-center justify-center touch-mobile:justify-start touch-mobile:pt-2 touch-mobile:pb-4 px-4">
|
||||
{/* Hero: stack vertically on 576 稿 */}
|
||||
<div className="flex flex-col touch-mobile:flex-col items-center gap-3 touch-mobile:gap-3 touch-desktop:flex-row touch-desktop:gap-4">
|
||||
<div className="flex h-full flex-col items-center justify-center max-md:justify-start max-md:pt-2 max-md:pb-4 px-4">
|
||||
{/* Hero: row on ≥768 (matches the desktop shell), stacked only on the H5 shell (≤767) */}
|
||||
<div className="flex flex-col items-center gap-3 md:flex-row md:gap-4">
|
||||
{bsConfig?.assistantIcon?.image && (
|
||||
<img
|
||||
className="overflow-hidden touch-mobile:w-14 touch-mobile:h-14 w-[52px] h-[52px] object-contain shrink-0"
|
||||
className="overflow-hidden w-[52px] h-[52px] object-contain shrink-0"
|
||||
src={__APP_ENV__.BASE_URL + bsConfig.assistantIcon.image}
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
<h2 className="max-w-[75vh] touch-mobile:max-w-full text-center text-xl touch-mobile:font-semibold touch-mobile:text-[#1d2129] touch-mobile:leading-snug font-medium dark:text-white touch-desktop:text-2xl px-0">
|
||||
<h2 className="max-w-full md:max-w-[75vw] text-center text-xl md:text-2xl font-semibold md:font-medium leading-snug md:leading-8 text-[#1d2129] dark:text-white px-0">
|
||||
{bsConfig?.welcomeMessage}
|
||||
</h2>
|
||||
</div>
|
||||
{!hideSubtitle && (
|
||||
<div className="max-w-lg touch-mobile:max-w-full text-center mt-[26px] touch-mobile:mt-3 text-sm touch-mobile:text-[13px] font-normal text-gray-500 touch-mobile:text-[#4e5969] leading-5 touch-mobile:leading-relaxed">
|
||||
<div className="max-w-lg text-center mt-[26px] text-sm font-normal text-gray-500 leading-5">
|
||||
{bsConfig?.functionDescription}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conversation starters */}
|
||||
{conversation_starters.length > 0 && (
|
||||
<div className="mt-6 touch-mobile:mt-5 w-full max-w-2xl flex flex-wrap justify-center gap-2 touch-mobile:gap-2 touch-mobile:px-0">
|
||||
<div className="mt-5 md:mt-6 w-full max-w-2xl flex flex-wrap justify-center gap-2">
|
||||
{conversation_starters
|
||||
.slice(0, Constants.MAX_CONVO_STARTERS)
|
||||
.map((text: string, index: number) => (
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Shared 点赞/点踩 (thumbs up / down) feedback control.
|
||||
*
|
||||
* Reused by every AI answer surface (daily chat, knowledge-space 知源, channel
|
||||
* subscription via AiMessageBubble; linsight task mode via ResultPanel). The
|
||||
* button visuals match the appChat MessageButtons / AiMessageBubble action row
|
||||
* (size-6 hit area, 14px bisheng-icons Outlined glyph, #818181 idle /
|
||||
* brand-500 active) so the whole action row reads as one consistent set.
|
||||
*
|
||||
* State is optimistic-local: the parent injects the persistence via `onLike`
|
||||
* (thumbs verdict) and `onDislikeComment` (reason text). `liked` seeds the
|
||||
* initial highlight and re-syncs when history reload delivers the stored value.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Outlined } from "bisheng-icons";
|
||||
import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, Textarea } from "~/components";
|
||||
import { useToastContext } from "~/Providers";
|
||||
import { useLocalize } from "~/hooks";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
// 0 = unrated / 1 = thumbs up / 2 = thumbs down (mirrors chatmessage.liked)
|
||||
type ThumbsState = 0 | 1 | 2;
|
||||
|
||||
const ACTION_BTN =
|
||||
"flex size-6 items-center justify-center rounded-[6px] transition-colors hover:bg-[#F7F7F7]";
|
||||
|
||||
interface MessageFeedbackButtonsProps {
|
||||
/** Initial / persisted verdict: 0 none, 1 up, 2 down. */
|
||||
liked?: number;
|
||||
/** Persist the new verdict (0/1/2). Called on every toggle. */
|
||||
onLike: (liked: number) => void;
|
||||
/** Persist the free-text reason when the user submits a dislike comment. */
|
||||
onDislikeComment?: (comment: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MessageFeedbackButtons({
|
||||
liked = 0,
|
||||
onLike,
|
||||
onDislikeComment,
|
||||
className,
|
||||
}: MessageFeedbackButtonsProps) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const [state, setState] = useState<ThumbsState>(liked as ThumbsState);
|
||||
const [commentOpen, setCommentOpen] = useState(false);
|
||||
const [commentError, setCommentError] = useState(false);
|
||||
const commentRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
// Re-sync when the persisted value arrives/changes (e.g. history reload).
|
||||
useEffect(() => {
|
||||
setState(liked as ThumbsState);
|
||||
}, [liked]);
|
||||
|
||||
const handleClick = (type: ThumbsState) => {
|
||||
setState((prev) => {
|
||||
const next: ThumbsState = prev === type ? 0 : type;
|
||||
onLike(next);
|
||||
// Prompt for a reason only when newly disliking.
|
||||
if (next === 2 && onDislikeComment) {
|
||||
setCommentError(false);
|
||||
setCommentOpen(true);
|
||||
if (commentRef.current) commentRef.current.value = "";
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmitComment = () => {
|
||||
const value = commentRef.current?.value?.trim();
|
||||
if (!value) {
|
||||
showToast?.({ message: localize("com_feedback_required"), status: "warning" });
|
||||
setCommentError(true);
|
||||
return;
|
||||
}
|
||||
onDislikeComment?.(value);
|
||||
setCommentOpen(false);
|
||||
setCommentError(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn("flex gap-1", className)}>
|
||||
<button
|
||||
type="button"
|
||||
className={ACTION_BTN}
|
||||
onClick={() => handleClick(1)}
|
||||
title="点赞"
|
||||
aria-label="点赞"
|
||||
aria-pressed={state === 1}
|
||||
>
|
||||
<Outlined.ThumbsUp
|
||||
size={14}
|
||||
className={cn(state === 1 ? "text-blue-500" : "text-[#818181]")}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={ACTION_BTN}
|
||||
onClick={() => handleClick(2)}
|
||||
title="点踩"
|
||||
aria-label="点踩"
|
||||
aria-pressed={state === 2}
|
||||
>
|
||||
<Outlined.ThumbsDown
|
||||
size={14}
|
||||
className={cn(state === 2 ? "text-blue-500" : "text-[#818181]")}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{onDislikeComment && (
|
||||
<Dialog open={commentOpen} onOpenChange={setCommentOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{localize("com_feedback_title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div>
|
||||
<Textarea
|
||||
ref={commentRef}
|
||||
maxLength={9999}
|
||||
className={cn("textarea", commentError && "border border-red-400")}
|
||||
/>
|
||||
<div className="flex justify-end gap-4 mt-4">
|
||||
<Button className="px-11" variant="outline" onClick={() => setCommentOpen(false)}>
|
||||
{localize("com_ui_cancel")}
|
||||
</Button>
|
||||
<Button className="px-11" onClick={handleSubmitComment}>
|
||||
{localize("com_ui_submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
/**
|
||||
* DeepStepGroup — regression guard for the anti-flicker fix + the quiet-open fold.
|
||||
*
|
||||
* Root cause the anti-flicker fix locks down: the group's live-vs-done UI used to be
|
||||
* driven by `group.running` ("any step mid-flight"), which toggles true↔false many
|
||||
* times within ONE live episode — thinking frames ship as `status:'end'` (never
|
||||
* running) and a tool step is running only between its start/end frames. Binding the
|
||||
* 正在/已 label (and, before b1ff8967a, the fold) to it made the group flicker on
|
||||
* every tool call ("内容上下反复跳跃").
|
||||
*
|
||||
* Current contract these tests assert:
|
||||
* - the 正在/已 LABEL follows the stable `active` prop (the live tail episode, owned
|
||||
* by ExecutionTimeline), NOT `group.running`;
|
||||
* - the FOLD defaults COLLAPSED for every group — even the live tail — so task mode
|
||||
* opens quiet (b1ff8967a); it is bound to neither `active` nor `group.running`.
|
||||
*/
|
||||
import { render } from '@testing-library/react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { DeepStepGroup } from './DeepStepGroup';
|
||||
import type { DeepStepGroup as DeepStepGroupData, MergedStep } from './stepUtils';
|
||||
|
||||
// useLocalize → identity so the rendered label IS the i18n key (assertable).
|
||||
jest.mock('~/hooks', () => ({
|
||||
__esModule: true,
|
||||
useLocalize: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
// jsdom has no IntersectionObserver; the sticky-header pin detection news one up
|
||||
// on mount. Stub it as an inert no-op so the group renders.
|
||||
beforeAll(() => {
|
||||
class MockIO {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
(window as any).IntersectionObserver = MockIO;
|
||||
(global as any).IntersectionObserver = MockIO;
|
||||
});
|
||||
|
||||
const RUNNING_LABEL = 'com_linsight_deep_thinking_running';
|
||||
const DONE_LABEL = 'com_linsight_deep_thinking_done';
|
||||
|
||||
/** One thinking step. Thinking is the minimal render tree (no tool/knowledge child
|
||||
* rows, so no extra hook deps) and always lands as a `status:'end'` frame, i.e.
|
||||
* running=false — exactly the case the old code mis-collapsed. */
|
||||
function thinkingStep(callId: string, output: string): MergedStep {
|
||||
return {
|
||||
callId,
|
||||
taskId: 't',
|
||||
name: 'thinking',
|
||||
stepType: 'thinking',
|
||||
running: false,
|
||||
callReason: '',
|
||||
params: null,
|
||||
output,
|
||||
namespace: null,
|
||||
extraInfo: {},
|
||||
// far-past second-level stamps so the live ticker measures elapsedMs > 0
|
||||
// (the label keeps its 用时/已用 clause instead of the 0s compact form).
|
||||
startedAt: 1000,
|
||||
endedAt: 1200,
|
||||
raw: {} as any,
|
||||
};
|
||||
}
|
||||
|
||||
function makeGroup(running: boolean, steps: MergedStep[]): DeepStepGroupData {
|
||||
return { kind: 'deep_step_group', steps, startedAt: 1000, endedAt: 1200, running };
|
||||
}
|
||||
|
||||
function renderGroup(group: DeepStepGroupData, active: boolean) {
|
||||
return render(
|
||||
<RecoilRoot>
|
||||
<DeepStepGroup group={group} active={active} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
}
|
||||
|
||||
/** The group's own fold container is the only element carrying an inline
|
||||
* grid-template-rows (thinking-only group renders no nested collapsibles). */
|
||||
function foldRows(container: HTMLElement): string {
|
||||
const grid = container.querySelector('[style*="grid-template-rows"]') as HTMLElement;
|
||||
return grid.style.gridTemplateRows;
|
||||
}
|
||||
|
||||
describe('DeepStepGroup — label follows `active`; fold opens quiet, bound to neither', () => {
|
||||
it('active=true shows the running label, yet opens collapsed (quiet) even as the live tail', () => {
|
||||
const { container, getByText } = renderGroup(
|
||||
makeGroup(false, [thinkingStep('c1', 'reasoning…')]),
|
||||
true,
|
||||
);
|
||||
getByText(RUNNING_LABEL); // label follows active, not the (false) group.running
|
||||
// Every group — even the live tail — defaults collapsed so task mode opens
|
||||
// quiet (b1ff8967a); the collapsed header still streams via the NarrationTicker.
|
||||
expect(foldRows(container)).toBe('0fr');
|
||||
});
|
||||
|
||||
it('active=false collapses and shows the done label even while group.running=true (the regression guard)', () => {
|
||||
// group.running=true would, under the old code, force-expand + "正在" —
|
||||
// the exact per-tool-call flicker we removed.
|
||||
const { container, getByText, queryByText } = renderGroup(
|
||||
makeGroup(true, [thinkingStep('c1', 'reasoning…')]),
|
||||
false,
|
||||
);
|
||||
getByText(DONE_LABEL);
|
||||
expect(queryByText(RUNNING_LABEL)).toBeNull();
|
||||
expect(foldRows(container)).toBe('0fr'); // collapsed
|
||||
});
|
||||
|
||||
it('toggling group.running while active stays true changes neither the label nor the (collapsed) fold (anti-flicker)', () => {
|
||||
const steps = [thinkingStep('c1', 'reasoning…')];
|
||||
const { container, rerender, getByText } = render(
|
||||
<RecoilRoot>
|
||||
<DeepStepGroup group={makeGroup(false, steps)} active={true} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
getByText(RUNNING_LABEL);
|
||||
expect(foldRows(container)).toBe('0fr');
|
||||
|
||||
// a tool call starts → group.running flips true … (active unchanged)
|
||||
rerender(
|
||||
<RecoilRoot>
|
||||
<DeepStepGroup group={makeGroup(true, steps)} active={true} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
getByText(RUNNING_LABEL);
|
||||
expect(foldRows(container)).toBe('0fr');
|
||||
|
||||
// … and ends → group.running flips back to false (active still unchanged)
|
||||
rerender(
|
||||
<RecoilRoot>
|
||||
<DeepStepGroup group={makeGroup(false, steps)} active={true} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
getByText(RUNNING_LABEL);
|
||||
expect(foldRows(container)).toBe('0fr'); // fold never flips with group.running
|
||||
});
|
||||
});
|
||||
@@ -230,7 +230,11 @@ export function ExecutionFlow({ versionId, conversationId, isSharePage = false,
|
||||
{/* ── artifacts area (P4): report link / answer markdown / file
|
||||
card — lifted into the terminal ResultPanel (peak-end). ── */}
|
||||
{completed && (
|
||||
<ResultPanel>
|
||||
<ResultPanel
|
||||
versionId={versionId}
|
||||
liked={linsight?.liked ?? undefined}
|
||||
allowFeedback={!readOnly && !isSharePage}
|
||||
>
|
||||
<ResultSection
|
||||
answer={linsight?.output_result?.answer}
|
||||
files={fileList}
|
||||
|
||||
@@ -11,14 +11,22 @@
|
||||
import { Outlined } from 'bisheng-icons';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { MessageFeedbackButtons } from '~/components/Chat/MessageFeedbackButtons';
|
||||
import { likeLinsightVersion, commentLinsightVersion } from '~/api/linsight';
|
||||
import { INK } from './execTokens';
|
||||
|
||||
interface ResultPanelProps {
|
||||
/** the terminal deliverable (typically <ResultSection />) */
|
||||
children: ReactNode;
|
||||
/** linsight session_version id — the feedback target */
|
||||
versionId?: string;
|
||||
/** persisted 点赞/点踩 verdict: 0 none / 1 up / 2 down */
|
||||
liked?: number;
|
||||
/** show 点赞/点踩 (off for read-only / share view) */
|
||||
allowFeedback?: boolean;
|
||||
}
|
||||
|
||||
export function ResultPanel({ children }: ResultPanelProps) {
|
||||
export function ResultPanel({ children, versionId, liked, allowFeedback }: ResultPanelProps) {
|
||||
const localize = useLocalize();
|
||||
// peak-end (§2.6): a DoubleCheck Ink "task completed" header marks the
|
||||
// terminal state and lifts the deliverable out of the homogeneous flow; body
|
||||
@@ -37,6 +45,18 @@ export function ResultPanel({ children }: ResultPanelProps) {
|
||||
</span>
|
||||
</div>
|
||||
{children}
|
||||
{/* 点赞/点踩 — the task result lives in linsight_session_version, so
|
||||
persist via the linsight feedback endpoint keyed by version id
|
||||
(best-effort: optimistic UI, backend wiring per PRD). */}
|
||||
{allowFeedback && versionId && (
|
||||
<div className="mt-3">
|
||||
<MessageFeedbackButtons
|
||||
liked={liked}
|
||||
onLike={(l) => { void likeLinsightVersion(versionId, l).catch(() => {}); }}
|
||||
onDislikeComment={(c) => { void commentLinsightVersion(versionId, c).catch(() => {}); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* TaskErrorCard — friendly rate-limit copy (限流文案统一).
|
||||
*
|
||||
* Verifies the user-visible contract of the rate-limit copy change:
|
||||
* - desc renders the unified "当前使用人数较多,请稍后再试。" in all three locales;
|
||||
* - the suggestion (hint) line is removed — the empty i18n value is hidden by the
|
||||
* component's `{hint && ...}` guard;
|
||||
* - the title is retained;
|
||||
* - quota_exhausted is left untouched (the throttling-vs-billing split) and still
|
||||
* shows the "contact admin to top up" hint.
|
||||
*
|
||||
* Uses the REAL i18n resources (not the usual identity mock) so the key→copy
|
||||
* wiring and the empty-string-hides-hint behaviour are actually exercised.
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import i18n from '~/locales/i18n';
|
||||
import { TaskErrorCard } from './TaskErrorCard';
|
||||
|
||||
// useLocalize → real i18n.t so rendered text is the actual localized copy.
|
||||
jest.mock('~/hooks', () => {
|
||||
const realI18n = require('~/locales/i18n').default;
|
||||
return {
|
||||
__esModule: true,
|
||||
useLocalize: () => (key: string, opts?: any) => realI18n.t(key, opts),
|
||||
};
|
||||
});
|
||||
|
||||
const RATE_LIMIT_DESC: Record<string, string> = {
|
||||
'zh-Hans': '当前使用人数较多,请稍后再试。',
|
||||
en: 'Too many users at the moment. Please try again later.',
|
||||
ja: '現在ご利用が集中しています。しばらくしてからもう一度お試しください。',
|
||||
};
|
||||
|
||||
describe('TaskErrorCard — rate-limit friendly copy', () => {
|
||||
afterAll(async () => {
|
||||
await i18n.changeLanguage('en');
|
||||
});
|
||||
|
||||
it.each(Object.entries(RATE_LIMIT_DESC))('renders the unified rate-limit desc in %s', async (lang, expected) => {
|
||||
await i18n.changeLanguage(lang);
|
||||
render(<TaskErrorCard errorType="rate_limit" detail="raw provider 429 text" />);
|
||||
expect(screen.getByText(expected)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('retains the rate-limit title', async () => {
|
||||
await i18n.changeLanguage('zh-Hans');
|
||||
render(<TaskErrorCard errorType="rate_limit" />);
|
||||
expect(screen.getByText('模型服务繁忙')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('drops the suggestion line for rate_limit (empty hint hidden)', async () => {
|
||||
await i18n.changeLanguage('zh-Hans');
|
||||
render(<TaskErrorCard errorType="rate_limit" />);
|
||||
// the old suggestion copy must be gone ...
|
||||
expect(screen.queryByText(/稍等片刻后重新发起任务/)).not.toBeInTheDocument();
|
||||
// ... and the empty hint key must not leak as raw text either
|
||||
expect(screen.queryByText('com_linsight_error_hint_rate_limit')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps quota_exhausted distinct (top-up hint intact, split not broken)', async () => {
|
||||
await i18n.changeLanguage('zh-Hans');
|
||||
render(<TaskErrorCard errorType="quota_exhausted" />);
|
||||
expect(screen.getByText('模型服务额度已用尽')).toBeInTheDocument();
|
||||
expect(screen.getByText(/联系管理员充值/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -250,7 +250,7 @@ export function TaskTurnPanel({ versionId, conversationId, answer, readOnly = fa
|
||||
document link opens it directly in ChatView's inline workspace
|
||||
panel (preview), replacing the legacy right-side drawer. */}
|
||||
{completed && (
|
||||
<ResultPanel>
|
||||
<ResultPanel versionId={versionId} liked={linsight?.liked ?? undefined} allowFeedback={!readOnly}>
|
||||
<ResultSection
|
||||
answer={linsight.output_result?.answer}
|
||||
files={fileList}
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
/**
|
||||
* timelineMemo — guards the React.memo comparators that keep the task-mode
|
||||
* execution timeline from re-rendering frozen episodes on every WS frame (the fix
|
||||
* for the "用时 N 秒" counter advancing unevenly / skipping seconds under a thinking
|
||||
* token-delta storm). The critical contract: the WS pump rebuilds the whole node
|
||||
* tree with FRESH objects each frame, so an unchanged (frozen) episode must compare
|
||||
* EQUAL across rebuilds (skip re-render), while any real change in the active tail
|
||||
* must compare UNEQUAL (re-render).
|
||||
*/
|
||||
import { deepStepGroupPropsEqual } from './DeepStepGroup';
|
||||
import { toolRowLitePropsEqual } from './ToolRowLite';
|
||||
import type { DeepStepGroup as DeepStepGroupData, MergedStep } from './stepUtils';
|
||||
|
||||
// useLocalize is only called inside the component, but importing DeepStepGroup
|
||||
// pulls the module graph in — mirror the sibling test's hook stub so it resolves.
|
||||
jest.mock('~/hooks', () => ({
|
||||
__esModule: true,
|
||||
useLocalize: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const SHARED_PARAMS = { q: 'a' }; // a stable raw-frame reference (what mergeStepFrames reuses)
|
||||
|
||||
function step(overrides: Partial<MergedStep> = {}): MergedStep {
|
||||
return {
|
||||
callId: 'c1',
|
||||
taskId: 't',
|
||||
name: 'thinking',
|
||||
stepType: 'thinking',
|
||||
running: false,
|
||||
callReason: '',
|
||||
params: null,
|
||||
output: 'abc',
|
||||
namespace: null,
|
||||
extraInfo: {},
|
||||
startedAt: 1000,
|
||||
endedAt: 1200,
|
||||
raw: {} as any,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Clone a step into a NEW object with identical field values — the rebuild case. */
|
||||
function rebuilt(s: MergedStep): MergedStep {
|
||||
return { ...s, extraInfo: {} }; // fresh extraInfo, as mergeStepFrames spreads one each pass
|
||||
}
|
||||
|
||||
function group(steps: MergedStep[], overrides: Partial<DeepStepGroupData> = {}): DeepStepGroupData {
|
||||
return { kind: 'deep_step_group', steps, startedAt: 1000, endedAt: 1200, running: false, ...overrides };
|
||||
}
|
||||
|
||||
describe('deepStepGroupPropsEqual', () => {
|
||||
it('treats a rebuilt-but-unchanged frozen episode as EQUAL (skip re-render)', () => {
|
||||
const s = step({ params: SHARED_PARAMS });
|
||||
const a = { group: group([s]), active: false, compact: false };
|
||||
const b = { group: group([rebuilt(s)]), active: false, compact: false };
|
||||
expect(deepStepGroupPropsEqual(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
it('re-renders when `active` flips (live tail ⇄ done)', () => {
|
||||
const s = step();
|
||||
expect(
|
||||
deepStepGroupPropsEqual(
|
||||
{ group: group([s]), active: true, compact: false },
|
||||
{ group: group([rebuilt(s)]), active: false, compact: false },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('re-renders when a streaming step appends output (length grows)', () => {
|
||||
const s = step({ output: 'abc' });
|
||||
const grown = step({ output: 'abcdef' });
|
||||
expect(
|
||||
deepStepGroupPropsEqual(
|
||||
{ group: group([s]), active: true, compact: false },
|
||||
{ group: group([grown]), active: true, compact: false },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('re-renders when a new step is appended to the episode', () => {
|
||||
const s = step({ callId: 'c1' });
|
||||
expect(
|
||||
deepStepGroupPropsEqual(
|
||||
{ group: group([s]), active: true, compact: false },
|
||||
{ group: group([rebuilt(s), step({ callId: 'c2', name: 'web_search', stepType: 'tool' })]), active: true, compact: false },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('re-renders when a MIDDLE step closes (running true→false) — the subtle case', () => {
|
||||
// c2 is not the last step; a last-step-only signature would miss its close.
|
||||
const mk = (c2Running: boolean) => [
|
||||
step({ callId: 'c1', name: 'thinking' }),
|
||||
step({ callId: 'c2', name: 'web_search', stepType: 'tool', running: c2Running }),
|
||||
step({ callId: 'c3', name: 'thinking' }),
|
||||
];
|
||||
expect(
|
||||
deepStepGroupPropsEqual(
|
||||
{ group: group(mk(true)), active: true, compact: false },
|
||||
{ group: group(mk(false)), active: true, compact: false },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('re-renders when the group clock end stamp changes', () => {
|
||||
const s = step();
|
||||
expect(
|
||||
deepStepGroupPropsEqual(
|
||||
{ group: group([s], { endedAt: 1200 }), active: true, compact: false },
|
||||
{ group: group([rebuilt(s)], { endedAt: 1300 }), active: true, compact: false },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('re-renders when a subagent segment goal changes; equal when same', () => {
|
||||
const s = step();
|
||||
const base = { group: group([s]), active: false, compact: false, subagent: { goal: 'research X', idx: 1 } };
|
||||
expect(
|
||||
deepStepGroupPropsEqual(base, {
|
||||
group: group([rebuilt(s)]),
|
||||
active: false,
|
||||
compact: false,
|
||||
subagent: { goal: 'research Y', idx: 1 },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
deepStepGroupPropsEqual(base, {
|
||||
group: group([rebuilt(s)]),
|
||||
active: false,
|
||||
compact: false,
|
||||
subagent: { goal: 'research X', idx: 1 },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toolRowLitePropsEqual', () => {
|
||||
it('treats a rebuilt-but-unchanged tool row as EQUAL', () => {
|
||||
const s = step({ name: 'web_search', stepType: 'tool', params: SHARED_PARAMS, output: 'res' });
|
||||
expect(toolRowLitePropsEqual({ step: s }, { step: rebuilt(s) })).toBe(true);
|
||||
});
|
||||
|
||||
it('re-renders when the tool step closes (running flip)', () => {
|
||||
const running = step({ name: 'web_search', stepType: 'tool', running: true });
|
||||
const done = step({ name: 'web_search', stepType: 'tool', running: false });
|
||||
expect(toolRowLitePropsEqual({ step: running }, { step: done })).toBe(false);
|
||||
});
|
||||
|
||||
it('re-renders when output streams in (length grows)', () => {
|
||||
const a = step({ name: 'web_search', stepType: 'tool', output: '' });
|
||||
const b = step({ name: 'web_search', stepType: 'tool', output: 'hit list' });
|
||||
expect(toolRowLitePropsEqual({ step: a }, { step: b })).toBe(false);
|
||||
});
|
||||
|
||||
it('re-renders when params first arrive (reference changes)', () => {
|
||||
const a = step({ name: 'web_search', stepType: 'tool', params: null });
|
||||
const b = step({ name: 'web_search', stepType: 'tool', params: SHARED_PARAMS });
|
||||
expect(toolRowLitePropsEqual({ step: a }, { step: b })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -28,9 +28,11 @@ interface KnowledgeSpaceSelectProps {
|
||||
value: TaskModeKnowledgeItem[];
|
||||
disabled?: boolean;
|
||||
onChange: (items: TaskModeKnowledgeItem[]) => void;
|
||||
/** Toolbar out of room (see useContainerCompact): collapse label to icon. */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function KnowledgeSpaceSelect({ value, disabled = false, onChange }: KnowledgeSpaceSelectProps) {
|
||||
export function KnowledgeSpaceSelect({ value, disabled = false, onChange, compact = false }: KnowledgeSpaceSelectProps) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const { data: bsConfig } = useGetBsConfig();
|
||||
@@ -154,7 +156,7 @@ export function KnowledgeSpaceSelect({ value, disabled = false, onChange }: Know
|
||||
WebkitMaskSize: 'contain', maskSize: 'contain',
|
||||
}}
|
||||
/>
|
||||
<span className="truncate max-w-[min(30vw,120px)]">{localize('com_ui_knowledge_space')}</span>
|
||||
{!compact && <span className="truncate">{localize('com_ui_knowledge_space')}</span>}
|
||||
<ChevronDown size={14} className="text-slate-400" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -65,20 +65,23 @@ export function ModelSelector({ value, disabled = false, onChange }: ModelSelect
|
||||
|
||||
return (
|
||||
<Select value={String(value)} disabled={disabled} onValueChange={onChange}>
|
||||
<SelectTrigger className="h-8 w-auto min-w-0 max-w-[min(40vw,220px)] touch-mobile:max-w-[min(40vw,140px)] gap-1 overflow-hidden border-none bg-transparent px-2 text-[#4E5969] shadow-none outline-none hover:bg-black/5 focus:ring-0">
|
||||
<SelectTrigger className="h-8 w-auto min-w-0 max-w-[min(40vw,220px)] max-md:max-w-[min(40vw,140px)] gap-1 overflow-hidden border-none bg-transparent px-2 text-[#4E5969] shadow-none outline-none hover:bg-black/5 focus:ring-0">
|
||||
<span className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[13px] font-normal">
|
||||
{label}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-white max-w-[280px]">
|
||||
<SelectContent className="bg-white max-w-[240px]">
|
||||
{options.map((opt: any) => (
|
||||
<SelectItem key={String(opt.id)} value={String(opt.id)} textValue={opt.displayName ?? opt.name}>
|
||||
<div className="flex min-w-0 flex-col py-0.5">
|
||||
<span>{opt.displayName ?? opt.name}</span>
|
||||
<div className="flex min-w-0 items-center py-0.5">
|
||||
<span className="shrink-0">{opt.displayName ?? opt.name}</span>
|
||||
{opt.description && (
|
||||
<span className="mt-0.5 whitespace-normal break-words text-xs text-gray-400">
|
||||
{opt.description}
|
||||
</span>
|
||||
<>
|
||||
<span className="mx-1.5 h-3 w-px shrink-0 bg-[#E5E6EB]" />
|
||||
<span className="min-w-0 truncate text-xs font-normal text-[#999999]">
|
||||
{opt.description}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '~/components/ui/AlertDialog';
|
||||
import { useGetBsConfig } from '~/hooks/queries/data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { useContainerCompact, useLocalize, TOOLBAR_COMPACT_THRESHOLD } from '~/hooks';
|
||||
import { useLinsightSessionManager } from '~/hooks/useLinsightManager';
|
||||
import InputFiles from '~/pages/appChat/components/InputFiles';
|
||||
import { useFileDropAndPaste } from '~/pages/appChat/useFileDropAndPaste';
|
||||
@@ -73,6 +73,9 @@ interface TaskModeInputProps {
|
||||
|
||||
export function TaskModeInput({ conversationId = 'new', disabled = false, onFollowUp, running = false, onStop }: TaskModeInputProps) {
|
||||
const localize = useLocalize();
|
||||
// Collapse toolbar labels to icons when the toolbar's own width (not the
|
||||
// viewport's) runs short — e.g. once the sidebar opens on a mid-size screen.
|
||||
const { ref: toolbarRef, compact: toolbarCompact } = useContainerCompact(TOOLBAR_COMPACT_THRESHOLD);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { showToast } = useToastContext();
|
||||
@@ -339,7 +342,7 @@ export function TaskModeInput({ conversationId = 'new', disabled = false, onFoll
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex h-8 min-h-8 w-full min-w-0 items-center justify-between gap-1">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
|
||||
<div ref={toolbarRef} className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
|
||||
<PlusMenu
|
||||
disabled={disabled}
|
||||
onUploadFile={() => inputFilesRef.current?.openPicker?.()}
|
||||
@@ -353,14 +356,16 @@ export function TaskModeInput({ conversationId = 'new', disabled = false, onFoll
|
||||
<KnowledgeSpaceSelect
|
||||
value={context.knowledge}
|
||||
disabled={disabled}
|
||||
compact={toolbarCompact}
|
||||
onChange={(knowledge) => setContext((prev) => ({ ...prev, knowledge }))}
|
||||
/>
|
||||
<ToolsSelect
|
||||
tools={context.tools}
|
||||
disabled={disabled}
|
||||
compact={toolbarCompact}
|
||||
onChange={(tools) => setContext((prev) => ({ ...prev, tools }))}
|
||||
/>
|
||||
<TaskModeToggle active disabled={disabled} onClick={handleExitTaskMode} />
|
||||
<TaskModeToggle active disabled={disabled} compact={toolbarCompact} onClick={handleExitTaskMode} />
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
|
||||
@@ -8,25 +8,33 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { Outlined } from 'bisheng-icons';
|
||||
import { useState } from 'react';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import useMediaQuery from '~/hooks/useMediaQuery';
|
||||
import { useLocalize, useMediaQuery } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface TaskModeToggleProps {
|
||||
active: boolean;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
/**
|
||||
* Toolbar ran out of room (measured by the parent, see useContainerCompact):
|
||||
* collapse to icon-only with a persistent exit "x". When compact, the hover
|
||||
* icon-swap is disabled — otherwise it renders a SECOND x next to the
|
||||
* persistent one. Roomy toolbars keep the hover binoculars→x affordance.
|
||||
*/
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function TaskModeToggle({ active, disabled = false, onClick }: TaskModeToggleProps) {
|
||||
export function TaskModeToggle({ active, disabled = false, onClick, compact = false }: TaskModeToggleProps) {
|
||||
const localize = useLocalize();
|
||||
const [hovered, setHovered] = useState(false);
|
||||
// Matches the CSS `touch-mobile` variant (≤1023px), where the label is
|
||||
// hidden and a persistent exit "x" is shown instead. In that layout the
|
||||
// hover icon-swap must be disabled — otherwise it renders a SECOND x next to
|
||||
// the persistent one. Wide screens keep the hover binoculars→x affordance.
|
||||
const isTouchLayout = useMediaQuery('(max-width: 1023px)');
|
||||
const showExit = active && hovered && !isTouchLayout;
|
||||
// Touch devices (iPad, foldables) can't hover, so the binoculars→x swap
|
||||
// never fires there — fall back to a persistent exit "x" even when the label
|
||||
// is shown. The swap stays only on hover-capable, roomy layouts.
|
||||
const noHover = useMediaQuery('(hover: none)');
|
||||
const showExit = active && hovered && !compact && !noHover;
|
||||
// Standing exit "x": when there's no hover-swap to reveal it (compact layout
|
||||
// or a non-hover device).
|
||||
const showPersistentExit = active && (compact || noHover);
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -48,14 +56,15 @@ export function TaskModeToggle({ active, disabled = false, onClick }: TaskModeTo
|
||||
) : (
|
||||
<Outlined.Binoculars size={16} className={active ? 'text-blue-600' : 'text-[#4E5969]'} />
|
||||
)}
|
||||
{/* Mobile: collapse to icon only to save horizontal space in the
|
||||
{/* Compact: collapse to icon only to save horizontal space in the
|
||||
input toolbar, matching the knowledge/tools selectors. */}
|
||||
<span className="touch-mobile:hidden">{localize('com_linsight_task_mode')}</span>
|
||||
{/* Mobile + active: persistent exit "x" standing in for the other
|
||||
selectors' chevron — same size/color/gap as their down icon
|
||||
(size 16, #999). Desktop keeps the hover-swap affordance above. */}
|
||||
{active && (
|
||||
<X size={16} className="hidden shrink-0 text-[#999] touch-mobile:block" />
|
||||
{!compact && <span>{localize('com_linsight_task_mode')}</span>}
|
||||
{/* Persistent exit "x" standing in for the other selectors' chevron
|
||||
— same size/color/gap as their down icon (size 16, #999). Shown
|
||||
when no hover-swap will reveal one: compact layout, or a device
|
||||
that can't hover. Hover-capable roomy layouts use the swap above. */}
|
||||
{showPersistentExit && (
|
||||
<X size={16} className="shrink-0 text-[#999]" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -19,9 +19,11 @@ interface ToolsSelectProps {
|
||||
tools: TaskModeToolItem[];
|
||||
disabled?: boolean;
|
||||
onChange: (tools: TaskModeToolItem[]) => void;
|
||||
/** Toolbar out of room (see useContainerCompact): collapse label to icon. */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function ToolsSelect({ tools, disabled = false, onChange }: ToolsSelectProps) {
|
||||
export function ToolsSelect({ tools, disabled = false, onChange, compact = false }: ToolsSelectProps) {
|
||||
const localize = useLocalize();
|
||||
const active = tools.some((tool) => tool.checked);
|
||||
|
||||
@@ -41,7 +43,7 @@ export function ToolsSelect({ tools, disabled = false, onChange }: ToolsSelectPr
|
||||
)}
|
||||
>
|
||||
<Hammer size={16} />
|
||||
<span className="truncate max-w-[min(30vw,120px)]">{localize('com_tools_title')}</span>
|
||||
{!compact && <span className="truncate">{localize('com_tools_title')}</span>}
|
||||
<ChevronDown size={14} className="text-slate-400" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -14,13 +14,11 @@ import {
|
||||
Label,
|
||||
} from '~/components/ui';
|
||||
import { useDeleteSharedLinkMutation, useSharedLinksQuery } from '~/hooks/queries/data-provider';
|
||||
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
||||
import { useLocalize, usePrefersMobileLayout } from '~/hooks';
|
||||
import DataTable from '~/components/ui/DataTable';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { useToastContext, useConfirm } from '~/Providers';
|
||||
import { formatDate } from '~/utils';
|
||||
import { Spinner } from '~/components/svg';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
@@ -37,7 +35,6 @@ export default function SharedLinks() {
|
||||
const { showToast } = useToastContext();
|
||||
const isSmallScreen = usePrefersMobileLayout();
|
||||
const [queryParams, setQueryParams] = useState<SharedLinksListParams>(DEFAULT_PARAMS);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch, isLoading } =
|
||||
@@ -86,8 +83,6 @@ export default function SharedLinks() {
|
||||
|
||||
const deleteMutation = useDeleteSharedLinkMutation({
|
||||
onSuccess: async () => {
|
||||
setIsDeleteOpen(false);
|
||||
setDeleteRow(null);
|
||||
await refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -144,14 +139,22 @@ export default function SharedLinks() {
|
||||
await fetchNextPage();
|
||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||
|
||||
const [deleteRow, setDeleteRow] = useState<SharedLinkItem | null>(null);
|
||||
|
||||
const confirmDelete = useCallback(() => {
|
||||
if (deleteRow) {
|
||||
handleDelete([deleteRow]);
|
||||
}
|
||||
setIsDeleteOpen(false);
|
||||
}, [deleteRow, handleDelete]);
|
||||
const confirm = useConfirm();
|
||||
const handleDeleteClick = useCallback(
|
||||
async (row: SharedLinkItem) => {
|
||||
const ok = await confirm({
|
||||
variant: 'destructive',
|
||||
title: localize('com_ui_delete_shared_link'),
|
||||
description: `${localize('com_ui_delete_confirm')} "${row.title}"`,
|
||||
confirmText: localize('com_ui_delete'),
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
handleDelete([row]);
|
||||
},
|
||||
[confirm, localize, handleDelete],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
@@ -247,10 +250,7 @@ export default function SharedLinks() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
||||
onClick={() => {
|
||||
setDeleteRow(row.original);
|
||||
setIsDeleteOpen(true);
|
||||
}}
|
||||
onClick={() => handleDeleteClick(row.original)}
|
||||
title={localize('com_ui_delete')}
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
@@ -261,7 +261,7 @@ export default function SharedLinks() {
|
||||
),
|
||||
},
|
||||
],
|
||||
[isSmallScreen, localize],
|
||||
[isSmallScreen, localize, handleDeleteClick],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -294,31 +294,6 @@ export default function SharedLinks() {
|
||||
/>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
<OGDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
|
||||
<OGDialogTemplate
|
||||
showCloseButton={false}
|
||||
title={localize('com_ui_delete_shared_link')}
|
||||
className="max-w-[450px]"
|
||||
main={
|
||||
<>
|
||||
<div className="flex w-full flex-col items-center gap-2">
|
||||
<div className="grid w-full items-center gap-2">
|
||||
<Label htmlFor="dialog-confirm-delete" className="text-left text-sm font-medium">
|
||||
{localize('com_ui_delete_confirm')} <strong>{deleteRow?.title}</strong>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: confirmDelete,
|
||||
selectClasses: `bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white ${
|
||||
deleteMutation.isLoading ? 'cursor-not-allowed opacity-80' : ''
|
||||
}`,
|
||||
selectText: deleteMutation.isLoading ? <Spinner /> : localize('com_ui_delete'),
|
||||
}}
|
||||
/>
|
||||
</OGDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
getMessageListApi,
|
||||
markMessageReadApi,
|
||||
} from "~/api/message";
|
||||
import { NotificationsDialog } from "./NotificationsDialog";
|
||||
|
||||
jest.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ i18n: { language: "zh-CN" } }),
|
||||
}));
|
||||
|
||||
jest.mock("~/hooks/useLocalize", () => ({
|
||||
__esModule: true,
|
||||
default: () => (key: string, vars?: Record<string, string>) => {
|
||||
const translations: Record<string, string> = {
|
||||
com_notifications_action_request_menu_access: "申请访问菜单「{{target}}」",
|
||||
com_notifications_action_approval_task_pending: "提交了「{{target}}」审批申请",
|
||||
};
|
||||
const template = translations[key];
|
||||
if (!template) return key;
|
||||
return template.replace("{{target}}", vars?.target ?? "");
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("~/Providers", () => ({
|
||||
useToastContext: () => ({ showToast: jest.fn() }),
|
||||
}));
|
||||
|
||||
jest.mock("~/api/message", () => ({
|
||||
getMessageListApi: jest.fn(),
|
||||
markMessageReadApi: jest.fn(),
|
||||
markAllMessageReadApi: jest.fn(),
|
||||
deleteMessageApi: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("~/components/ui/Dialog", () => ({
|
||||
Dialog: ({ open, children }: { open?: boolean; children: React.ReactNode }) => (open ? <div>{children}</div> : null),
|
||||
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
|
||||
}));
|
||||
|
||||
jest.mock("~/components/ui/ExpandableSearchField", () => ({
|
||||
ExpandableSearchField: () => null,
|
||||
}));
|
||||
|
||||
jest.mock("~/components/ui/Tabs", () => ({
|
||||
Tabs: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
TabsList: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
TabsTrigger: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>,
|
||||
TabsContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
jest.mock("~/components/ui/Button", () => ({
|
||||
Button: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
|
||||
<button type="button" {...props}>{children}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock("~/components/ui/Avatar", () => ({
|
||||
Avatar: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
AvatarImage: () => null,
|
||||
AvatarName: ({ name }: { name?: string }) => <span>{name}</span>,
|
||||
}));
|
||||
|
||||
jest.mock("~/components/ui/Tooltip", () => ({
|
||||
TooltipAnchor: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
describe("NotificationsDialog approval jump", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation(() => ({
|
||||
matches: false,
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
})),
|
||||
});
|
||||
class MockIntersectionObserver {
|
||||
observe = jest.fn();
|
||||
disconnect = jest.fn();
|
||||
}
|
||||
(window as any).IntersectionObserver = MockIntersectionObserver;
|
||||
(global as any).IntersectionObserver = MockIntersectionObserver;
|
||||
});
|
||||
|
||||
it("opens approval center instead of inline approving request messages", async () => {
|
||||
jest.mocked(getMessageListApi).mockResolvedValue({
|
||||
total: 1,
|
||||
data: [{
|
||||
id: 501,
|
||||
sender: 7,
|
||||
sender_name: "Alice",
|
||||
message_type: "request",
|
||||
action_code: "request_knowledge_space",
|
||||
status: "pending",
|
||||
is_read: false,
|
||||
create_time: "2026-04-27T10:00:00Z",
|
||||
update_time: "2026-04-27T10:00:00Z",
|
||||
content: [{
|
||||
type: "business_url",
|
||||
content: "知识空间订阅申请",
|
||||
metadata: {
|
||||
business_type: "approval_instance_id",
|
||||
data: { approval_instance_id: 99 },
|
||||
},
|
||||
}],
|
||||
}],
|
||||
});
|
||||
jest.mocked(markMessageReadApi).mockResolvedValue({});
|
||||
const openApprovalCenter = jest.fn();
|
||||
|
||||
render(<NotificationsDialog open onOpenApprovalCenter={openApprovalCenter} />);
|
||||
|
||||
expect(await screen.findByText("com_notifications_view_approval")).toBeInTheDocument();
|
||||
expect(screen.queryByText("com_notifications_accept")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("com_notifications_reject")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("com_notifications_view_approval"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openApprovalCenter).toHaveBeenCalledWith({
|
||||
tab: "my_tasks",
|
||||
taskId: null,
|
||||
instanceId: 99,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("uses scenario-specific PRD copy for later approval nodes", async () => {
|
||||
jest.mocked(getMessageListApi).mockResolvedValue({
|
||||
total: 1,
|
||||
data: [{
|
||||
id: 502,
|
||||
sender: 7,
|
||||
sender_name: "站内信",
|
||||
message_type: "notify",
|
||||
action_code: "approval_task_pending",
|
||||
status: "pending",
|
||||
is_read: false,
|
||||
create_time: "2026-06-01T10:00:00Z",
|
||||
update_time: "2026-06-01T10:00:00Z",
|
||||
content: [
|
||||
{ type: "system_text", content: "approval_task_pending" },
|
||||
{
|
||||
type: "business_url",
|
||||
content: "--知识空间",
|
||||
metadata: {
|
||||
business_type: "approval_instance_id",
|
||||
scenario_code: "menu_access_request",
|
||||
data: {
|
||||
approval_instance_id: "99",
|
||||
business_name: "知识空间",
|
||||
scenario_code: "menu_access_request",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}],
|
||||
});
|
||||
jest.mocked(markMessageReadApi).mockResolvedValue({});
|
||||
|
||||
render(<NotificationsDialog open onOpenApprovalCenter={jest.fn()} />);
|
||||
|
||||
expect(await screen.findByText(/申请访问菜单/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/提交了/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import { ApprovalCenterDialog } from "./ApprovalCenterDialog";
|
||||
import {
|
||||
getApprovalInstanceDetailApi,
|
||||
getMyApprovalTaskDetailApi,
|
||||
listMyApprovalRequestsApi,
|
||||
listMyApprovalTasksApi,
|
||||
} from "~/api/approval";
|
||||
|
||||
jest.mock("~/hooks/useLocalize", () => ({
|
||||
__esModule: true,
|
||||
default: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const mockShowToast = jest.fn();
|
||||
|
||||
jest.mock("~/Providers", () => ({
|
||||
useToastContext: () => ({ showToast: mockShowToast }),
|
||||
}));
|
||||
|
||||
jest.mock("~/api/approval", () => ({
|
||||
getApprovalInstanceDetailApi: jest.fn(),
|
||||
getMyApprovalTaskDetailApi: jest.fn(),
|
||||
listMyApprovalRequestsApi: jest.fn(),
|
||||
listMyApprovalTasksApi: jest.fn(),
|
||||
decideApprovalTaskApi: jest.fn(),
|
||||
withdrawApprovalInstanceApi: jest.fn(),
|
||||
revokeMenuAccessGrantApi: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("~/components/ui/Dialog", () => ({
|
||||
Dialog: ({ open, children }: { open?: boolean; children: React.ReactNode }) => (open ? <div>{children}</div> : null),
|
||||
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
|
||||
}));
|
||||
|
||||
describe("ApprovalCenterDialog", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("does not render a resubmit action for rejected requests", async () => {
|
||||
jest.mocked(listMyApprovalRequestsApi).mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
instance_id: 21,
|
||||
business_name: "知识库申请",
|
||||
status: "rejected",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
});
|
||||
jest.mocked(getApprovalInstanceDetailApi).mockResolvedValue({
|
||||
instance_id: 21,
|
||||
business_name: "知识库申请",
|
||||
status: "rejected",
|
||||
scenario_code: "knowledge_space_subscribe_request",
|
||||
} as any);
|
||||
|
||||
render(
|
||||
<ApprovalCenterDialog
|
||||
open
|
||||
onOpenChange={jest.fn()}
|
||||
target={{ tab: "my_requests", instanceId: 21 }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getApprovalInstanceDetailApi).toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.queryByText("com_approval_action_resubmit")).toBeNull();
|
||||
});
|
||||
|
||||
it("selects the my-task matching the target instance id when no task id is provided", async () => {
|
||||
// Channel/space subscribe approval notifications only carry instance_id (no task_id);
|
||||
// the dialog must resolve the correct task from instance_id instead of picking the first.
|
||||
jest.mocked(listMyApprovalTasksApi).mockResolvedValue({
|
||||
data: [
|
||||
{ task_id: 901, instance_id: 500, status: "pending", business_name: "频道A" },
|
||||
{ task_id: 902, instance_id: 777, status: "pending", business_name: "频道B" },
|
||||
],
|
||||
total: 2,
|
||||
});
|
||||
jest.mocked(getMyApprovalTaskDetailApi).mockResolvedValue({
|
||||
task_id: 902,
|
||||
instance_id: 777,
|
||||
status: "pending",
|
||||
} as any);
|
||||
|
||||
render(
|
||||
<ApprovalCenterDialog
|
||||
open
|
||||
onOpenChange={jest.fn()}
|
||||
target={{ tab: "my_tasks", instanceId: 777 }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getMyApprovalTaskDetailApi).toHaveBeenCalledWith(902);
|
||||
});
|
||||
});
|
||||
});
|
||||
+37
-19
@@ -1,34 +1,52 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* "System maintenance" empty-state illustration (database with a wrench).
|
||||
* "System maintenance" empty-state illustration (magnifier over a bug).
|
||||
*
|
||||
* Brand greens re-point to the `--brand-*` palette so the illustration follows
|
||||
* the blue ⇄ green theme switch. SVG presentation attributes ignore `var()`,
|
||||
* so brand fills / strokes are applied via inline `style`
|
||||
* (see BRAND-THEME-HANDOFF.md §3).
|
||||
* Brand greens re-point to the `--illus-*` palette so the illustration follows
|
||||
* the blue ⇄ green theme switch (and greyscale via the `grey` prop). SVG
|
||||
* presentation attributes ignore `var()`, so brand fills / strokes are applied
|
||||
* via inline `style` (see BRAND-THEME-HANDOFF.md §3 / §3.1 / §3.2).
|
||||
*
|
||||
* Colour mapping (§5):
|
||||
* #19B476 (main green) → rgb(var(--illus-500))
|
||||
* #BDE6D3 (light green) → rgb(var(--illus-100))
|
||||
* white → kept as-is
|
||||
* Colour mapping (by lightness → §5):
|
||||
* #19B476 (main green) → rgb(var(--illus-500))
|
||||
* #86DEB8 / #9BDDC1 (mid green) → rgb(var(--illus-300))
|
||||
* #D3EFE3 / #DDF0E8 / #AAE9CE → rgb(var(--illus-100))
|
||||
* white → kept as-is
|
||||
*/
|
||||
export const SystemMaintenanceIllustration = ({ className, grey, ...props }: React.SVGProps<SVGSVGElement> & { grey?: boolean }) => {
|
||||
const fill100 = { fill: 'rgb(var(--illus-100))' } as React.CSSProperties;
|
||||
const fill300 = { fill: 'rgb(var(--illus-300))' } as React.CSSProperties;
|
||||
const fill500 = { fill: 'rgb(var(--illus-500))' } as React.CSSProperties;
|
||||
const stroke100 = { stroke: 'rgb(var(--illus-100))' } as React.CSSProperties;
|
||||
const stroke500 = { stroke: 'rgb(var(--illus-500))' } as React.CSSProperties;
|
||||
const fill500stroke100 = { fill: 'rgb(var(--illus-500))', stroke: 'rgb(var(--illus-100))' } as React.CSSProperties;
|
||||
const fill500stroke500 = { fill: 'rgb(var(--illus-500))', stroke: 'rgb(var(--illus-500))' } as React.CSSProperties;
|
||||
|
||||
return (
|
||||
<svg width="400" height="400" viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg" className={['brand-illustration', grey && 'illus-grey', className].filter(Boolean).join(' ')} {...props}>
|
||||
<ellipse cx="181.687" cy="155.079" rx="106.687" ry="23.8642" style={fill500} />
|
||||
<rect x="74.9999" y="100.332" width="213.374" height="55.2151" style={fill500} />
|
||||
<circle cx="99.7018" cy="143.766" r="10.266" fill="white" />
|
||||
<circle opacity="0.6" cx="129.04" cy="149.766" r="10.266" fill="white" />
|
||||
<ellipse cx="181.687" cy="99.8642" rx="106.687" ry="23.8642" style={fill500} stroke="white" strokeWidth="8" />
|
||||
<path d="M288.374 223.864H288.352C287.237 236.828 239.909 247.26 181.686 247.26C123.464 247.26 76.1366 236.828 75.0224 223.864H74.9999V168.649H75.0224C76.1372 181.613 123.464 192.046 181.686 192.046C239.909 192.046 287.237 181.613 288.352 168.649H288.374V223.864Z" style={fill100} />
|
||||
<path d="M288.374 292.181H288.352C287.238 305.145 239.91 315.577 181.686 315.577C123.464 315.577 76.1364 305.145 75.0224 292.181H74.9999V236.966H75.0224C76.137 249.93 123.464 260.363 181.686 260.363C239.909 260.363 287.237 249.93 288.352 236.966H288.374V292.181Z" style={fill100} />
|
||||
<path d="M174.2 223.925C174.2 251.9 197.034 274.639 224.982 274.521C228.553 274.507 232.035 274.124 235.386 273.404C238.486 272.742 241.719 273.712 243.967 275.963L285.169 317.205C289.694 321.735 295.66 324 301.626 324C307.592 324 313.557 321.735 318.083 317.205C322.609 312.675 324.872 306.703 324.872 300.732C324.872 294.76 322.609 288.788 318.083 284.258L276.778 242.913C274.545 240.678 273.575 237.456 274.222 234.368C274.927 231.014 275.294 227.528 275.294 223.969C275.324 196.067 252.622 173.328 224.747 173.328C221.103 173.328 217.547 173.711 214.109 174.446C213.198 174.637 212.287 174.858 211.39 175.108C207.923 176.064 206.776 180.418 209.318 182.977L211.317 184.977L234.724 208.393C238.104 211.776 239.97 216.306 239.97 221.16C239.97 225.999 238.104 230.529 234.724 233.912C231.345 237.295 226.819 239.163 221.985 239.163C217.15 239.163 212.625 237.295 209.23 233.912L183.839 208.481C181.297 205.937 176.933 207.069 175.978 210.555C175.728 211.452 175.508 212.364 175.317 213.276C174.582 216.718 174.2 220.277 174.2 223.925Z" style={fill500} stroke="white" strokeWidth="8" />
|
||||
<circle opacity="0.4" cx="99.7018" cy="210.766" r="10.266" fill="white" />
|
||||
<circle opacity="0.4" cx="99.7018" cy="277.766" r="10.266" fill="white" />
|
||||
<circle cx="199.564" cy="221.492" r="108.508" style={fill100} />
|
||||
<path d="M243.805 97.0672C256.64 98.1625 269.476 111.673 269.476 111.673L255.838 135.406C255.838 135.406 232.173 126.643 226.959 118.245C221.745 109.847 230.97 95.972 243.805 97.0672Z" style={fill100} />
|
||||
<ellipse cx="205.881" cy="320.8" rx="120.4" ry="8.4" style={fill100} />
|
||||
<path d="M271.87 111.073C292.15 125.045 299.901 132.12 309.359 143.163L310.37 144.35L310.444 144.466C316.432 153.778 318.847 158.829 320.744 166.031L321.117 167.501L321.137 167.579L321.149 167.658C322.551 176.134 322.797 182.534 321.075 187.921C319.323 193.403 315.639 197.528 309.915 201.696L309.913 201.697C294.721 212.726 286.298 217.232 271.588 223.492C266.991 225.946 264.556 227.745 263.36 229.389C262.39 230.723 262.14 232.093 262.611 234.248L262.715 234.69L262.729 234.746C270.058 267.8 270.337 286.463 268.374 319.724L268.258 321.697H141.853L142.38 319.171C148.247 291.094 149.949 275.296 147.146 246.201C146.534 239.856 148.962 234.358 153.559 229.468C158.101 224.636 164.842 220.302 173.137 216.115C189.702 207.752 213.298 199.582 239.922 189.245L240.067 189.189L240.219 189.155C252.964 186.277 260.574 184.368 279.163 177.77C283.174 173.991 284.684 171.529 284.979 169.557C285.262 167.671 284.508 165.69 282.273 162.559C269.863 150.168 262.48 145.109 249.113 136.141L247.647 135.158L248.366 133.546C250.757 128.184 252.837 123.78 255.999 120.097C259.211 116.356 263.422 113.48 269.894 110.857L270.94 110.433L271.87 111.073Z" style={fill500stroke100} strokeWidth="4.19355" />
|
||||
<ellipse cx="199.564" cy="209.96" rx="29.879" ry="25.6855" fill="white" />
|
||||
<circle cx="176.681" cy="148.4" r="78.4" fill="white" style={stroke500} strokeWidth="6.29032" />
|
||||
<path d="M124.08 138.608C124.629 141.427 122.79 144.158 119.971 144.707C117.152 145.257 114.422 143.417 113.872 140.599C113.322 137.78 115.807 129.625 116.985 129.396C118.163 129.166 123.53 135.789 124.08 138.608Z" style={fill300} />
|
||||
<path d="M208.879 99.4077C209.429 102.226 207.59 104.957 204.771 105.507C201.952 106.056 199.221 104.217 198.672 101.398C198.122 98.5794 200.607 90.4248 201.785 90.1952C202.963 89.9655 208.33 96.5889 208.879 99.4077Z" style={fill300} />
|
||||
<path d="M229.68 129.808C230.229 132.627 228.39 135.357 225.571 135.907C222.752 136.457 220.022 134.617 219.472 131.798C218.922 128.98 221.407 120.825 222.585 120.595C223.763 120.366 229.13 126.989 229.68 129.808Z" style={fill300} />
|
||||
<path d="M126.171 134.625C126.635 137.006 125.081 139.313 122.699 139.778C120.318 140.242 118.01 138.688 117.546 136.306C117.082 133.925 119.181 127.035 120.177 126.841C121.172 126.647 125.706 132.243 126.171 134.625Z" fill="white" />
|
||||
<path d="M211.002 95.5828C211.475 98.0081 209.892 100.358 207.467 100.83C205.041 101.303 202.692 99.7207 202.219 97.2954C201.746 94.8701 203.884 87.854 204.898 87.6564C205.911 87.4588 210.529 93.1575 211.002 95.5828Z" fill="white" />
|
||||
<path d="M231.78 125.875C232.248 128.27 230.684 130.591 228.289 131.058C225.894 131.525 223.573 129.962 223.106 127.566C222.639 125.171 224.751 118.241 225.752 118.046C226.753 117.851 231.313 123.479 231.78 125.875Z" fill="white" />
|
||||
<path d="M170.54 261.855C171.74 285.855 169.664 295.588 166.54 319.055" style={stroke100} strokeWidth="6.29032" strokeLinecap="round" />
|
||||
<ellipse cx="141.207" cy="165.992" rx="3.77185" ry="7.33871" transform="rotate(73.507 141.207 165.992)" style={fill500} />
|
||||
<ellipse cx="177.424" cy="159.413" rx="7.355" ry="3.63321" transform="rotate(-9.93375 177.424 159.413)" style={fill500} />
|
||||
<path d="M179.573 179.108C167.9 185.591 161.258 186.446 149.325 185.179C149.325 185.179 155.13 202.809 169.684 198.952C184.239 195.094 179.573 179.108 179.573 179.108Z" style={fill500stroke500} strokeWidth="4.19355" strokeLinejoin="round" />
|
||||
<path d="M134.701 185.323L133.501 190.923M127.501 186.123L126.701 189.723" style={stroke100} strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M205.991 168.548L204.791 174.148M198.791 169.348L197.991 174.148M212.391 169.748L211.591 172.948" style={stroke100} strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<circle cx="101.081" cy="255.2" r="13.2" style={fill500} />
|
||||
<path d="M81.904 237.37C81.904 240.639 79.2589 243.288 75.9959 243.288C72.733 243.288 70.0879 240.639 70.0879 237.37C70.0879 234.101 72.733 231.452 75.9959 231.452C79.2589 231.452 81.904 234.101 81.904 237.37Z" style={fill300} />
|
||||
<path d="M299.685 277.953C299.685 281.221 297.04 283.871 293.777 283.871C290.514 283.871 287.869 281.221 287.869 277.953C287.869 274.684 290.514 272.034 293.777 272.034C297.04 272.034 299.685 274.684 299.685 277.953Z" style={fill300} />
|
||||
<circle cx="285.481" cy="89.1999" r="5.6" style={fill500} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
import { PermissionDialog } from "./PermissionDialog";
|
||||
|
||||
jest.mock("~/pages/knowledge/SpaceDetail/KnowledgeSpaceShareDialog", () => ({
|
||||
KnowledgeSpaceShareDialog: ({
|
||||
resourceType,
|
||||
resourceId,
|
||||
resourceName,
|
||||
showShareTab,
|
||||
showMembersTab,
|
||||
showPermissionTab,
|
||||
}: any) => (
|
||||
<div>
|
||||
{`share-dialog:${resourceType}:${resourceId}:${resourceName}:${showShareTab}:${showMembersTab}:${showPermissionTab}`}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("PermissionDialog", () => {
|
||||
it("uses the shared subject-scoped permission dialog", () => {
|
||||
render(
|
||||
<PermissionDialog
|
||||
open
|
||||
onOpenChange={jest.fn()}
|
||||
resourceType="channel"
|
||||
resourceId="channel-1"
|
||||
resourceName="Channel 1"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("share-dialog:channel:channel-1:Channel 1:false:false:true"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,382 +0,0 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
authorizeResource,
|
||||
getGrantableRelationModels,
|
||||
getResourcePermissions,
|
||||
getResourceGrantDepartmentChildren,
|
||||
searchResourceGrantDepartments,
|
||||
getResourceGrantUserGroups,
|
||||
getResourceGrantUsers,
|
||||
} from "~/api/permission";
|
||||
import { PermissionGrantTab } from "./PermissionGrantTab";
|
||||
|
||||
const deptNode = (
|
||||
id: number,
|
||||
name: string,
|
||||
parent_id: number | null,
|
||||
path: string,
|
||||
has_children: boolean,
|
||||
) => ({
|
||||
id,
|
||||
dept_id: `dept-${id}`,
|
||||
name,
|
||||
parent_id,
|
||||
path,
|
||||
has_children,
|
||||
matched: false,
|
||||
children: [] as any[],
|
||||
});
|
||||
|
||||
const emptyDeptSearch = { roots: [], total_matches: 0, truncated: false };
|
||||
|
||||
const mockLocalize = (key: string) => key;
|
||||
|
||||
jest.mock("~/hooks", () => ({
|
||||
useLocalize: () => mockLocalize,
|
||||
usePrefersMobileLayout: () => false,
|
||||
}));
|
||||
|
||||
jest.mock("~/Providers", () => ({
|
||||
useToastContext: () => ({ showToast: jest.fn() }),
|
||||
}));
|
||||
|
||||
jest.mock("~/api/permission", () => ({
|
||||
authorizeResource: jest.fn(),
|
||||
getGrantableRelationModels: jest.fn(),
|
||||
getResourcePermissions: jest.fn(),
|
||||
getResourceGrantDepartmentChildren: jest.fn(),
|
||||
searchResourceGrantDepartments: jest.fn(),
|
||||
getResourceGrantUserGroups: jest.fn(),
|
||||
getResourceGrantUsers: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedAuthorizeResource = jest.mocked(authorizeResource);
|
||||
const mockedGetGrantableRelationModels = jest.mocked(getGrantableRelationModels);
|
||||
const mockedGetResourcePermissions = jest.mocked(getResourcePermissions);
|
||||
const mockedDeptChildren = jest.mocked(getResourceGrantDepartmentChildren);
|
||||
const mockedDeptSearch = jest.mocked(searchResourceGrantDepartments);
|
||||
const mockedGetResourceGrantUserGroups = jest.mocked(getResourceGrantUserGroups);
|
||||
const mockedGetResourceGrantUsers = jest.mocked(getResourceGrantUsers);
|
||||
|
||||
describe("PermissionGrantTab", () => {
|
||||
beforeAll(() => {
|
||||
class IntersectionObserverMock implements IntersectionObserver {
|
||||
readonly root = null;
|
||||
readonly rootMargin = "";
|
||||
readonly thresholds = [];
|
||||
disconnect = jest.fn();
|
||||
observe = jest.fn();
|
||||
takeRecords = jest.fn(() => []);
|
||||
unobserve = jest.fn();
|
||||
}
|
||||
Object.defineProperty(window, "IntersectionObserver", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: IntersectionObserverMock,
|
||||
});
|
||||
class ResizeObserverMock implements ResizeObserver {
|
||||
disconnect = jest.fn();
|
||||
observe = jest.fn();
|
||||
unobserve = jest.fn();
|
||||
}
|
||||
Object.defineProperty(globalThis, "ResizeObserver", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: ResizeObserverMock,
|
||||
});
|
||||
if (!window.PointerEvent) {
|
||||
Object.defineProperty(window, "PointerEvent", {
|
||||
configurable: true,
|
||||
value: MouseEvent,
|
||||
});
|
||||
}
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = jest.fn();
|
||||
}
|
||||
if (!Element.prototype.hasPointerCapture) {
|
||||
Element.prototype.hasPointerCapture = jest.fn(() => false);
|
||||
}
|
||||
if (!Element.prototype.setPointerCapture) {
|
||||
Element.prototype.setPointerCapture = jest.fn();
|
||||
}
|
||||
if (!Element.prototype.releasePointerCapture) {
|
||||
Element.prototype.releasePointerCapture = jest.fn();
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockedAuthorizeResource.mockResolvedValue(null);
|
||||
mockedGetResourcePermissions.mockResolvedValue([]);
|
||||
mockedGetGrantableRelationModels.mockResolvedValue([
|
||||
{
|
||||
id: "viewer",
|
||||
name: "Viewer",
|
||||
relation: "viewer",
|
||||
permissions: [],
|
||||
is_system: true,
|
||||
},
|
||||
]);
|
||||
mockedGetResourceGrantUsers.mockResolvedValue([]);
|
||||
mockedDeptChildren.mockResolvedValue([deptNode(7, "测试部门", null, "/7/", false)] as any);
|
||||
mockedDeptSearch.mockResolvedValue(emptyDeptSearch as any);
|
||||
mockedGetResourceGrantUserGroups.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
const channelRelationModels = [
|
||||
{
|
||||
id: "owner",
|
||||
name: "Owner",
|
||||
relation: "owner",
|
||||
permissions: [],
|
||||
is_system: true,
|
||||
},
|
||||
{
|
||||
id: "viewer",
|
||||
name: "Viewer",
|
||||
relation: "viewer",
|
||||
permissions: [],
|
||||
is_system: true,
|
||||
},
|
||||
{
|
||||
id: "editor",
|
||||
name: "Editor",
|
||||
relation: "editor",
|
||||
permissions: [],
|
||||
is_system: true,
|
||||
},
|
||||
{
|
||||
id: "manager",
|
||||
name: "Manager",
|
||||
relation: "manager",
|
||||
permissions: [],
|
||||
is_system: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
async function openRelationSelect() {
|
||||
const trigger = screen.getByRole("combobox");
|
||||
trigger.focus();
|
||||
fireEvent.keyDown(trigger, {
|
||||
key: "ArrowDown",
|
||||
code: "ArrowDown",
|
||||
keyCode: 40,
|
||||
});
|
||||
return await screen.findByRole("listbox");
|
||||
}
|
||||
|
||||
it("keeps owner grant level available for channel user grants", async () => {
|
||||
render(
|
||||
<PermissionGrantTab
|
||||
resourceType="channel"
|
||||
resourceId="channel-1"
|
||||
onSuccess={jest.fn()}
|
||||
prefetchedGrantableModels={[...channelRelationModels]}
|
||||
prefetchedGrantableModelsLoaded
|
||||
skipGrantableModelsRequest
|
||||
fixedSubjectType="user"
|
||||
/>,
|
||||
);
|
||||
|
||||
const listbox = await openRelationSelect();
|
||||
|
||||
expect(listbox).toHaveTextContent("com_permission.level_owner");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["department", "部门"],
|
||||
["user_group", "用户组"],
|
||||
] as const)("hides owner grant level for channel %s grants", async (subjectType) => {
|
||||
render(
|
||||
<PermissionGrantTab
|
||||
resourceType="channel"
|
||||
resourceId="channel-1"
|
||||
onSuccess={jest.fn()}
|
||||
prefetchedGrantableModels={[...channelRelationModels]}
|
||||
prefetchedGrantableModelsLoaded
|
||||
skipGrantableModelsRequest
|
||||
fixedSubjectType={subjectType}
|
||||
/>,
|
||||
);
|
||||
|
||||
const listbox = await openRelationSelect();
|
||||
|
||||
expect(listbox).not.toHaveTextContent("com_permission.level_owner");
|
||||
expect(listbox).toHaveTextContent("com_permission.level_viewer");
|
||||
});
|
||||
|
||||
it("submits the current include-children checkbox value for department grants", async () => {
|
||||
render(
|
||||
<PermissionGrantTab
|
||||
resourceType="knowledge_space"
|
||||
resourceId="space-1"
|
||||
onSuccess={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "com_permission.subject_department" }));
|
||||
fireEvent.click(await screen.findByText("测试部门"));
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "com_permission.include_children" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "com_permission.action_submit" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedAuthorizeResource).toHaveBeenCalledWith(
|
||||
"knowledge_space",
|
||||
"space-1",
|
||||
[
|
||||
{
|
||||
subject_type: "department",
|
||||
subject_id: 7,
|
||||
relation: "viewer",
|
||||
model_id: "viewer",
|
||||
include_children: false,
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
});
|
||||
expect(mockedDeptChildren).toHaveBeenCalledWith(
|
||||
"knowledge_space",
|
||||
"space-1",
|
||||
null,
|
||||
{ signal: expect.any(AbortSignal) },
|
||||
);
|
||||
});
|
||||
|
||||
it("summarizes only the explicitly picked department, not its descendants — decision 10", async () => {
|
||||
// 测试部门 has children, but include-children coverage is conveyed by the flag;
|
||||
// the summary must not enumerate descendants client-side.
|
||||
mockedDeptChildren.mockResolvedValue([deptNode(7, "测试部门", null, "/7/", true)] as any);
|
||||
|
||||
render(
|
||||
<PermissionGrantTab
|
||||
resourceType="knowledge_space"
|
||||
resourceId="space-1"
|
||||
onSuccess={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "com_permission.subject_department" }));
|
||||
fireEvent.click(await screen.findByText("测试部门"));
|
||||
|
||||
await waitFor(() => {
|
||||
// The picked department appears both as a tree row and as a summary chip.
|
||||
expect(screen.getAllByText("测试部门").length).toBeGreaterThan(1);
|
||||
});
|
||||
// No materialized descendant label is ever produced.
|
||||
expect(screen.queryByText("测试部门/子部门")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks already granted departments as disabled without selecting them again", async () => {
|
||||
mockedGetResourcePermissions.mockResolvedValue([
|
||||
{
|
||||
subject_type: "department",
|
||||
subject_id: 7,
|
||||
subject_name: "测试部门",
|
||||
relation: "viewer",
|
||||
include_children: false,
|
||||
},
|
||||
] as any);
|
||||
|
||||
render(
|
||||
<PermissionGrantTab
|
||||
resourceType="knowledge_space"
|
||||
resourceId="space-1"
|
||||
onSuccess={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "com_permission.subject_department" }));
|
||||
|
||||
const departmentLabel = await screen.findByText("测试部门");
|
||||
const checkbox = departmentLabel.parentElement?.querySelector('[role="checkbox"]');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(checkbox).toHaveAttribute("data-state", "unchecked");
|
||||
expect(checkbox).toBeDisabled();
|
||||
});
|
||||
expect(screen.getByText("com_permission.already_granted")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(departmentLabel);
|
||||
fireEvent.click(screen.getByRole("button", { name: "com_permission.action_submit" }));
|
||||
|
||||
expect(mockedAuthorizeResource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks already granted users as disabled without selecting them again", async () => {
|
||||
mockedGetResourcePermissions.mockResolvedValue([
|
||||
{
|
||||
subject_type: "user",
|
||||
subject_id: 8,
|
||||
subject_name: "Alice",
|
||||
relation: "viewer",
|
||||
},
|
||||
] as any);
|
||||
mockedGetResourceGrantUsers.mockResolvedValue([
|
||||
{ user_id: 8, user_name: "Alice" },
|
||||
]);
|
||||
|
||||
render(
|
||||
<PermissionGrantTab
|
||||
resourceType="knowledge_space"
|
||||
resourceId="space-1"
|
||||
onSuccess={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const userLabel = await screen.findByText("Alice");
|
||||
// The already-granted user row has a single checkbox; the name span is nested
|
||||
// deeper than its sibling checkbox, so query the row's checkbox by role.
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(checkbox).toHaveAttribute("data-state", "unchecked");
|
||||
expect(checkbox).toBeDisabled();
|
||||
});
|
||||
expect(screen.getByText("com_permission.already_granted")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(userLabel);
|
||||
fireEvent.click(screen.getByRole("button", { name: "com_permission.action_submit" }));
|
||||
|
||||
expect(mockedAuthorizeResource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks already granted user groups as disabled without selecting them again", async () => {
|
||||
mockedGetResourcePermissions.mockResolvedValue([
|
||||
{
|
||||
subject_type: "user_group",
|
||||
subject_id: 9,
|
||||
subject_name: "测试用户组",
|
||||
relation: "viewer",
|
||||
},
|
||||
] as any);
|
||||
mockedGetResourceGrantUserGroups.mockResolvedValue([
|
||||
{ id: 9, group_name: "测试用户组" },
|
||||
]);
|
||||
|
||||
render(
|
||||
<PermissionGrantTab
|
||||
resourceType="knowledge_space"
|
||||
resourceId="space-1"
|
||||
onSuccess={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "com_permission.subject_user_group" }));
|
||||
|
||||
const userGroupLabel = await screen.findByText("测试用户组");
|
||||
const checkbox = userGroupLabel.parentElement?.querySelector('[role="checkbox"]');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(checkbox).toHaveAttribute("data-state", "unchecked");
|
||||
expect(checkbox).toBeDisabled();
|
||||
});
|
||||
expect(screen.getByText("com_permission.already_granted")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(userGroupLabel);
|
||||
fireEvent.click(screen.getByRole("button", { name: "com_permission.action_submit" }));
|
||||
|
||||
expect(mockedAuthorizeResource).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,480 +0,0 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
authorizeResource,
|
||||
getGrantableRelationModels,
|
||||
getResourcePermissions,
|
||||
} from "~/api/permission";
|
||||
import { PermissionListTab } from "./PermissionListTab";
|
||||
|
||||
jest.mock("~/hooks", () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
jest.mock("~/Providers", () => ({
|
||||
useToastContext: () => ({ showToast: jest.fn() }),
|
||||
useConfirm: () => jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
jest.mock("~/api/permission", () => ({
|
||||
authorizeResource: jest.fn(),
|
||||
getGrantableRelationModels: jest.fn(),
|
||||
getResourcePermissions: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("~/components/ui/Avatar", () => ({
|
||||
Avatar: ({ children }: any) => <div>{children}</div>,
|
||||
AvatarName: ({ name }: any) => <div>{name}</div>,
|
||||
}));
|
||||
|
||||
jest.mock("~/components/ui/DropdownMenu", () => ({
|
||||
DropdownMenu: ({ children }: any) => <div>{children}</div>,
|
||||
DropdownMenuTrigger: ({ children }: any) => <div>{children}</div>,
|
||||
DropdownMenuContent: ({ children }: any) => <div>{children}</div>,
|
||||
DropdownMenuItem: ({ children, onSelect, ...props }: any) => (
|
||||
<button type="button" onClick={onSelect} {...props}>{children}</button>
|
||||
),
|
||||
DropdownMenuSeparator: () => <div />,
|
||||
}));
|
||||
|
||||
const mockedGetGrantableRelationModels = jest.mocked(getGrantableRelationModels);
|
||||
const mockedGetResourcePermissions = jest.mocked(getResourcePermissions);
|
||||
const mockedAuthorizeResource = jest.mocked(authorizeResource);
|
||||
|
||||
describe("Client PermissionListTab", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockedAuthorizeResource.mockResolvedValue(null);
|
||||
mockedGetGrantableRelationModels.mockResolvedValue([
|
||||
{
|
||||
id: "owner",
|
||||
name: "Owner",
|
||||
relation: "owner",
|
||||
permissions: [],
|
||||
is_system: true,
|
||||
},
|
||||
{
|
||||
id: "viewer",
|
||||
name: "Viewer",
|
||||
relation: "viewer",
|
||||
permissions: [],
|
||||
is_system: true,
|
||||
},
|
||||
{
|
||||
id: "editor",
|
||||
name: "Editor",
|
||||
relation: "editor",
|
||||
permissions: [],
|
||||
is_system: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the last owner row read-only", async () => {
|
||||
mockedGetResourcePermissions.mockResolvedValue([
|
||||
{
|
||||
subject_type: "user",
|
||||
subject_id: 2,
|
||||
subject_name: "Alice",
|
||||
relation: "owner",
|
||||
model_id: "owner",
|
||||
model_name: "Owner",
|
||||
},
|
||||
] as any);
|
||||
|
||||
render(
|
||||
<PermissionListTab
|
||||
resourceType="knowledge_space"
|
||||
resourceId="space-1"
|
||||
refreshKey={0}
|
||||
fixedSubjectType="user"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("Alice").length).toBeGreaterThan(0);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows owner actions when another owner remains", async () => {
|
||||
mockedGetResourcePermissions.mockResolvedValue([
|
||||
{
|
||||
subject_type: "user",
|
||||
subject_id: 2,
|
||||
subject_name: "Alice",
|
||||
relation: "owner",
|
||||
model_id: "owner",
|
||||
model_name: "Owner",
|
||||
},
|
||||
{
|
||||
subject_type: "user",
|
||||
subject_id: 3,
|
||||
subject_name: "Bob",
|
||||
relation: "owner",
|
||||
model_id: "owner",
|
||||
model_name: "Owner",
|
||||
},
|
||||
] as any);
|
||||
|
||||
render(
|
||||
<PermissionListTab
|
||||
resourceType="knowledge_space"
|
||||
resourceId="space-1"
|
||||
refreshKey={0}
|
||||
fixedSubjectType="user"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("Alice").length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(screen.getAllByLabelText("com_permission.remove")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("hides the owner option for a user group entry", async () => {
|
||||
mockedGetResourcePermissions.mockResolvedValue([
|
||||
{
|
||||
subject_type: "user_group",
|
||||
subject_id: 9,
|
||||
subject_name: "zz",
|
||||
relation: "viewer",
|
||||
model_id: "viewer",
|
||||
model_name: "Viewer",
|
||||
},
|
||||
] as any);
|
||||
|
||||
render(
|
||||
<PermissionListTab
|
||||
resourceType="channel"
|
||||
resourceId="channel-1"
|
||||
refreshKey={0}
|
||||
fixedSubjectType="user_group"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("zz").length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(screen.queryByText("com_permission.level_owner")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("com_permission.level_editor")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the owner option for a user entry", async () => {
|
||||
mockedGetResourcePermissions.mockResolvedValue([
|
||||
{
|
||||
subject_type: "user",
|
||||
subject_id: 9,
|
||||
subject_name: "Carol",
|
||||
relation: "viewer",
|
||||
model_id: "viewer",
|
||||
model_name: "Viewer",
|
||||
},
|
||||
] as any);
|
||||
|
||||
render(
|
||||
<PermissionListTab
|
||||
resourceType="channel"
|
||||
resourceId="channel-1"
|
||||
refreshKey={0}
|
||||
fixedSubjectType="user"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("Carol").length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(screen.getByText("com_permission.level_owner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("deletes all relations for the selected subject", async () => {
|
||||
mockedGetResourcePermissions.mockResolvedValue([
|
||||
{
|
||||
subject_type: "user",
|
||||
subject_id: 2,
|
||||
subject_name: "Alice",
|
||||
relation: "viewer",
|
||||
model_id: "viewer",
|
||||
model_name: "Viewer",
|
||||
},
|
||||
{
|
||||
subject_type: "user",
|
||||
subject_id: 2,
|
||||
subject_name: "Alice",
|
||||
relation: "editor",
|
||||
model_id: "editor",
|
||||
model_name: "Editor",
|
||||
},
|
||||
{
|
||||
subject_type: "user",
|
||||
subject_id: 3,
|
||||
subject_name: "Bob",
|
||||
relation: "viewer",
|
||||
model_id: "viewer",
|
||||
model_name: "Viewer",
|
||||
},
|
||||
] as any);
|
||||
|
||||
render(
|
||||
<PermissionListTab
|
||||
resourceType="knowledge_file"
|
||||
resourceId="file-1"
|
||||
refreshKey={0}
|
||||
fixedSubjectType="user"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("Alice").length).toBeGreaterThan(0);
|
||||
});
|
||||
fireEvent.click(screen.getAllByLabelText("com_permission.remove")[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedAuthorizeResource).toHaveBeenCalledWith(
|
||||
"knowledge_file",
|
||||
"file-1",
|
||||
[],
|
||||
[
|
||||
{
|
||||
subject_type: "user",
|
||||
subject_id: 2,
|
||||
relation: "viewer",
|
||||
},
|
||||
{
|
||||
subject_type: "user",
|
||||
subject_id: 2,
|
||||
relation: "editor",
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("updates the binding without a revoke when only the model changes (same relation)", async () => {
|
||||
mockedGetGrantableRelationModels.mockResolvedValue([
|
||||
{ id: "viewer", name: "Viewer", relation: "viewer", permissions: [], is_system: true },
|
||||
{ id: "custom-viewer", name: "自定义查看", relation: "viewer", permissions: [], is_system: false },
|
||||
] as any);
|
||||
mockedGetResourcePermissions.mockResolvedValue([
|
||||
{
|
||||
subject_type: "user",
|
||||
subject_id: 2,
|
||||
subject_name: "Alice",
|
||||
relation: "viewer",
|
||||
model_id: "viewer",
|
||||
model_name: "Viewer",
|
||||
},
|
||||
] as any);
|
||||
|
||||
render(
|
||||
<PermissionListTab
|
||||
resourceType="channel"
|
||||
resourceId="channel-1"
|
||||
refreshKey={0}
|
||||
fixedSubjectType="user"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("Alice").length).toBeGreaterThan(0);
|
||||
});
|
||||
fireEvent.click(screen.getByText("自定义查看"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedAuthorizeResource).toHaveBeenCalledWith(
|
||||
"channel",
|
||||
"channel-1",
|
||||
[
|
||||
{
|
||||
subject_type: "user",
|
||||
subject_id: 2,
|
||||
relation: "viewer",
|
||||
model_id: "custom-viewer",
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("revokes the old relation when the model change also changes the relation", async () => {
|
||||
mockedGetResourcePermissions.mockResolvedValue([
|
||||
{
|
||||
subject_type: "user",
|
||||
subject_id: 2,
|
||||
subject_name: "Alice",
|
||||
relation: "viewer",
|
||||
model_id: "viewer",
|
||||
model_name: "Viewer",
|
||||
},
|
||||
] as any);
|
||||
|
||||
render(
|
||||
<PermissionListTab
|
||||
resourceType="channel"
|
||||
resourceId="channel-1"
|
||||
refreshKey={0}
|
||||
fixedSubjectType="user"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("Alice").length).toBeGreaterThan(0);
|
||||
});
|
||||
fireEvent.click(screen.getByText("com_permission.level_editor"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedAuthorizeResource).toHaveBeenCalledWith(
|
||||
"channel",
|
||||
"channel-1",
|
||||
[
|
||||
{
|
||||
subject_type: "user",
|
||||
subject_id: 2,
|
||||
relation: "editor",
|
||||
model_id: "editor",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
subject_type: "user",
|
||||
subject_id: 2,
|
||||
relation: "viewer",
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes department include-children grants across subtree and exact variants", async () => {
|
||||
mockedGetResourcePermissions.mockResolvedValue([
|
||||
{
|
||||
subject_type: "department",
|
||||
subject_id: 7,
|
||||
subject_name: "研发部",
|
||||
relation: "viewer",
|
||||
model_id: "viewer",
|
||||
model_name: "Viewer",
|
||||
include_children: true,
|
||||
},
|
||||
] as any);
|
||||
|
||||
render(
|
||||
<PermissionListTab
|
||||
resourceType="knowledge_space"
|
||||
resourceId="space-1"
|
||||
refreshKey={0}
|
||||
fixedSubjectType="department"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("研发部")).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("com_permission.remove"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedAuthorizeResource).toHaveBeenCalledWith(
|
||||
"knowledge_space",
|
||||
"space-1",
|
||||
[],
|
||||
[
|
||||
{
|
||||
subject_type: "department",
|
||||
subject_id: 7,
|
||||
relation: "viewer",
|
||||
include_children: true,
|
||||
},
|
||||
{
|
||||
subject_type: "department",
|
||||
subject_id: 7,
|
||||
relation: "viewer",
|
||||
include_children: false,
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses an injected permission API instead of generic resource endpoints", async () => {
|
||||
const permissionApi = {
|
||||
getPermissions: jest.fn().mockResolvedValue([
|
||||
{
|
||||
subject_type: "user",
|
||||
subject_id: 2,
|
||||
subject_name: "Alice",
|
||||
relation: "viewer",
|
||||
model_id: "viewer",
|
||||
model_name: "Viewer",
|
||||
},
|
||||
]),
|
||||
authorize: jest.fn(),
|
||||
getGrantableRelationModels: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: "viewer",
|
||||
name: "Viewer",
|
||||
relation: "viewer",
|
||||
permissions: [],
|
||||
is_system: true,
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
render(
|
||||
<PermissionListTab
|
||||
resourceType="channel"
|
||||
resourceId="channel-1"
|
||||
refreshKey={0}
|
||||
fixedSubjectType="user"
|
||||
permissionApi={permissionApi as any}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("Alice").length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(permissionApi.getPermissions).toHaveBeenCalledWith("channel", "channel-1");
|
||||
expect(permissionApi.getGrantableRelationModels).toHaveBeenCalledWith("channel", "channel-1");
|
||||
expect(mockedGetResourcePermissions).not.toHaveBeenCalled();
|
||||
expect(mockedGetGrantableRelationModels).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("locks the current user's own row so they cannot change their own permission", async () => {
|
||||
// Two user owners → both rows would normally expose action buttons.
|
||||
mockedGetResourcePermissions.mockResolvedValue([
|
||||
{ subject_type: "user", subject_id: 2, subject_name: "Alice", relation: "owner", model_id: "owner", model_name: "Owner" },
|
||||
{ subject_type: "user", subject_id: 5, subject_name: "Me", relation: "owner", model_id: "owner", model_name: "Owner" },
|
||||
] as any);
|
||||
|
||||
const { unmount } = render(
|
||||
<PermissionListTab
|
||||
resourceType="knowledge_space"
|
||||
resourceId="s1"
|
||||
refreshKey={0}
|
||||
fixedSubjectType="user"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(screen.getAllByText("Alice").length).toBeGreaterThan(0));
|
||||
const withoutSelfLock = screen.queryAllByRole("button").length;
|
||||
unmount();
|
||||
|
||||
// With currentUserId=5 (Me): the current user's own row is locked, so it
|
||||
// exposes no modify/remove buttons — fewer buttons than the unlocked render.
|
||||
render(
|
||||
<PermissionListTab
|
||||
resourceType="knowledge_space"
|
||||
resourceId="s1"
|
||||
refreshKey={0}
|
||||
fixedSubjectType="user"
|
||||
currentUserId={5}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(screen.getAllByText("Me").length).toBeGreaterThan(0));
|
||||
const withSelfLock = screen.queryAllByRole("button").length;
|
||||
|
||||
expect(withoutSelfLock).toBeGreaterThan(0);
|
||||
expect(withSelfLock).toBeLessThan(withoutSelfLock);
|
||||
});
|
||||
});
|
||||
@@ -1,166 +0,0 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
getResourceGrantDepartmentChildren,
|
||||
searchResourceGrantDepartments,
|
||||
} from "~/api/permission";
|
||||
import type { SelectedSubject } from "~/api/permission";
|
||||
import { SubjectSearchDepartment } from "./SubjectSearchDepartment";
|
||||
|
||||
jest.mock("~/hooks", () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
// F038: the client picker is now a lazy tree fed by the resource-scoped grant
|
||||
// endpoints (children / search). No full-tree load.
|
||||
jest.mock("~/api/permission", () => ({
|
||||
getResourceGrantDepartmentChildren: jest.fn(),
|
||||
searchResourceGrantDepartments: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedChildren = jest.mocked(getResourceGrantDepartmentChildren);
|
||||
const mockedSearch = jest.mocked(searchResourceGrantDepartments);
|
||||
|
||||
const node = (
|
||||
id: number,
|
||||
name: string,
|
||||
parent_id: number | null,
|
||||
path: string,
|
||||
has_children: boolean,
|
||||
matched = false,
|
||||
) => ({
|
||||
id,
|
||||
dept_id: `dept-${id}`,
|
||||
name,
|
||||
parent_id,
|
||||
path,
|
||||
has_children,
|
||||
matched,
|
||||
children: [] as any[],
|
||||
});
|
||||
|
||||
const emptySearch = { roots: [], total_matches: 0, truncated: false };
|
||||
|
||||
describe("SubjectSearchDepartment (lazy, F038 decision 9/10)", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Root layer = 全集团 (has children); search returns its pruned subtree.
|
||||
mockedChildren.mockResolvedValue([node(1, "全集团", null, "/1/", true)] as any);
|
||||
mockedSearch.mockResolvedValue(emptySearch as any);
|
||||
});
|
||||
|
||||
it("lazy-loads the grant root layer via the resource-scoped children endpoint", async () => {
|
||||
render(
|
||||
<SubjectSearchDepartment
|
||||
value={[]}
|
||||
onChange={jest.fn()}
|
||||
resourceType="workflow"
|
||||
resourceId="wf-1"
|
||||
includeChildren
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedChildren).toHaveBeenCalledWith("workflow", "wf-1", null, {
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
});
|
||||
expect(await screen.findByText("全集团")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows descendants as checked + disabled (implicit) when an ancestor grant includes children — decision 9 (path-based)", async () => {
|
||||
mockedSearch.mockResolvedValue({
|
||||
roots: [
|
||||
{ ...node(1, "全集团", null, "/1/", true), children: [node(2, "子部门", 1, "/1/2/", false, true)] },
|
||||
],
|
||||
total_matches: 1,
|
||||
truncated: false,
|
||||
} as any);
|
||||
|
||||
const value: SelectedSubject[] = [
|
||||
{ type: "department", id: 1, name: "全集团", include_children: true },
|
||||
];
|
||||
|
||||
render(
|
||||
<SubjectSearchDepartment
|
||||
value={value}
|
||||
onChange={jest.fn()}
|
||||
resourceType="workflow"
|
||||
resourceId="wf-1"
|
||||
includeChildren
|
||||
/>,
|
||||
);
|
||||
|
||||
// Root load primes the ancestor's path so implicit selection resolves.
|
||||
await screen.findByText("全集团");
|
||||
fireEvent.change(screen.getByPlaceholderText("com_permission.search_department"), {
|
||||
target: { value: "子部门" },
|
||||
});
|
||||
|
||||
const childLabel = await screen.findByText("子部门");
|
||||
const childCheckbox = within(childLabel.parentElement as HTMLElement).getByRole("checkbox");
|
||||
expect(childCheckbox).toHaveAttribute("data-state", "checked");
|
||||
expect(childCheckbox).toBeDisabled();
|
||||
});
|
||||
|
||||
it("summarizes only the explicit picks, never the materialized subtree — decision 10", async () => {
|
||||
const onSelectionSummaryChange = jest.fn();
|
||||
|
||||
render(
|
||||
<SubjectSearchDepartment
|
||||
value={[{ type: "department", id: 1, name: "全集团", include_children: true }]}
|
||||
onChange={jest.fn()}
|
||||
resourceType="workflow"
|
||||
resourceId="wf-1"
|
||||
includeChildren
|
||||
onSelectionSummaryChange={onSelectionSummaryChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSelectionSummaryChange).toHaveBeenLastCalledWith([
|
||||
{ type: "department", id: 1, name: "全集团", include_children: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows already granted departments as disabled and unchecked without selecting them", async () => {
|
||||
render(
|
||||
<SubjectSearchDepartment
|
||||
value={[]}
|
||||
onChange={jest.fn()}
|
||||
resourceType="workflow"
|
||||
resourceId="wf-1"
|
||||
includeChildren
|
||||
disabledIds={[1]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const departmentLabel = await screen.findByText("全集团");
|
||||
const checkbox = within(departmentLabel.parentElement as HTMLElement).getByRole("checkbox");
|
||||
|
||||
expect(checkbox).toHaveAttribute("data-state", "unchecked");
|
||||
expect(checkbox).toBeDisabled();
|
||||
expect(screen.getByText("com_permission.already_granted")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds a department carrying the current include-children flag when toggled on", async () => {
|
||||
const onChange = jest.fn();
|
||||
|
||||
render(
|
||||
<SubjectSearchDepartment
|
||||
value={[]}
|
||||
onChange={onChange}
|
||||
resourceType="workflow"
|
||||
resourceId="wf-1"
|
||||
includeChildren
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByText("全集团"));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
{ type: "department", id: 1, name: "全集团", include_children: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -136,7 +136,13 @@ const SelectItem = React.forwardRef<
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
)}
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
{/* Radix ItemText silently drops className/style, so the shrink constraint
|
||||
lives on this wrapper: as a flex child it must be allowed to go below
|
||||
its content width, otherwise long content overflows past the pr-8
|
||||
indicator area instead of truncating before the check mark. */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</div>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
@@ -24,6 +24,7 @@ export { default as useNewConvo } from './useNewConvo';
|
||||
export { default as useLocalize } from './useLocalize';
|
||||
export type { TranslationKeys } from './useLocalize';
|
||||
export { default as useMediaQuery } from './useMediaQuery';
|
||||
export { useContainerCompact, TOOLBAR_COMPACT_THRESHOLD } from './useContainerCompact';
|
||||
export { default as usePrefersMobileLayout } from './usePrefersMobileLayout';
|
||||
export { default as useScrollToRef } from './useScrollToRef';
|
||||
export { useScrollRevealRef } from './useScrollRevealRef';
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Width (px) below which the input toolbars collapse their button labels to
|
||||
* icons. Sized for the WIDEST locale (English labels — "Knowledge Space",
|
||||
* "Task mode" — are far longer than the Chinese ones), so labels collapse as a
|
||||
* group before any single one truncates, in every language. Tune here once;
|
||||
* both toolbars (AiChatInput, TaskModeInput) read this same value.
|
||||
*/
|
||||
export const TOOLBAR_COMPACT_THRESHOLD = 440;
|
||||
|
||||
/**
|
||||
* Report whether a container's inline width has dropped below `threshold`,
|
||||
* measured live via a ResizeObserver.
|
||||
*
|
||||
* Input toolbars collapse their button labels to icon-only when space runs
|
||||
* out. Viewport media queries are the wrong signal for that: the same viewport
|
||||
* width leaves very different room once the sidebar opens. This hook measures
|
||||
* the ACTUAL space available (the flex-1 toolbar column), so labels collapse
|
||||
* exactly when the box that holds them can no longer fit them.
|
||||
*
|
||||
* The observed element must be layout-sized (e.g. `flex-1`), NOT content-sized:
|
||||
* its width is then decided by the row, independent of whether labels are shown,
|
||||
* so hiding labels can't feed back into the measurement and oscillate.
|
||||
*
|
||||
* Returns a callback ref (re-attaches the observer across conditional remounts,
|
||||
* which a deps-based effect would miss) plus the current `compact` flag.
|
||||
*/
|
||||
export function useContainerCompact(threshold: number) {
|
||||
const [compact, setCompact] = useState(false);
|
||||
const observerRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
const ref = useCallback(
|
||||
(el: HTMLElement | null) => {
|
||||
observerRef.current?.disconnect();
|
||||
observerRef.current = null;
|
||||
if (!el) return;
|
||||
const update = () => setCompact(el.clientWidth < threshold);
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
observerRef.current = ro;
|
||||
},
|
||||
[threshold],
|
||||
);
|
||||
|
||||
return { ref, compact };
|
||||
}
|
||||
@@ -1520,8 +1520,8 @@
|
||||
"empty_go_explore": "No apps used yet. Head to the app marketplace",
|
||||
"explore_more": "Explore more apps",
|
||||
"recent_apps_hint": "Your recently used apps are all here~",
|
||||
"service_maintenance_title": "System under maintenance",
|
||||
"service_maintenance": "We're performing emergency maintenance; service will be back soon.",
|
||||
"service_maintenance_title": "Oops, we hit a small hiccup",
|
||||
"service_maintenance": "Hang tight for a moment — we'll be back to normal shortly.",
|
||||
"refresh": "Refresh to retry"
|
||||
},
|
||||
"com_knowledge": {
|
||||
|
||||
@@ -1444,8 +1444,8 @@
|
||||
"empty_go_explore": "利用したアプリはまだありません。アプリ広場へどうぞ",
|
||||
"explore_more": "さらにアプリを探す",
|
||||
"recent_apps_hint": "最近使用したアプリはすべてここにあります~",
|
||||
"service_maintenance_title": "システムメンテナンス中",
|
||||
"service_maintenance": "緊急メンテナンスを実施中です。まもなく復旧します。",
|
||||
"service_maintenance_title": "おっと、システムに少し問題が発生しました",
|
||||
"service_maintenance": "少々お待ちください。まもなく通常どおりご利用いただけます。",
|
||||
"refresh": "再読み込み"
|
||||
},
|
||||
"com_knowledge": {
|
||||
|
||||
@@ -1447,8 +1447,8 @@
|
||||
"empty_go_explore": "暂无使用过的应用,可以前往应用广场",
|
||||
"explore_more": "探索更多应用",
|
||||
"recent_apps_hint": "最近使用过的应用都在这里~",
|
||||
"service_maintenance_title": "系统维护中",
|
||||
"service_maintenance": "我们正在进行紧急维护,服务将尽快恢复。",
|
||||
"service_maintenance_title": "哎呀,系统出了点小状况",
|
||||
"service_maintenance": "短暂停留一下,马上恢复正常使用",
|
||||
"refresh": "刷新重试"
|
||||
},
|
||||
"com_knowledge": {
|
||||
|
||||
@@ -792,7 +792,7 @@ export function ArticleList({
|
||||
<LoadingIcon className="size-20 text-primary" />
|
||||
</div>
|
||||
) : articles.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center py-60 text-center">
|
||||
<div className="flex flex-1 flex-col items-center justify-center py-8 text-center">
|
||||
{(searchQuery || selectedSources.length > 0 || onlyUnread) ? (
|
||||
<>
|
||||
<EmptyStateIllustration className="size-[120px] mb-4 opacity-90" />
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ChannelRole } from "~/api/channels";
|
||||
import type { Channel } from "~/api/channels";
|
||||
import { ChannelActionsMenu } from "./ChannelActionsMenu";
|
||||
|
||||
// react-query is mocked so the component reads channel lists straight from the
|
||||
// stubbed cache; queryKey[1] is "created" | "subscribed" (see component).
|
||||
const mockLists: Record<string, Channel[]> = { created: [], subscribed: [] };
|
||||
jest.mock("@tanstack/react-query", () => ({
|
||||
useQuery: ({ queryKey }: { queryKey: unknown[] }) => ({
|
||||
data: mockLists[queryKey[1] as string] ?? [],
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockHandleDeleteChannel = jest.fn();
|
||||
const mockHandleUnsubscribeChannel = jest.fn();
|
||||
jest.mock("../hooks/useChannelActions", () => ({
|
||||
useChannelActions: () => ({
|
||||
handleDeleteChannel: mockHandleDeleteChannel,
|
||||
handleUnsubscribeChannel: mockHandleUnsubscribeChannel,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("~/hooks", () => ({
|
||||
useLocalize: () => (key: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
"com_subscription.channel_settings": "频道设置",
|
||||
"com_subscription.edit_channel": "编辑频道",
|
||||
"com_subscription.permission_management": "权限管理",
|
||||
"com_subscription.share": "分享",
|
||||
"com_subscription.source_filter": "信息源筛选",
|
||||
"com_subscription.dissolve_channel": "解散频道",
|
||||
"com_subscription.delete_channel": "删除频道",
|
||||
"com_subscription.unsubscribe": "取消订阅",
|
||||
"com_subscription.prompt_tip": "提示",
|
||||
"com_subscription.confirm_delete_channel_for_all": "删除频道",
|
||||
"com_subscription.confirm_unsubscribe_channel_and_subs": "取消订阅",
|
||||
"com_subscription.confirm": "确认",
|
||||
"com_subscription.cancel": "取消",
|
||||
};
|
||||
return labels[key] ?? key;
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("~/Providers", () => ({
|
||||
useConfirm: () => jest.fn().mockResolvedValue(true),
|
||||
useToastContext: () => ({ showToast: jest.fn() }),
|
||||
}));
|
||||
|
||||
jest.mock("bisheng-icons", () => ({
|
||||
Outlined: new Proxy(
|
||||
{},
|
||||
{ get: () => () => <span data-testid="icon" /> },
|
||||
),
|
||||
}));
|
||||
|
||||
const createChannel = (role: Channel["role"], permissionIds?: string[]): Channel => ({
|
||||
id: "channel-1",
|
||||
name: "资讯频道",
|
||||
creator: "owner",
|
||||
creatorId: "1",
|
||||
subscriberCount: 3,
|
||||
articleCount: 5,
|
||||
unreadCount: 0,
|
||||
role,
|
||||
isPinned: false,
|
||||
createdAt: "2026-05-28T00:00:00Z",
|
||||
updatedAt: "2026-05-28T00:00:00Z",
|
||||
subChannels: [],
|
||||
permissionIds,
|
||||
});
|
||||
|
||||
function renderMenu(
|
||||
list: "created" | "subscribed",
|
||||
role: Channel["role"],
|
||||
permissionIds?: string[],
|
||||
) {
|
||||
const channel = createChannel(role, permissionIds);
|
||||
mockLists.created = [];
|
||||
mockLists.subscribed = [];
|
||||
mockLists[list] = [channel];
|
||||
const props = {
|
||||
channel,
|
||||
onChannelSelect: jest.fn(),
|
||||
onManageMembers: jest.fn(),
|
||||
onChannelSettings: jest.fn(),
|
||||
};
|
||||
const view = render(<ChannelActionsMenu {...props} />);
|
||||
return { ...view, props };
|
||||
}
|
||||
|
||||
async function openMenu(container: HTMLElement) {
|
||||
const user = userEvent.setup();
|
||||
const trigger = container.querySelector("button");
|
||||
expect(trigger).not.toBeNull();
|
||||
await user.click(trigger as HTMLButtonElement);
|
||||
return user;
|
||||
}
|
||||
|
||||
describe("ChannelActionsMenu permission gating", () => {
|
||||
beforeEach(() => {
|
||||
mockHandleDeleteChannel.mockClear();
|
||||
mockHandleUnsubscribeChannel.mockClear();
|
||||
});
|
||||
|
||||
it("shows channel settings to a granted owner whose channel sits in the followed list", async () => {
|
||||
const { container } = renderMenu("subscribed", "owner", [
|
||||
"view_channel",
|
||||
"edit_channel",
|
||||
"delete_channel",
|
||||
"manage_channel_owner",
|
||||
]);
|
||||
await openMenu(container);
|
||||
|
||||
expect(await screen.findByText("频道设置")).toBeInTheDocument();
|
||||
expect(screen.getByText("权限管理")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows both dissolve and unsubscribe to a granted owner in the followed list", async () => {
|
||||
const { container } = renderMenu("subscribed", "owner", [
|
||||
"view_channel",
|
||||
"edit_channel",
|
||||
"delete_channel",
|
||||
"manage_channel_owner",
|
||||
]);
|
||||
await openMenu(container);
|
||||
|
||||
expect(await screen.findByText("解散频道")).toBeInTheDocument();
|
||||
expect(screen.getByText("取消订阅")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows channel settings to an editor (edit permission, no delete) in the followed list", async () => {
|
||||
const { container } = renderMenu("subscribed", "editor", ["view_channel", "edit_channel"]);
|
||||
await openMenu(container);
|
||||
|
||||
expect(await screen.findByText("频道设置")).toBeInTheDocument();
|
||||
// Editor cannot dissolve (no delete_channel) but can leave.
|
||||
expect(screen.queryByText("解散频道")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("取消订阅")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides channel settings from a plain subscriber (viewer)", async () => {
|
||||
const { container } = renderMenu("subscribed", "viewer", ["view_channel"]);
|
||||
await openMenu(container);
|
||||
|
||||
expect(await screen.findByText("取消订阅")).toBeInTheDocument();
|
||||
expect(screen.queryByText("频道设置")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("解散频道")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows only dissolve (no unsubscribe) for the creator's own channel", async () => {
|
||||
const { container } = renderMenu("created", ChannelRole.CREATOR);
|
||||
await openMenu(container);
|
||||
|
||||
expect(await screen.findByText("频道设置")).toBeInTheDocument();
|
||||
expect(screen.getByText("解散频道")).toBeInTheDocument();
|
||||
expect(screen.queryByText("取消订阅")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,191 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ChannelRole } from "~/api/channels";
|
||||
import type { Channel } from "~/api/channels";
|
||||
import ChannelItem from "./ChannelItem";
|
||||
|
||||
jest.mock("~/hooks", () => ({
|
||||
useLocalize: () => (key: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
"com_subscription.channel_settings": "频道设置",
|
||||
"com_subscription.member_management": "成员管理",
|
||||
"com_subscription.unpin": "取消置顶",
|
||||
"com_subscription.pin_channel": "置顶频道",
|
||||
"com_subscription.prompt_tip": "提示",
|
||||
"com_subscription.confirm_unsubscribe_channel_and_subs": "取消订阅",
|
||||
"com_subscription.confirm_delete_channel_for_all": "删除频道",
|
||||
"com_subscription.confirm": "确认",
|
||||
"com_subscription.cancel": "取消",
|
||||
"com_subscription.max_10_characters": "最多10个字符",
|
||||
"com_subscription.dissolve_channel": "解散频道",
|
||||
"com_subscription.unsubscribe": "取消订阅",
|
||||
};
|
||||
return labels[key] ?? key;
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("~/Providers", () => ({
|
||||
useConfirm: () => jest.fn().mockResolvedValue(true),
|
||||
useToastContext: () => ({
|
||||
showToast: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("~/components/ui/icon/ClosedIcon", () => ({
|
||||
__esModule: true,
|
||||
default: () => <span data-testid="closed-icon" />,
|
||||
}));
|
||||
|
||||
jest.mock("~/components/icons/channels", () => ({
|
||||
ChannelPinIcon: () => <span data-testid="pin-icon" />,
|
||||
}));
|
||||
|
||||
jest.mock("~/components/icons/SpaceNotebookIcon", () => ({
|
||||
SpaceNotebookIcon: () => <span data-testid="notebook-icon" />,
|
||||
}));
|
||||
|
||||
const createChannel = (role: Channel["role"], permissionIds?: string[]): Channel => ({
|
||||
id: "channel-1",
|
||||
name: "资讯频道",
|
||||
creator: "owner",
|
||||
creatorId: "1",
|
||||
subscriberCount: 3,
|
||||
articleCount: 5,
|
||||
unreadCount: 0,
|
||||
role,
|
||||
isPinned: false,
|
||||
createdAt: "2026-05-28T00:00:00Z",
|
||||
updatedAt: "2026-05-28T00:00:00Z",
|
||||
subChannels: [],
|
||||
permissionIds,
|
||||
});
|
||||
|
||||
function renderChannelItem(
|
||||
role: Channel["role"],
|
||||
type: "created" | "subscribed" = "subscribed",
|
||||
permissionIds?: string[],
|
||||
) {
|
||||
const props = {
|
||||
channel: createChannel(role, permissionIds),
|
||||
isActive: false,
|
||||
type,
|
||||
onSelect: jest.fn(),
|
||||
onUpdate: jest.fn(),
|
||||
onDelete: jest.fn(),
|
||||
onUnsubscribe: jest.fn(),
|
||||
onPin: jest.fn(),
|
||||
onManageMembers: jest.fn(),
|
||||
onChannelSettings: jest.fn(),
|
||||
};
|
||||
|
||||
const view = render(<ChannelItem {...props} />);
|
||||
return { ...view, props };
|
||||
}
|
||||
|
||||
describe("ChannelItem relation actions", () => {
|
||||
it("shows channel settings to editor without member management", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderChannelItem("editor");
|
||||
const menuTrigger = container.querySelector("button");
|
||||
|
||||
expect(menuTrigger).not.toBeNull();
|
||||
await user.click(menuTrigger as HTMLButtonElement);
|
||||
|
||||
expect(await screen.findByText("频道设置")).toBeInTheDocument();
|
||||
expect(screen.queryByText("成员管理")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show channel settings to viewer", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderChannelItem("viewer");
|
||||
const menuTrigger = container.querySelector("button");
|
||||
|
||||
expect(menuTrigger).not.toBeNull();
|
||||
await user.click(menuTrigger as HTMLButtonElement);
|
||||
|
||||
expect(screen.queryByText("频道设置")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("成员管理")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides member management when manager model no longer grants it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderChannelItem("manager", "subscribed", ["view_channel", "edit_channel"]);
|
||||
const menuTrigger = container.querySelector("button");
|
||||
|
||||
expect(menuTrigger).not.toBeNull();
|
||||
await user.click(menuTrigger as HTMLButtonElement);
|
||||
|
||||
expect(await screen.findByText("频道设置")).toBeInTheDocument();
|
||||
expect(screen.queryByText("成员管理")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps legacy creator able to open channel settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderChannelItem(ChannelRole.CREATOR, "created");
|
||||
const menuTrigger = container.querySelector("button");
|
||||
|
||||
expect(menuTrigger).not.toBeNull();
|
||||
await user.click(menuTrigger as HTMLButtonElement);
|
||||
|
||||
expect(await screen.findByText("频道设置")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows both dissolve and unsubscribe to a subscribed user granted delete_channel", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderChannelItem("manager", "subscribed", [
|
||||
"view_channel",
|
||||
"edit_channel",
|
||||
"delete_channel",
|
||||
]);
|
||||
const menuTrigger = container.querySelector("button");
|
||||
|
||||
expect(menuTrigger).not.toBeNull();
|
||||
await user.click(menuTrigger as HTMLButtonElement);
|
||||
|
||||
expect(await screen.findByText("解散频道")).toBeInTheDocument();
|
||||
expect(screen.getByText("取消订阅")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows only dissolve (no unsubscribe) for a created channel", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderChannelItem(ChannelRole.CREATOR, "created", [
|
||||
"view_channel",
|
||||
"edit_channel",
|
||||
"delete_channel",
|
||||
]);
|
||||
const menuTrigger = container.querySelector("button");
|
||||
|
||||
expect(menuTrigger).not.toBeNull();
|
||||
await user.click(menuTrigger as HTMLButtonElement);
|
||||
|
||||
expect(await screen.findByText("解散频道")).toBeInTheDocument();
|
||||
expect(screen.queryByText("取消订阅")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("triggers onDelete when a delete-permitted subscriber dissolves", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container, props } = renderChannelItem("manager", "subscribed", [
|
||||
"view_channel",
|
||||
"delete_channel",
|
||||
]);
|
||||
const menuTrigger = container.querySelector("button");
|
||||
|
||||
await user.click(menuTrigger as HTMLButtonElement);
|
||||
await user.click(await screen.findByText("解散频道"));
|
||||
|
||||
expect(props.onDelete).toHaveBeenCalledWith("channel-1");
|
||||
expect(props.onUnsubscribe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows unsubscribe to a subscriber without delete permission", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderChannelItem("viewer", "subscribed", ["view_channel"]);
|
||||
const menuTrigger = container.querySelector("button");
|
||||
|
||||
expect(menuTrigger).not.toBeNull();
|
||||
await user.click(menuTrigger as HTMLButtonElement);
|
||||
|
||||
expect(await screen.findByText("取消订阅")).toBeInTheDocument();
|
||||
expect(screen.queryByText("解散频道")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,118 +0,0 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { NotificationSeverity } from "~/common";
|
||||
import type { Channel } from "~/api/channels";
|
||||
import { SortType } from "~/api/channels";
|
||||
import { useChannelActions } from "./useChannelActions";
|
||||
|
||||
const mockShowToast = jest.fn();
|
||||
const mockUnsubscribeChannelApi = jest.fn();
|
||||
|
||||
jest.mock("~/hooks", () => ({
|
||||
useLocalize: () => (key: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
"com_subscription.unsubscribe_failed_retry": "取消订阅失败,请重试",
|
||||
"com_subscription.unsubscribed": "已取消订阅",
|
||||
"com_subscription.organization_grant_unsubscribe_blocked": ORGANIZATION_GRANT_MESSAGE,
|
||||
};
|
||||
return labels[key] ?? key;
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("~/Providers", () => ({
|
||||
useToastContext: () => ({
|
||||
showToast: mockShowToast,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("~/api/channels", () => ({
|
||||
SortType: {
|
||||
RECENT_UPDATE: "latest_update",
|
||||
RECENT_ADDED: "latest_added",
|
||||
NAME: "channel_name",
|
||||
},
|
||||
pinChannelApi: jest.fn(),
|
||||
updateChannelApi: jest.fn(),
|
||||
deleteChannelApi: jest.fn(),
|
||||
unsubscribeChannelApi: (...args: unknown[]) => mockUnsubscribeChannelApi(...args),
|
||||
}));
|
||||
|
||||
const ORGANIZATION_GRANT_MESSAGE = "本频道通过部门/用户组授权给你,暂无法取消订阅";
|
||||
|
||||
function createChannel(id = "channel-1"): Channel {
|
||||
return {
|
||||
id,
|
||||
name: "资讯频道",
|
||||
creator: "owner",
|
||||
creatorId: "1",
|
||||
subscriberCount: 3,
|
||||
articleCount: 5,
|
||||
unreadCount: 0,
|
||||
role: "viewer",
|
||||
isPinned: false,
|
||||
createdAt: "2026-05-28T00:00:00Z",
|
||||
updatedAt: "2026-05-28T00:00:00Z",
|
||||
subChannels: [],
|
||||
permissionIds: ["view_channel"],
|
||||
};
|
||||
}
|
||||
|
||||
describe("useChannelActions unsubscribe", () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
mockShowToast.mockClear();
|
||||
mockUnsubscribeChannelApi.mockReset();
|
||||
});
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
it("shows organization grant message and restores optimistic state when unsubscribe is blocked", async () => {
|
||||
const channel = createChannel();
|
||||
const onChannelSelect = jest.fn();
|
||||
queryClient.setQueryData(["channels", "subscribed", SortType.RECENT_UPDATE], [channel]);
|
||||
const invalidateQueriesSpy = jest.spyOn(queryClient, "invalidateQueries");
|
||||
mockUnsubscribeChannelApi.mockResolvedValue({
|
||||
status_code: 19055,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useChannelActions({
|
||||
activeChannelId: channel.id,
|
||||
createdSortBy: SortType.RECENT_UPDATE,
|
||||
subscribedSortBy: SortType.RECENT_UPDATE,
|
||||
createdChannels: [],
|
||||
subscribedChannels: [channel],
|
||||
onChannelSelect,
|
||||
}), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleUnsubscribeChannel(channel.id);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith({
|
||||
message: ORGANIZATION_GRANT_MESSAGE,
|
||||
severity: NotificationSeverity.ERROR,
|
||||
});
|
||||
});
|
||||
expect(mockShowToast).not.toHaveBeenCalledWith(expect.objectContaining({
|
||||
message: "已取消订阅",
|
||||
}));
|
||||
expect(queryClient.getQueryData(["channels", "subscribed", SortType.RECENT_UPDATE])).toEqual([channel]);
|
||||
expect(onChannelSelect).toHaveBeenLastCalledWith(channel);
|
||||
expect(onChannelSelect).not.toHaveBeenCalledWith(null);
|
||||
expect(mockUnsubscribeChannelApi).toHaveBeenCalledWith(channel.id);
|
||||
expect(invalidateQueriesSpy).not.toHaveBeenCalledWith({
|
||||
queryKey: ["channels", "subscribed"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { NotificationSeverity } from "~/common";
|
||||
import { useCrawlQueue } from "./useCrawlQueue";
|
||||
|
||||
const mockShowToast = jest.fn();
|
||||
const mockCrawlTempSourceApi = jest.fn();
|
||||
const mockAddWebsiteSourceApi = jest.fn();
|
||||
|
||||
// p-limit ships as ESM and is not transformed by jest; replace it with a
|
||||
// pass-through limiter that runs the task immediately.
|
||||
jest.mock("p-limit", () => ({
|
||||
__esModule: true,
|
||||
default: () => (fn: () => unknown) => fn(),
|
||||
}));
|
||||
|
||||
jest.mock("~/utils", () => ({
|
||||
generateUUID: () => "test-crawl-id",
|
||||
}));
|
||||
|
||||
jest.mock("~/hooks", () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
jest.mock("~/Providers", () => ({
|
||||
useToastContext: () => ({ showToast: mockShowToast }),
|
||||
}));
|
||||
|
||||
jest.mock("~/api/channels", () => ({
|
||||
crawlTempSourceApi: (...args: unknown[]) => mockCrawlTempSourceApi(...args),
|
||||
addWebsiteSourceApi: (...args: unknown[]) => mockAddWebsiteSourceApi(...args),
|
||||
}));
|
||||
|
||||
describe("useCrawlQueue API-key limit handling", () => {
|
||||
beforeEach(() => {
|
||||
mockShowToast.mockClear();
|
||||
mockCrawlTempSourceApi.mockReset();
|
||||
mockAddWebsiteSourceApi.mockReset();
|
||||
});
|
||||
|
||||
it("shows an error popup when crawl is rejected with the 19006 API-key limit code", async () => {
|
||||
mockCrawlTempSourceApi.mockResolvedValue({ status_code: 19006 });
|
||||
|
||||
const { result } = renderHook(() => useCrawlQueue({ onSourceAdded: jest.fn() }));
|
||||
|
||||
act(() => {
|
||||
result.current.enqueue("https://example.com");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith({
|
||||
message: "api_errors.19006",
|
||||
severity: NotificationSeverity.ERROR,
|
||||
});
|
||||
});
|
||||
// The site is never added once the account quota is exhausted.
|
||||
expect(mockAddWebsiteSourceApi).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not popup for per-site crawl failures (19003), which stay as queue tooltips", async () => {
|
||||
mockCrawlTempSourceApi.mockResolvedValue({ status_code: 19003 });
|
||||
|
||||
const { result } = renderHook(() => useCrawlQueue({ onSourceAdded: jest.fn() }));
|
||||
|
||||
act(() => {
|
||||
result.current.enqueue("https://example.com/article/123");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.queue[0]?.status).toBe("failed");
|
||||
});
|
||||
expect(mockShowToast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -109,8 +109,9 @@ export function ConfirmDialogSection() {
|
||||
subtitle={
|
||||
<>
|
||||
删除/危险操作时的「确认 / 取消」小弹窗。两套体系:旧页面走{' '}
|
||||
<code>OGDialogTemplate selection</code>(剩 14 文件),新页面走 <code>useConfirm()</code>
|
||||
(24 文件,含已迁入的 8 处)。<b>收敛第一步已完成</b>:B 套壳与按钮已对齐 C 套,历史 9 种 selectClasses
|
||||
<code>OGDialogTemplate selection</code>(剩 13 文件:7 处死 UI 确认 + 6 处表单弹窗),新页面走{' '}
|
||||
<code>useConfirm()</code>(26 文件,含已迁入的 10 处)。
|
||||
<b>用户可见的真确认已全部迁完</b>;死 UI(被注释的 SidePanel 树 + 无人引用的 Chat/Header 树)待死代码清理,表单弹窗归 Modal 期。<b>收敛第一步已完成</b>:B 套壳与按钮已对齐 C 套,历史 9 种 selectClasses
|
||||
被自动折叠为 danger / primary 两档 —— 下方旧写法卡片现在应呈现统一外观,逐个打开即是验收。
|
||||
(另有 9 个文件手拼 <code>AlertDialog</code> —— 属于普通弹窗,归 Modal 改造范围,本期不动。)
|
||||
</>
|
||||
@@ -126,7 +127,7 @@ export function ConfirmDialogSection() {
|
||||
<>
|
||||
<code>OGDialogTemplate</code> + <code>selection</code>
|
||||
</>,
|
||||
'14(原 21,迁移中)',
|
||||
'13(原 21;剩余全是死 UI 或表单)',
|
||||
'旧页面(会话/书签/Agent/设置/Prompt…LibreChat 血统)',
|
||||
'差 · 确认按钮 9 种写法',
|
||||
],
|
||||
@@ -135,7 +136,7 @@ export function ConfirmDialogSection() {
|
||||
<>
|
||||
<code>useConfirm()</code>(ConfirmContext + AlertDialog)
|
||||
</>,
|
||||
'24(收敛目标,含已迁入 8 处)',
|
||||
'26(收敛完成,含已迁入 10 处)',
|
||||
'新页面(知识空间 / 订阅频道 / 权限)',
|
||||
'好 · 样式集中在一个文件,destructive/default 两档',
|
||||
],
|
||||
@@ -151,20 +152,20 @@ export function ConfirmDialogSection() {
|
||||
[
|
||||
'1',
|
||||
<code key="c">bg-red-700 dark:bg-red-600 hover:bg-red-800 …</code>,
|
||||
'删除(书签/工具/分享链接…)· 删会话 2 处已迁 C',
|
||||
'6(原 8)',
|
||||
'可达的 4 处已迁 C;剩 4 处全是死 UI(书签/分享弹窗/两个工具移除)',
|
||||
'4(原 8)· 全死 UI',
|
||||
],
|
||||
[
|
||||
'2',
|
||||
<code key="c">bg-red-600 hover:bg-red-700 dark:hover:bg-red-800</code>,
|
||||
'删除(Agent / Assistant)· Prompt 组已迁 C',
|
||||
'2(原 3)',
|
||||
'删除 Agent / Assistant —— 死 UI(SidePanel 被注释)',
|
||||
'2(原 3)· 全死 UI',
|
||||
],
|
||||
[
|
||||
'3',
|
||||
<code key="c">bg-red-600 hover:bg-red-700 dark:hover:bg-red-600</code>,
|
||||
'清空预设',
|
||||
'1',
|
||||
'清空预设 —— 死 UI(Chat/Header 无人引用)',
|
||||
'1 · 死 UI',
|
||||
],
|
||||
[
|
||||
'4',
|
||||
@@ -292,7 +293,7 @@ export function ConfirmDialogSection() {
|
||||
/>
|
||||
<ConfirmDemo
|
||||
label="Loading 态(isLoading: true)"
|
||||
note="模板内置 Spinner · 自塞 Spinner 的只剩 SharedLinks 1 处(原 4 处,3 处已迁 C)"
|
||||
note="模板内置 Spinner · 各页自塞 Spinner 的写法已随迁移清零(原 4 处)"
|
||||
title="删除会话"
|
||||
body="确认按钮处于加载中。"
|
||||
selectText="删除"
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
/**
|
||||
* Modal / Dialog gallery — DEV-ONLY. See docs-ui-refactor/组件-Modal弹窗.md.
|
||||
*
|
||||
* Renders BiSheng's two parallel dialog families side by side so the difference
|
||||
* (overlay darkness / blur / z-index) is visible by opening them.
|
||||
* Fresh survey (2026-07-09): FIVE coexisting modal populations. The same demo
|
||||
* content (title + description + input + cancel/confirm footer) is mounted into
|
||||
* each shell so the shell differences (overlay / radius / padding / title /
|
||||
* buttons) are the only variable when opening them side by side.
|
||||
*/
|
||||
import { Button } from '~/components/ui/Button';
|
||||
import { Input } from '~/components/ui/Input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
@@ -23,16 +26,46 @@ import {
|
||||
OGDialogTitle,
|
||||
OGDialogDescription,
|
||||
} from '~/components/ui/OriginalDialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
} from '~/components/ui/AlertDialog';
|
||||
import DialogTemplate from '~/components/ui/DialogTemplate';
|
||||
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
||||
import { useConfirm } from '~/Providers';
|
||||
import { Section, Demo, DemoGrid, CompareTable } from '../components/kit';
|
||||
|
||||
const sampleBody = (
|
||||
<p className="text-sm text-text-primary">
|
||||
这里是弹窗正文示例。放一段说明文字、表单或列表,用来观察内边距、行距与滚动表现。
|
||||
</p>
|
||||
/** Identical body for every shell so only the shell itself differs. */
|
||||
const demoBody = (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm text-text-primary">
|
||||
这里是弹窗正文示例。观察内边距、行距与正文和标题/按钮的间距。
|
||||
</p>
|
||||
<Input placeholder="示例输入框" />
|
||||
</div>
|
||||
);
|
||||
|
||||
/** Small reference demo of the finalized confirm dialog, for shell comparison. */
|
||||
function ConfirmReferenceDemo() {
|
||||
const confirm = useConfirm();
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
confirm({
|
||||
variant: 'destructive',
|
||||
title: '确认删除',
|
||||
description: '弹窗壳视觉参照:圆角16 / p-5 / 灰底毛玻璃遮罩。',
|
||||
confirmText: '确认删除',
|
||||
})
|
||||
}
|
||||
>
|
||||
打开
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModalSection() {
|
||||
return (
|
||||
<Section
|
||||
@@ -40,94 +73,263 @@ export function ModalSection() {
|
||||
title="Modal 弹窗"
|
||||
subtitle={
|
||||
<>
|
||||
现状有 <b>两套并行体系</b>:A 套 <code>Dialog</code>(毛玻璃遮罩 z-100)与 B 套{' '}
|
||||
<code>OriginalDialog / OG</code>(纯深色遮罩 z-50)。逐个打开对比背景暗度、模糊、层级。
|
||||
2026-07-09 重新盘点:全站普通弹窗共 <b>5 个并行体系、约 64 个业务文件</b>
|
||||
。二次确认期已把 B 套壳对齐 C 套视觉,但 A 套(原语直接拼 22 处 + 模板 3
|
||||
处)与手拼 AlertDialog(7 处)仍是另外两种壳。逐个打开对比,定统一标准。
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* Difference table */}
|
||||
<div className="mb-6">
|
||||
{/* ① Population overview */}
|
||||
<h3 className="mb-3 mt-2 text-base font-semibold text-text-primary">
|
||||
① 用量盘子(当前精确扫描,排除 ui/ 与画廊)
|
||||
</h3>
|
||||
<div className="mb-8">
|
||||
<CompareTable
|
||||
head={['维度', 'A 套 Dialog', 'B 套 OriginalDialog / OG']}
|
||||
head={['体系', '实现', '业务文件数', '用在哪 / 备注']}
|
||||
rows={[
|
||||
['遮罩颜色', 'bg-black/40(浅)', 'bg-black/80(深)'],
|
||||
['毛玻璃模糊', '有 backdrop-blur-md', '无'],
|
||||
['层级 z-index', 'z-[100]', 'z-50'],
|
||||
['便捷模板', 'DialogTemplate(~4 处)', 'OGDialogTemplate(~25 处 · 用得最多)'],
|
||||
[
|
||||
'A 套 · 原语直接拼',
|
||||
'Dialog + DialogContent 手拼',
|
||||
<b key="a1">22(最大人群)</b>,
|
||||
'新页面为主:知识库 8、订阅 2、审批/通知/账号/分享/appChat…(含 MainLayout 全局弹窗)',
|
||||
],
|
||||
[
|
||||
'A 套 · 模板',
|
||||
'DialogTemplate',
|
||||
'3',
|
||||
'EditPresetDialog、PresetItems、ContextButton(后者在 SidePanel 死树)',
|
||||
],
|
||||
[
|
||||
'B 套 · 模板',
|
||||
'OGDialogTemplate',
|
||||
'16(原 25,确认迁移后)',
|
||||
'书签/导出/SetKey/归档/Agent 面板…(其中 SidePanel 死树约 6 处);壳已对齐 C 套',
|
||||
],
|
||||
[
|
||||
'B 套 · 原语直接拼',
|
||||
'OGDialog + OGDialogContent 手拼',
|
||||
'16',
|
||||
'设置(账号/数据)、Prompts、文件预览、ShareAgent…;壳同上(已对齐 C 套)',
|
||||
],
|
||||
[
|
||||
'手拼 AlertDialog',
|
||||
'AlertDialogContent + 自拼头尾',
|
||||
'7',
|
||||
'频道成员 2、爬取系 4、灵思 TaskModeInput(部分带确认性质,本期一并处理)',
|
||||
],
|
||||
[
|
||||
'C 套 · useConfirm(参照)',
|
||||
'ConfirmContext(AlertDialog 底层)',
|
||||
'26(已收敛 ✅)',
|
||||
'二次确认已定稿的视觉基准:圆角16 / p-5 / 灰底毛玻璃 —— Modal 壳的天然候选',
|
||||
],
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DemoGrid cols={4}>
|
||||
{/* A-set raw primitives */}
|
||||
<Demo label="A 套 · Dialog 原语" note="ui/Dialog.tsx · 毛玻璃遮罩">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">打开</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Dialog 原语</DialogTitle>
|
||||
<DialogDescription>手动拼 Header / Footer 的底层版本。</DialogDescription>
|
||||
</DialogHeader>
|
||||
{sampleBody}
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
{/* ② Shell anatomy */}
|
||||
<h3 className="mb-3 text-base font-semibold text-text-primary">
|
||||
② 壳样式解剖(源码当前真实值)
|
||||
</h3>
|
||||
<div className="mb-8">
|
||||
<CompareTable
|
||||
head={['维度', 'A 套 Dialog', 'B 套 OriginalDialog(已对齐 C 套)', 'AlertDialog(手拼底座)']}
|
||||
rows={[
|
||||
[
|
||||
'遮罩',
|
||||
'bg-black/40 + blur(浅黑毛玻璃)',
|
||||
'bg-gray-500/90 + blur(灰白毛玻璃)',
|
||||
'bg-gray-500/90 + blur(同 B)',
|
||||
],
|
||||
['层级 z-index', 'z-[100]', 'z-50', 'z-[110]'],
|
||||
['圆角', 'sm:rounded-lg(8px,移动端直角)', 'rounded-2xl(16px)', 'sm:rounded-lg(8px,移动端直角)'],
|
||||
['内边距', 'p-5(20px)', 'p-5(20px)', 'p-6(24px)'],
|
||||
[
|
||||
'边框 / 阴影',
|
||||
'border + shadow-lg',
|
||||
'border #ebebeb + 淡投影',
|
||||
'无边框、无阴影',
|
||||
],
|
||||
[
|
||||
'标题',
|
||||
'text-base font-semibold',
|
||||
'text-base font-medium',
|
||||
'组件是 text-lg semibold,但各页多自拼标题行',
|
||||
],
|
||||
[
|
||||
'关闭按钮 ×',
|
||||
'内置右上(可关)',
|
||||
'内置右上(可关)',
|
||||
'无内置,各页自拼',
|
||||
],
|
||||
[
|
||||
'深色模式底色',
|
||||
'dark:bg-[#303134](写死)',
|
||||
'bg-background(跟主题)',
|
||||
'dark:bg-gray-900',
|
||||
],
|
||||
[
|
||||
'移动端行为',
|
||||
'居中缩放出现',
|
||||
'居中缩放出现',
|
||||
'从底部滑入、贴底',
|
||||
],
|
||||
[
|
||||
'footer 按钮',
|
||||
'各页自拼(多为 Button outline + default)',
|
||||
'模板:取消白底描边 + 确认 danger/primary 档;直接拼的各页自理',
|
||||
'各页自拼(红 #F53F3F 等)',
|
||||
],
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ③ Side-by-side demos — identical content, different shells */}
|
||||
<h3 className="mb-3 text-base font-semibold text-text-primary">
|
||||
③ 同一内容装进各个壳(逐个打开对比)
|
||||
</h3>
|
||||
<div className="mb-8">
|
||||
<DemoGrid cols={3}>
|
||||
{/* A-set raw primitives — the largest population */}
|
||||
<Demo label="A 套 · 原语直接拼(22 处)" note="ui/Dialog.tsx · 浅黑毛玻璃 · 圆角8 · p-5">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">打开</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>弹窗标题</DialogTitle>
|
||||
<DialogDescription>标题下的说明文字。</DialogDescription>
|
||||
</DialogHeader>
|
||||
{demoBody}
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">取消</Button>
|
||||
</DialogClose>
|
||||
<Button>确定</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Demo>
|
||||
|
||||
{/* A-set template — legacy default black confirm button */}
|
||||
<Demo label="A 套 · DialogTemplate(3 处)" note="旧模板 · 默认黑底确认钮 · 正文额外 px-6">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">打开</Button>
|
||||
</DialogTrigger>
|
||||
<DialogTemplate
|
||||
title="弹窗标题"
|
||||
description="标题下的说明文字。"
|
||||
main={demoBody}
|
||||
selection={{ selectHandler: () => null, selectText: '确定' }}
|
||||
/>
|
||||
</Dialog>
|
||||
</Demo>
|
||||
|
||||
{/* B-set template — shell already aligned with C-set */}
|
||||
<Demo label="B 套 · OGDialogTemplate(16 处)" note="壳已对齐 C 套 · 圆角16 · 取消/确认已统一">
|
||||
<OGDialog>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button variant="outline">打开</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogTemplate
|
||||
title="弹窗标题"
|
||||
description="标题下的说明文字。"
|
||||
className="max-w-md"
|
||||
main={demoBody}
|
||||
selection={{
|
||||
selectHandler: () => null,
|
||||
selectVariant: 'primary',
|
||||
selectText: '确定',
|
||||
}}
|
||||
/>
|
||||
</OGDialog>
|
||||
</Demo>
|
||||
|
||||
{/* B-set raw primitives — same shell, hand-rolled body/footer */}
|
||||
<Demo label="B 套 · OG 原语直接拼(16 处)" note="壳同左 · 头尾各页自拼(设置/Prompts 老页面)">
|
||||
<OGDialog>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button variant="outline">打开</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogContent className="max-w-md">
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle>弹窗标题</OGDialogTitle>
|
||||
<OGDialogDescription>标题下的说明文字。</OGDialogDescription>
|
||||
</OGDialogHeader>
|
||||
{demoBody}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline">取消</Button>
|
||||
</DialogClose>
|
||||
<Button>确定</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Demo>
|
||||
<Button>确定</Button>
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
</Demo>
|
||||
|
||||
{/* A-set template */}
|
||||
<Demo label="A 套 · DialogTemplate" note="ui/DialogTemplate.tsx">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">打开</Button>
|
||||
</DialogTrigger>
|
||||
<DialogTemplate
|
||||
title="DialogTemplate"
|
||||
description="传 title / main / buttons 的便捷版。"
|
||||
main={sampleBody}
|
||||
buttons={<Button>确定</Button>}
|
||||
/>
|
||||
</Dialog>
|
||||
</Demo>
|
||||
{/* Hand-rolled AlertDialog population */}
|
||||
<Demo
|
||||
label="手拼 AlertDialog(7 处)"
|
||||
note="p-6 · 圆角8 · 无边框阴影 · z-110 · 移动端贴底滑入"
|
||||
>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline">打开</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent className="max-w-md">
|
||||
{/* Business pages hand-roll header/footer like this (ChannelMemberDialog etc.) */}
|
||||
<h3 className="text-base font-medium text-text-primary">弹窗标题</h3>
|
||||
{demoBody}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline">取消</Button>
|
||||
<Button>确定</Button>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Demo>
|
||||
|
||||
{/* B-set raw primitives */}
|
||||
<Demo label="B 套 · OG 原语" note="ui/OriginalDialog.tsx · 纯深色遮罩">
|
||||
<OGDialog>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button variant="outline">打开</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogContent className="w-11/12 max-w-lg bg-background text-foreground">
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle>OG 原语</OGDialogTitle>
|
||||
<OGDialogDescription>OriginalDialog 底层版本。</OGDialogDescription>
|
||||
</OGDialogHeader>
|
||||
<div className="py-2">{sampleBody}</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
</Demo>
|
||||
{/* C-set reference — the finalized confirm shell */}
|
||||
<Demo
|
||||
label="C 套 · useConfirm 参照(已定稿)"
|
||||
note="二次确认基准壳:圆角16 / p-5 / 灰底毛玻璃 —— Modal 壳候选"
|
||||
>
|
||||
<ConfirmReferenceDemo />
|
||||
</Demo>
|
||||
</DemoGrid>
|
||||
</div>
|
||||
|
||||
{/* B-set template — the most used one */}
|
||||
<Demo label="B 套 · OGDialogTemplate" note="用得最多 · 基准候选">
|
||||
<OGDialog>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button>打开</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogTemplate
|
||||
title="OGDialogTemplate"
|
||||
description="全站用得最多的便捷模板,收敛基准候选。"
|
||||
className="max-w-lg"
|
||||
main={sampleBody}
|
||||
buttons={<Button>确定</Button>}
|
||||
/>
|
||||
</OGDialog>
|
||||
</Demo>
|
||||
</DemoGrid>
|
||||
{/* ④ Decision checklist */}
|
||||
<h3 className="mb-3 text-base font-semibold text-text-primary">④ 待设计师定夺</h3>
|
||||
<div className="rounded-xl border border-border-light bg-muted/20 p-5 text-sm leading-7 text-text-primary">
|
||||
<ol className="list-decimal space-y-1 pl-5">
|
||||
<li>
|
||||
<b>遮罩</b>:A 套浅黑毛玻璃(black/40+blur) vs B/C 套灰白毛玻璃(gray-500/90+blur)?
|
||||
(纯深色 black/80 已在二次确认期淘汰)
|
||||
</li>
|
||||
<li>
|
||||
<b>圆角</b>:8px(A 套 / AlertDialog) vs 16px(B/C 套)?移动端是否保留直角/贴底?
|
||||
</li>
|
||||
<li>
|
||||
<b>内边距</b>:p-5(20px,A/B/C) vs p-6(24px,AlertDialog)?header/body/footer 间距(当前统一 gap-4)?
|
||||
</li>
|
||||
<li>
|
||||
<b>标题</b>:font-semibold(A 套) vs font-medium(B/C 套)?
|
||||
</li>
|
||||
<li>
|
||||
<b>关闭按钮 ×</b>:样式与位置(当前 A/B 内置右上小 ×,AlertDialog 各页自拼)?
|
||||
</li>
|
||||
<li>
|
||||
<b>footer 按钮</b>:普通弹窗的取消/确定用 Button 组件(outline+default) 还是 C
|
||||
套确认弹窗那对(白底描边 + danger/primary)?按钮间距 gap-2 vs gap-3?
|
||||
</li>
|
||||
<li>
|
||||
<b>层级</b>:z-50 / z-[100] / z-[110] 三档并存,统一到几?(需盘 Drawer/Sheet/Popover 的层级关系)
|
||||
</li>
|
||||
<li>
|
||||
<b>原语收敛</b>:A 套 22 处直拼是最大人群 —— 是把 A 套壳改成标准(业务零改动),还是逐批迁 B 套?
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import { KnowledgeSpacePreviewDrawer } from "./KnowledgeSpacePreviewDrawer";
|
||||
import { SpaceRole, VisibilityType, getJoinedSpacesApi, getSpaceChildrenApi, getSpaceInfoApi, subscribeSpaceApi } from "~/api/knowledge";
|
||||
|
||||
jest.mock("~/Providers", () => ({
|
||||
useToastContext: () => ({
|
||||
showToast: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("~/hooks", () => ({
|
||||
useLocalize: () => (key: string) => {
|
||||
const dict: Record<string, string> = {
|
||||
"com_knowledge.loading": "加载中",
|
||||
"com_knowledge.join": "加入",
|
||||
"com_knowledge.joined": "已加入",
|
||||
"com_knowledge.space_invalid_or_deleted": "该知识空间已失效或被删除",
|
||||
"com_knowledge.collapse_drawer": "收起",
|
||||
"com_knowledge.close": "关闭",
|
||||
"com_knowledge.articles_count": "篇内容",
|
||||
"com_knowledge.users_count": "用户",
|
||||
"com_knowledge.space_view_requires_join": "加入后可查看详情",
|
||||
"com_knowledge.exit_space_short": "退出空间",
|
||||
"com_knowledge.withdraw_application": "撤回申请",
|
||||
"com_knowledge.reapply": "重新申请",
|
||||
};
|
||||
return dict[key] || key;
|
||||
},
|
||||
usePrefersMobileLayout: () => false,
|
||||
}));
|
||||
|
||||
jest.mock("./SpaceDetail/FileCard", () => ({
|
||||
FileCard: () => <div data-testid="file-card" />,
|
||||
}));
|
||||
|
||||
jest.mock("~/components/ui/Sheet", () => ({
|
||||
Sheet: ({ open, children }: any) => (open ? <div data-testid="sheet">{children}</div> : null),
|
||||
SheetContent: ({ children }: any) => <div>{children}</div>,
|
||||
SheetHeader: ({ children }: any) => <div>{children}</div>,
|
||||
SheetTitle: ({ children }: any) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
jest.mock("~/components/ui/Tooltip2", () => ({
|
||||
Tooltip: ({ children }: any) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: any) => <>{children}</>,
|
||||
TooltipContent: ({ children }: any) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
jest.mock("~/components/ui/Button", () => ({
|
||||
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
|
||||
}));
|
||||
|
||||
jest.mock("~/api/knowledge", () => ({
|
||||
SpaceRole: {
|
||||
CREATOR: "creator",
|
||||
ADMIN: "admin",
|
||||
MEMBER: "member",
|
||||
},
|
||||
VisibilityType: {
|
||||
PUBLIC: "public",
|
||||
PRIVATE: "private",
|
||||
APPROVAL: "approval",
|
||||
},
|
||||
SPACE_CHILDREN_STATUS_SUCCESS_ONLY: [2],
|
||||
getJoinedSpacesApi: jest.fn(),
|
||||
getSpaceChildrenApi: jest.fn(),
|
||||
getSpaceInfoApi: jest.fn(),
|
||||
subscribeSpaceApi: jest.fn(),
|
||||
unsubscribeSpaceApi: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("KnowledgeSpacePreviewDrawer", () => {
|
||||
test("keeps fallback detail visible for unjoined square spaces when info fetch is denied", async () => {
|
||||
const mockedGetSpaceInfoApi = jest.mocked(getSpaceInfoApi);
|
||||
const mockedGetSpaceChildrenApi = jest.mocked(getSpaceChildrenApi);
|
||||
mockedGetSpaceChildrenApi.mockResolvedValue({ data: [], total: 0 });
|
||||
mockedGetSpaceInfoApi.mockRejectedValue(new Error("permission denied"));
|
||||
|
||||
const baseSpace = {
|
||||
id: "space-1",
|
||||
name: "未加入空间",
|
||||
description: "这是广场卡片上的摘要",
|
||||
icon: "",
|
||||
visibility: VisibilityType.PUBLIC,
|
||||
creator: "Zhou",
|
||||
creatorId: "u-1",
|
||||
memberCount: 3,
|
||||
fileCount: 8,
|
||||
totalFileCount: 8,
|
||||
role: SpaceRole.MEMBER,
|
||||
isPinned: false,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
tags: [],
|
||||
isReleased: true,
|
||||
isFollowed: false,
|
||||
isPending: false,
|
||||
};
|
||||
|
||||
function Wrapper() {
|
||||
const [statusMap, setStatusMap] = useState<Record<string, "join" | "joined" | "pending" | "rejected">>({});
|
||||
|
||||
return (
|
||||
<KnowledgeSpacePreviewDrawer
|
||||
spaceId={baseSpace.id}
|
||||
initialSpace={{
|
||||
...baseSpace,
|
||||
squareStatus: statusMap[baseSpace.id],
|
||||
}}
|
||||
open
|
||||
onOpenChange={() => undefined}
|
||||
onSquareStatusChange={(id, status) => {
|
||||
setStatusMap((prev) => ({
|
||||
...prev,
|
||||
[id]: status,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render(<Wrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("未加入空间")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("这是广场卡片上的摘要").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
|
||||
expect(mockedGetSpaceInfoApi).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("loads files for unjoined public square spaces", async () => {
|
||||
const mockedGetSpaceInfoApi = jest.mocked(getSpaceInfoApi);
|
||||
const mockedGetSpaceChildrenApi = jest.mocked(getSpaceChildrenApi);
|
||||
const publicSpace = {
|
||||
id: "space-public",
|
||||
name: "公开空间",
|
||||
description: "公开可浏览",
|
||||
icon: "",
|
||||
visibility: VisibilityType.PUBLIC,
|
||||
creator: "Zhou",
|
||||
creatorId: "u-1",
|
||||
memberCount: 3,
|
||||
fileCount: 1,
|
||||
totalFileCount: 1,
|
||||
role: SpaceRole.MEMBER,
|
||||
isPinned: false,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
tags: [],
|
||||
isReleased: true,
|
||||
isFollowed: false,
|
||||
isPending: false,
|
||||
};
|
||||
|
||||
mockedGetSpaceInfoApi.mockResolvedValue(publicSpace as any);
|
||||
mockedGetSpaceChildrenApi.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "file-1",
|
||||
name: "公开文件.pdf",
|
||||
type: "pdf",
|
||||
tags: [],
|
||||
path: "公开文件.pdf",
|
||||
spaceId: "space-public",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
} as any);
|
||||
|
||||
render(
|
||||
<KnowledgeSpacePreviewDrawer
|
||||
spaceId={publicSpace.id}
|
||||
initialSpace={publicSpace as any}
|
||||
open
|
||||
onOpenChange={() => undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedGetSpaceChildrenApi).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
space_id: "space-public",
|
||||
file_status: [2],
|
||||
})
|
||||
);
|
||||
expect(screen.getByTestId("file-card")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText("加入后可查看详情")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("allows reapplying from rejected preview state", async () => {
|
||||
const mockedGetSpaceInfoApi = jest.mocked(getSpaceInfoApi);
|
||||
const mockedGetJoinedSpacesApi = jest.mocked(getJoinedSpacesApi);
|
||||
const mockedSubscribeSpaceApi = jest.mocked(subscribeSpaceApi);
|
||||
const rejectedSpace = {
|
||||
id: "space-2",
|
||||
name: "审批空间",
|
||||
description: "需要审批",
|
||||
icon: "",
|
||||
visibility: VisibilityType.APPROVAL,
|
||||
creator: "Zhou",
|
||||
creatorId: "u-1",
|
||||
memberCount: 3,
|
||||
fileCount: 8,
|
||||
totalFileCount: 8,
|
||||
role: SpaceRole.MEMBER,
|
||||
isPinned: false,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
tags: [],
|
||||
isReleased: true,
|
||||
isFollowed: false,
|
||||
isPending: false,
|
||||
subscriptionStatus: "rejected",
|
||||
};
|
||||
|
||||
mockedGetSpaceInfoApi.mockResolvedValue(rejectedSpace as any);
|
||||
mockedGetJoinedSpacesApi.mockResolvedValue([]);
|
||||
mockedSubscribeSpaceApi.mockResolvedValue({ status: "pending", spaceId: "space-2" });
|
||||
|
||||
render(
|
||||
<KnowledgeSpacePreviewDrawer
|
||||
spaceId={rejectedSpace.id}
|
||||
initialSpace={rejectedSpace as any}
|
||||
open
|
||||
onOpenChange={() => undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "重新申请" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedSubscribeSpaceApi).toHaveBeenCalledWith("space-2");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { NoPermissionIllustration } from "~/components/illustrations";
|
||||
import { EmptyStateIllustration, NoPermissionIllustration } from "~/components/illustrations";
|
||||
import { ChevronRight, X } from "lucide-react";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "~/components/ui/Sheet";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/Tooltip2";
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "~/api/knowledge";
|
||||
import { checkPermission } from "~/api/permission";
|
||||
import { cn } from "~/utils";
|
||||
import { LoadingIcon } from "~/components/ui/icon/Loading";
|
||||
import { useLocalize, usePrefersMobileLayout, useScrollRevealRef } from "~/hooks";
|
||||
import { useEffectiveQuota } from "~/hooks/useEffectiveQuota";
|
||||
|
||||
@@ -58,6 +59,12 @@ export function KnowledgeSpacePreviewDrawer({
|
||||
const [childrenPage, setChildrenPage] = useState(1);
|
||||
const [childrenTotal, setChildrenTotal] = useState(0);
|
||||
const [loadingChildrenMore, setLoadingChildrenMore] = useState(false);
|
||||
// True while the FIRST page of the file list is in flight, so the empty
|
||||
// state is not flashed before data arrives.
|
||||
const [loadingFiles, setLoadingFiles] = useState(false);
|
||||
// Guards against out-of-order responses when the effect re-fires (space /
|
||||
// folder switch) while a previous first-page request is still pending.
|
||||
const filesRequestSeqRef = useRef(0);
|
||||
// F027: cursor for the next page of `getSpaceChildrenApi`. null on first
|
||||
// page (and after a parent/space switch). Backend `next_cursor` advances
|
||||
// it as the user scrolls.
|
||||
@@ -197,17 +204,21 @@ export function KnowledgeSpacePreviewDrawer({
|
||||
// Load file preview list for spaces that are visible to the current user
|
||||
useEffect(() => {
|
||||
if (!space || !canViewFiles) {
|
||||
filesRequestSeqRef.current += 1;
|
||||
setFilesPreview([]);
|
||||
setChildrenTotal(0);
|
||||
setChildrenPage(1);
|
||||
setLoadingFiles(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset + initial load
|
||||
const requestSeq = ++filesRequestSeqRef.current;
|
||||
setFilesPreview([]);
|
||||
setChildrenPage(1);
|
||||
setChildrenTotal(0);
|
||||
setLoadingChildrenMore(false);
|
||||
setLoadingFiles(true);
|
||||
// F027: reset cursor so the first request after a parent/space switch
|
||||
// fetches page 1 (cursor=null) instead of inheriting a stale token.
|
||||
preview_next_cursor_ref.current = null;
|
||||
@@ -221,6 +232,7 @@ export function KnowledgeSpacePreviewDrawer({
|
||||
...(fileStatusFilter ? { file_status: fileStatusFilter } : {}),
|
||||
})
|
||||
.then(res => {
|
||||
if (requestSeq !== filesRequestSeqRef.current) return;
|
||||
setFilesPreview(res.data);
|
||||
// F027: derive a count surrogate from `has_more` since `total`
|
||||
// is gone (used only to decide "load more" visibility).
|
||||
@@ -228,8 +240,13 @@ export function KnowledgeSpacePreviewDrawer({
|
||||
preview_next_cursor_ref.current = res.next_cursor ?? null;
|
||||
})
|
||||
.catch(() => {
|
||||
if (requestSeq !== filesRequestSeqRef.current) return;
|
||||
setFilesPreview([]);
|
||||
setChildrenTotal(0);
|
||||
})
|
||||
.finally(() => {
|
||||
if (requestSeq !== filesRequestSeqRef.current) return;
|
||||
setLoadingFiles(false);
|
||||
});
|
||||
// Include join/subscription signals so file list loads when async info maps to joined without subscription_status.
|
||||
// canViewApprovalContent is resolved asynchronously (checkPermission) for APPROVAL spaces; without it as a
|
||||
@@ -511,7 +528,7 @@ export function KnowledgeSpacePreviewDrawer({
|
||||
}}
|
||||
>
|
||||
{canViewFiles ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex min-h-full flex-col space-y-2">
|
||||
<div className="mb-1 text-sm text-[#4E5969] flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
@@ -536,9 +553,17 @@ export function KnowledgeSpacePreviewDrawer({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{filesPreview.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-64 text-[#86909c] text-sm">
|
||||
{localize("com_knowledge.no_files")}</div>
|
||||
{loadingFiles ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<LoadingIcon className="size-20 text-primary" />
|
||||
</div>
|
||||
) : filesPreview.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center text-center">
|
||||
<EmptyStateIllustration className="size-[120px] mb-4 opacity-90" />
|
||||
<p className="text-[14px] font-normal text-[#999999]">
|
||||
{localize("com_knowledge.no_files")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3 min-[768px]:grid-cols-3">
|
||||
{filesPreview.map((f) => (
|
||||
@@ -595,7 +620,11 @@ export function KnowledgeSpacePreviewDrawer({
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center px-6 py-4 text-sm text-[#86909c] touch-mobile:px-0">
|
||||
{loadingSpace ? localize("com_knowledge.loading") : localize("com_knowledge.space_invalid_or_deleted")}
|
||||
{loadingSpace ? (
|
||||
<LoadingIcon className="size-20 text-primary" />
|
||||
) : (
|
||||
localize("com_knowledge.space_invalid_or_deleted")
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import KnowledgeSquare from "./KnowledgeSquare";
|
||||
import { getJoinedSpacesApi, getSquareSpacesApi, SpaceRole, subscribeSpaceApi, VisibilityType } from "~/api/knowledge";
|
||||
|
||||
jest.mock("~/Providers", () => ({
|
||||
useToastContext: () => ({
|
||||
showToast: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("~/hooks", () => ({
|
||||
useLocalize: () => (key: string) => {
|
||||
const dict: Record<string, string> = {
|
||||
"com_knowledge.explore_square": "知识广场",
|
||||
"com_knowledge.explore_more_spaces": "探索更多空间",
|
||||
"com_knowledge.search_space_placeholder": "搜索空间",
|
||||
"com_knowledge.no_matched_space": "暂无空间",
|
||||
"com_knowledge.reapply": "重新申请",
|
||||
"com_knowledge.join": "加入",
|
||||
"com_knowledge.pending": "待审批",
|
||||
"com_knowledge.joined": "已加入",
|
||||
"com_knowledge.no_description": "暂无描述",
|
||||
"com_knowledge.users_count": "用户",
|
||||
"com_knowledge.applied_to_join_space": "申请已发送",
|
||||
"com_subscription.articles": "篇内容",
|
||||
};
|
||||
return dict[key] || key;
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("~/api/knowledge", () => ({
|
||||
SpaceRole: {
|
||||
CREATOR: "creator",
|
||||
ADMIN: "admin",
|
||||
MEMBER: "member",
|
||||
},
|
||||
VisibilityType: {
|
||||
PUBLIC: "public",
|
||||
PRIVATE: "private",
|
||||
APPROVAL: "approval",
|
||||
},
|
||||
getJoinedSpacesApi: jest.fn(),
|
||||
getSquareSpacesApi: jest.fn(),
|
||||
subscribeSpaceApi: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("KnowledgeSquare", () => {
|
||||
test("reapplies from rejected card state", async () => {
|
||||
const mockedGetSquareSpacesApi = jest.mocked(getSquareSpacesApi);
|
||||
const mockedGetJoinedSpacesApi = jest.mocked(getJoinedSpacesApi);
|
||||
const mockedSubscribeSpaceApi = jest.mocked(subscribeSpaceApi);
|
||||
const onSquareStatusChange = jest.fn();
|
||||
|
||||
mockedGetSquareSpacesApi.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "space-1",
|
||||
name: "审批空间",
|
||||
description: "需要审批",
|
||||
icon: "",
|
||||
visibility: VisibilityType.APPROVAL,
|
||||
creator: "Zhou",
|
||||
creatorId: "u-1",
|
||||
memberCount: 3,
|
||||
fileCount: 8,
|
||||
totalFileCount: 8,
|
||||
role: SpaceRole.MEMBER,
|
||||
isPinned: false,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
tags: [],
|
||||
isReleased: true,
|
||||
isFollowed: false,
|
||||
isPending: false,
|
||||
squareStatus: "rejected",
|
||||
subscriptionStatus: "rejected",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
} as any);
|
||||
mockedGetJoinedSpacesApi.mockResolvedValue([]);
|
||||
mockedSubscribeSpaceApi.mockResolvedValue({ status: "pending", spaceId: "space-1" });
|
||||
|
||||
render(
|
||||
<KnowledgeSquare
|
||||
statusOverride={{ "space-1": "rejected" }}
|
||||
onSquareStatusChange={onSquareStatusChange}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "重新申请" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedSubscribeSpaceApi).toHaveBeenCalledWith("space-1");
|
||||
expect(onSquareStatusChange).toHaveBeenCalledWith("space-1", "pending");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -219,9 +219,9 @@ export function EditTagsModal({
|
||||
<DialogContent
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
className="flex w-[600px] flex-col items-stretch gap-0 rounded-xl border-none bg-white p-0 shadow-[0px_5px_22px_0px_rgba(61,68,110,0.2)] outline-none touch-mobile:inset-0 touch-mobile:left-0 touch-mobile:top-0 touch-mobile:h-dvh touch-mobile:w-screen touch-mobile:max-w-none touch-mobile:translate-x-0 touch-mobile:translate-y-0 touch-mobile:rounded-none [&>button]:hidden"
|
||||
className="flex w-[600px] max-w-[600px] flex-col items-stretch gap-0 border-none bg-white p-0 shadow-[0px_5px_22px_0px_rgba(61,68,110,0.2)] [outline:none] rounded-none sm:rounded-none md:rounded-xl max-md:inset-0 max-md:left-0 max-md:top-0 max-md:h-dvh max-md:w-screen max-md:max-w-none max-md:translate-x-0 max-md:translate-y-0 [&>button]:hidden"
|
||||
>
|
||||
<DialogHeader className="relative h-12 shrink-0 justify-center space-y-0 px-6 py-3 text-left touch-mobile:h-auto touch-mobile:px-4 touch-mobile:pt-6 touch-mobile:pb-4">
|
||||
<DialogHeader className="relative h-12 shrink-0 justify-center space-y-0 px-5 py-3 text-left max-md:h-auto max-md:px-4 max-md:pt-6 max-md:pb-4">
|
||||
<DialogTitle className="text-[16px] leading-6 font-medium text-[#212121]">
|
||||
{isBatchMode ? localize("com_knowledge.batch_add_tags") : localize("com_knowledge.edit_tags")}
|
||||
</DialogTitle>
|
||||
@@ -235,10 +235,10 @@ export function EditTagsModal({
|
||||
</button>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-3 px-6 py-6 touch-mobile:px-4 touch-mobile:py-4">
|
||||
<div className="flex flex-1 flex-col gap-4 px-5 py-3 max-md:px-4 max-md:py-4">
|
||||
{/* Tags Input Box */}
|
||||
<div
|
||||
className="relative flex min-h-8 cursor-text flex-wrap items-center gap-1 rounded-[8px] border border-[#EBECF0] bg-white px-3 py-[5px] pr-[40px] transition-colors focus-within:border-primary"
|
||||
className="relative flex min-h-8 cursor-text flex-wrap items-center gap-1 rounded-[8px] border border-[#EBECF0] bg-white px-3 py-[5px] pr-[40px] transition-[border-color,box-shadow] focus-within:border-[#ddd] focus-within:shadow-[0_0_0_2px_#f1f5f9]"
|
||||
onClick={() => document.getElementById("tag-input")?.focus()}
|
||||
>
|
||||
{selectedTags.map((tag) => (
|
||||
@@ -261,6 +261,7 @@ export function EditTagsModal({
|
||||
<input
|
||||
id="tag-input"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -269,7 +270,7 @@ export function EditTagsModal({
|
||||
? localize("com_knowledge.input_tags_placeholder")
|
||||
: ""
|
||||
}
|
||||
className="flex-1 min-w-[120px] bg-transparent outline-none text-sm leading-[22px] text-[#212121] placeholder-[#86909c] min-h-[22px]"
|
||||
className="flex-1 min-w-[120px] bg-transparent outline-none text-sm leading-[22px] text-[#212121] placeholder-[#999] min-h-[22px]"
|
||||
// maxLength={8}
|
||||
/>
|
||||
<span className="absolute right-3 top-0 flex h-full items-center text-[14px] leading-[22px] text-[#999]">
|
||||
@@ -284,7 +285,7 @@ export function EditTagsModal({
|
||||
<div className="text-[14px] leading-[22px] font-medium text-[#212121]">{localize("com_knowledge.existing_tags")}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{spaceTags.length === 0 && (
|
||||
<span className="text-[12px] text-[#86909c]">{localize("com_knowledge.no_tags")}</span>
|
||||
<span className="text-[14px] text-[#999]">{localize("com_knowledge.no_tags")}</span>
|
||||
)}
|
||||
{spaceTags.map((tag) => {
|
||||
const isSelected = selectedTagIds.has(tag.id);
|
||||
@@ -318,16 +319,16 @@ export function EditTagsModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex h-14 shrink-0 items-center justify-end gap-3 border-none px-6 py-3 touch-mobile:!mt-auto touch-mobile:!h-auto touch-mobile:!flex-row touch-mobile:!justify-stretch touch-mobile:border-t touch-mobile:border-[#ECECEC] touch-mobile:px-4 touch-mobile:py-3 sm:space-x-0">
|
||||
<DialogFooter className="flex h-14 shrink-0 items-center justify-end gap-3 border-none px-5 py-3 max-md:!mt-auto max-md:!h-auto max-md:!flex-row max-md:!justify-stretch max-md:border-t max-md:border-[#ECECEC] max-md:px-4 max-md:py-3 sm:space-x-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 min-w-[60px] rounded-[6px] border-[#ebecf0] bg-white/50 px-4 font-normal text-[#070038] backdrop-blur-[8px] hover:bg-white/70 touch-mobile:flex-1"
|
||||
className="h-8 min-w-[60px] rounded-[6px] border-[#ebecf0] bg-white/50 px-4 font-normal text-[#070038] backdrop-blur-[8px] hover:bg-white/70 max-md:flex-1"
|
||||
onClick={handleClose}
|
||||
>
|
||||
{localize("com_knowledge.cancel")}</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
className="h-8 min-w-[60px] rounded-[6px] px-4 font-normal touch-mobile:flex-1"
|
||||
className="h-8 min-w-[60px] rounded-[6px] px-4 font-normal max-md:flex-1"
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
>
|
||||
|
||||
-165
@@ -1,165 +0,0 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import { getGrantableRelationModels } from "~/api/permission";
|
||||
import { KnowledgeSpaceShareDialog } from "./KnowledgeSpaceShareDialog";
|
||||
|
||||
jest.mock("~/hooks", () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
jest.mock("~/Providers", () => ({
|
||||
useToastContext: () => ({ showToast: jest.fn() }),
|
||||
}));
|
||||
|
||||
jest.mock("~/utils", () => ({
|
||||
copyText: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("~/api/permission", () => ({
|
||||
getGrantableRelationModels: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("~/components/KnowledgeSpaceMemberManagementPanel", () => ({
|
||||
KnowledgeSpaceMemberManagementPanel: () => <div>member-panel</div>,
|
||||
}));
|
||||
|
||||
jest.mock("~/components/permission/PermissionListTab", () => ({
|
||||
PermissionListTab: ({ resourceType, resourceId, fixedSubjectType }: any) => (
|
||||
<div>{`list:${resourceType}:${resourceId}:${fixedSubjectType}`}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock("~/components/permission/PermissionGrantTab", () => ({
|
||||
PermissionGrantTab: ({ resourceType, resourceId, fixedSubjectType, includeChildren }: any) => (
|
||||
<div>{`grant:${resourceType}:${resourceId}:${fixedSubjectType}:${includeChildren ? "include" : "exclude"}`}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock("~/components/ui", () => ({
|
||||
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
|
||||
Checkbox: ({ checked, onCheckedChange }: any) => (
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked ? "true" : "false"}
|
||||
onClick={() => onCheckedChange?.(!checked)}
|
||||
/>
|
||||
),
|
||||
Dialog: ({ children }: any) => <div>{children}</div>,
|
||||
DialogContent: ({ children }: any) => <div>{children}</div>,
|
||||
DialogHeader: ({ children }: any) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: any) => <div>{children}</div>,
|
||||
Input: (props: any) => <input {...props} />,
|
||||
Tabs: ({ children }: any) => <div>{children}</div>,
|
||||
TabsContent: ({ children }: any) => <div>{children}</div>,
|
||||
TabsList: ({ children }: any) => <div>{children}</div>,
|
||||
TabsTrigger: ({ children }: any) => <button type="button">{children}</button>,
|
||||
}));
|
||||
|
||||
const mockedGetGrantableRelationModels = jest.mocked(getGrantableRelationModels);
|
||||
|
||||
describe("KnowledgeSpaceShareDialog", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockedGetGrantableRelationModels.mockResolvedValue([
|
||||
{
|
||||
id: "viewer",
|
||||
name: "Viewer",
|
||||
relation: "viewer",
|
||||
permissions: [],
|
||||
is_system: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders a single permission list tab instance for the active subject type", async () => {
|
||||
render(
|
||||
<KnowledgeSpaceShareDialog
|
||||
open
|
||||
onOpenChange={jest.fn()}
|
||||
resourceId="space-59"
|
||||
resourceName="Space 59"
|
||||
showShareTab={false}
|
||||
showMembersTab={false}
|
||||
showPermissionTab
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedGetGrantableRelationModels).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(screen.getAllByText("list:knowledge_space:space-59:user")).toHaveLength(1);
|
||||
expect(screen.queryByText("list:knowledge_space:space-59:department")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("list:knowledge_space:space-59:user_group")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("passes the include-children toggle state into the grant form", async () => {
|
||||
render(
|
||||
<KnowledgeSpaceShareDialog
|
||||
open
|
||||
onOpenChange={jest.fn()}
|
||||
resourceId="space-59"
|
||||
resourceName="Space 59"
|
||||
showShareTab={false}
|
||||
showMembersTab={false}
|
||||
showPermissionTab
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedGetGrantableRelationModels).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const grantDepartmentTab = screen.getAllByRole("button", {
|
||||
name: "com_permission.subject_department",
|
||||
}).at(-1);
|
||||
expect(grantDepartmentTab).toBeTruthy();
|
||||
fireEvent.click(grantDepartmentTab!);
|
||||
expect(await screen.findByText("grant:knowledge_space:space-59:department:include")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("checkbox"));
|
||||
expect(await screen.findByText("grant:knowledge_space:space-59:department:exclude")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("can manage a file resource with the same grant dialog", async () => {
|
||||
render(
|
||||
<KnowledgeSpaceShareDialog
|
||||
open
|
||||
onOpenChange={jest.fn()}
|
||||
resourceType="knowledge_file"
|
||||
resourceId="file-9"
|
||||
resourceName="File 9"
|
||||
showShareTab={false}
|
||||
showMembersTab={false}
|
||||
showPermissionTab
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedGetGrantableRelationModels).toHaveBeenCalledWith("knowledge_file", "file-9");
|
||||
});
|
||||
expect(screen.getByText("list:knowledge_file:file-9:user")).toBeInTheDocument();
|
||||
expect(screen.getByText("grant:knowledge_file:file-9:user:include")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("can manage a folder resource with the same grant dialog", async () => {
|
||||
render(
|
||||
<KnowledgeSpaceShareDialog
|
||||
open
|
||||
onOpenChange={jest.fn()}
|
||||
resourceType="folder"
|
||||
resourceId="folder-9"
|
||||
resourceName="Folder 9"
|
||||
showShareTab={false}
|
||||
showMembersTab={false}
|
||||
showPermissionTab
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedGetGrantableRelationModels).toHaveBeenCalledWith("folder", "folder-9");
|
||||
});
|
||||
expect(screen.getByText("list:folder:folder-9:user")).toBeInTheDocument();
|
||||
expect(screen.getByText("grant:folder:folder-9:user:include")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,186 +0,0 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
FileType,
|
||||
SpaceRole,
|
||||
VisibilityType,
|
||||
getDepartmentSpacesApi,
|
||||
getJoinedSpacesApi,
|
||||
getMineSpacesApi,
|
||||
getSpaceChildrenApi,
|
||||
type KnowledgeSpace,
|
||||
} from "~/api/knowledge";
|
||||
import { listUploadableSpacesApi } from "~/api/messageExport";
|
||||
import { MoveToDialog } from "./MoveToDialog";
|
||||
|
||||
jest.mock("bisheng-icons", () => ({
|
||||
Outlined: {
|
||||
City: (props: any) => <span data-testid="city-icon" {...props} />,
|
||||
Down: (props: any) => <span data-testid="down-icon" {...props} />,
|
||||
File: (props: any) => <span data-testid="file-icon" {...props} />,
|
||||
FileImage: (props: any) => <span data-testid="file-image-icon" {...props} />,
|
||||
FolderClose: (props: any) => <span data-testid="folder-icon" {...props} />,
|
||||
Notebook: (props: any) => <span data-testid="notebook-icon" {...props} />,
|
||||
Right: (props: any) => <span data-testid="right-icon" {...props} />,
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("lucide-react", () => ({
|
||||
Loader2: (props: any) => <span data-testid="loader" {...props} />,
|
||||
}));
|
||||
|
||||
jest.mock("~/components/ui/Button", () => ({
|
||||
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
|
||||
}));
|
||||
|
||||
jest.mock("~/components/ui/Dialog", () => ({
|
||||
Dialog: ({ open, children }: any) => (open ? <div>{children}</div> : null),
|
||||
DialogContent: ({ children }: any) => <div>{children}</div>,
|
||||
DialogFooter: ({ children }: any) => <div>{children}</div>,
|
||||
DialogHeader: ({ children }: any) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: any) => <h2>{children}</h2>,
|
||||
}));
|
||||
|
||||
jest.mock("~/components/ui/ExpandableSearchField", () => ({
|
||||
ExpandableSearchField: ({ value, onChange, placeholder }: any) => (
|
||||
<input aria-label={placeholder} value={value} onChange={(event) => onChange(event.target.value)} />
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock("~/hooks", () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
jest.mock("../hooks/useDynamicEllipsis", () => ({
|
||||
useDynamicEllipsis: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../sidebar/DynamicEllipsisName", () => ({
|
||||
DynamicEllipsisName: ({ name, trailing }: any) => (
|
||||
<span>
|
||||
{name}
|
||||
{trailing}
|
||||
</span>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock("./MoveToFolderTree", () => ({
|
||||
MoveToFolderTree: () => <div data-testid="folder-tree" />,
|
||||
}));
|
||||
|
||||
jest.mock("~/api/messageExport", () => ({
|
||||
listUploadableSpacesApi: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("~/api/knowledge", () => ({
|
||||
FileType: {
|
||||
FOLDER: "folder",
|
||||
PDF: "pdf",
|
||||
},
|
||||
SpaceRole: {
|
||||
CREATOR: "creator",
|
||||
ADMIN: "admin",
|
||||
MEMBER: "member",
|
||||
},
|
||||
VisibilityType: {
|
||||
PUBLIC: "public",
|
||||
PRIVATE: "private",
|
||||
APPROVAL: "approval",
|
||||
},
|
||||
SPACE_CHILDREN_STATUS_NUMS_EXCLUDE_FAILED: [2],
|
||||
getDepartmentSpacesApi: jest.fn(),
|
||||
getJoinedSpacesApi: jest.fn(),
|
||||
getMineSpacesApi: jest.fn(),
|
||||
getSpaceChildrenApi: jest.fn(),
|
||||
}));
|
||||
|
||||
function makeSpace(id: string, name: string): KnowledgeSpace {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description: "",
|
||||
icon: "",
|
||||
visibility: VisibilityType.PRIVATE,
|
||||
creator: "tester",
|
||||
creatorId: "user-1",
|
||||
memberCount: 1,
|
||||
fileCount: 0,
|
||||
totalFileCount: 0,
|
||||
role: SpaceRole.MEMBER,
|
||||
isPinned: false,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
tags: [],
|
||||
isReleased: true,
|
||||
};
|
||||
}
|
||||
|
||||
function renderDialog() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MoveToDialog
|
||||
open
|
||||
onOpenChange={() => undefined}
|
||||
currentSpaceId="current-space"
|
||||
currentSpaceName="Current Space"
|
||||
onConfirm={() => undefined}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("MoveToDialog", () => {
|
||||
test("shows children for the first visible uploadable space when current space is not uploadable", async () => {
|
||||
const targetSpace = makeSpace("target-space", "Target Space");
|
||||
jest.mocked(listUploadableSpacesApi).mockResolvedValue([
|
||||
{ id: targetSpace.id, name: targetSpace.name },
|
||||
]);
|
||||
jest.mocked(getDepartmentSpacesApi).mockResolvedValue([]);
|
||||
jest.mocked(getMineSpacesApi).mockResolvedValue([makeSpace("current-space", "Current Space")]);
|
||||
jest.mocked(getJoinedSpacesApi).mockResolvedValue([targetSpace]);
|
||||
jest.mocked(getSpaceChildrenApi).mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "folder-1",
|
||||
name: "Target Folder",
|
||||
type: FileType.FOLDER,
|
||||
tags: [],
|
||||
path: "Target Folder",
|
||||
spaceId: targetSpace.id,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
],
|
||||
page_size: 200,
|
||||
has_more: false,
|
||||
next_cursor: null,
|
||||
} as any);
|
||||
|
||||
renderDialog();
|
||||
|
||||
expect(screen.getByText("com_knowledge.move_empty_folder")).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSpaceChildrenApi).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
space_id: targetSpace.id,
|
||||
file_status: [2],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(getSpaceChildrenApi).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ space_id: "current-space" }),
|
||||
);
|
||||
expect(await screen.findByText("Target Folder")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,131 +0,0 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { NotificationSeverity } from "~/common";
|
||||
import type { KnowledgeSpace } from "~/api/knowledge";
|
||||
import { SpaceRole, SpaceSortType, VisibilityType } from "~/api/knowledge";
|
||||
import { useSpaceActions } from "./useSpaceActions";
|
||||
|
||||
const mockShowToast = jest.fn();
|
||||
const mockUnsubscribeSpaceApi = jest.fn();
|
||||
|
||||
const ORGANIZATION_GRANT_MESSAGE = "本空间通过部门/用户组授权给你,暂无法退出";
|
||||
|
||||
jest.mock("~/hooks", () => ({
|
||||
useLocalize: () => (key: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
"com_knowledge.exit_space_failed": "退出空间失败",
|
||||
"com_knowledge.exited_space": "已退出空间",
|
||||
"com_knowledge.organization_grant_exit_blocked": ORGANIZATION_GRANT_MESSAGE,
|
||||
};
|
||||
return labels[key] ?? key;
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("~/Providers", () => ({
|
||||
useToastContext: () => ({
|
||||
showToast: mockShowToast,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("~/api/knowledge", () => ({
|
||||
SpaceRole: {
|
||||
CREATOR: "creator",
|
||||
ADMIN: "admin",
|
||||
MEMBER: "member",
|
||||
},
|
||||
SpaceSortType: {
|
||||
NAME: "name",
|
||||
UPDATE_TIME: "update_time",
|
||||
},
|
||||
VisibilityType: {
|
||||
PUBLIC: "public",
|
||||
PRIVATE: "private",
|
||||
APPROVAL: "approval",
|
||||
},
|
||||
updateSpaceApi: jest.fn(),
|
||||
deleteSpaceApi: jest.fn(),
|
||||
unsubscribeSpaceApi: (...args: unknown[]) => mockUnsubscribeSpaceApi(...args),
|
||||
pinSpaceApi: jest.fn(),
|
||||
}));
|
||||
|
||||
function createSpace(id = "space-1"): KnowledgeSpace {
|
||||
return {
|
||||
id,
|
||||
name: "知识空间",
|
||||
description: "用于回归测试",
|
||||
icon: "",
|
||||
visibility: VisibilityType.PUBLIC,
|
||||
creator: "owner",
|
||||
creatorId: "1",
|
||||
memberCount: 3,
|
||||
fileCount: 5,
|
||||
totalFileCount: 5,
|
||||
role: SpaceRole.MEMBER,
|
||||
isPinned: false,
|
||||
createdAt: "2026-05-28T00:00:00Z",
|
||||
updatedAt: "2026-05-28T00:00:00Z",
|
||||
tags: [],
|
||||
isReleased: true,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useSpaceActions leave", () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
mockShowToast.mockClear();
|
||||
mockUnsubscribeSpaceApi.mockReset();
|
||||
});
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
it("shows organization grant message and keeps joined state when leave is blocked", async () => {
|
||||
const space = createSpace();
|
||||
const onSpaceSelect = jest.fn();
|
||||
queryClient.setQueryData(["knowledgeSpaces", "joined", SpaceSortType.UPDATE_TIME], [space]);
|
||||
const invalidateQueriesSpy = jest.spyOn(queryClient, "invalidateQueries");
|
||||
mockUnsubscribeSpaceApi.mockResolvedValue({
|
||||
status_code: 18071,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSpaceActions({
|
||||
activeSpaceId: space.id,
|
||||
createdSortBy: SpaceSortType.UPDATE_TIME,
|
||||
joinedSortBy: SpaceSortType.UPDATE_TIME,
|
||||
departmentSortBy: SpaceSortType.UPDATE_TIME,
|
||||
createdSpaces: [],
|
||||
joinedSpaces: [space],
|
||||
departmentSpaces: [],
|
||||
onSpaceSelect,
|
||||
}), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleLeaveSpace(space.id);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith({
|
||||
message: ORGANIZATION_GRANT_MESSAGE,
|
||||
severity: NotificationSeverity.ERROR,
|
||||
});
|
||||
});
|
||||
expect(mockShowToast).not.toHaveBeenCalledWith(expect.objectContaining({
|
||||
message: "已退出空间",
|
||||
}));
|
||||
expect(queryClient.getQueryData(["knowledgeSpaces", "joined", SpaceSortType.UPDATE_TIME])).toEqual([space]);
|
||||
expect(onSpaceSelect).not.toHaveBeenCalledWith(null);
|
||||
expect(mockUnsubscribeSpaceApi).toHaveBeenCalledWith(space.id);
|
||||
expect(invalidateQueriesSpy).not.toHaveBeenCalledWith({
|
||||
queryKey: ["knowledgeSpaces", "joined"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -60,6 +60,19 @@ function collectExpandedIds(nodes: TreeNode[], acc: Set<number>): Set<number> {
|
||||
return acc;
|
||||
}
|
||||
|
||||
/** Find a node anywhere in the tree by id, searching into loaded (but possibly
|
||||
* collapsed) children too — a node's `children` array persists after collapse. */
|
||||
function findNode(nodes: TreeNode[], id: number): TreeNode | undefined {
|
||||
for (const n of nodes) {
|
||||
if (n.id === id) return n;
|
||||
if (Array.isArray(n.children)) {
|
||||
const found = findNode(n.children, id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ─── Single node row ──────────────────────────────────────────────────────────
|
||||
|
||||
interface TreeNodeRowProps {
|
||||
@@ -166,6 +179,11 @@ export function KnowledgeFolderTree({
|
||||
}: KnowledgeFolderTreeProps) {
|
||||
const [roots, setRoots] = useState<TreeNode[]>([]);
|
||||
const [rootLoading, setRootLoading] = useState(false);
|
||||
// True once the root list for the current (knowledgeId, fileStatus) has
|
||||
// finished loading. Gates the deep-link effect below — unlike rootLoading
|
||||
// (whose initial `false` would let that effect run against an empty tree
|
||||
// on mount), this only flips true after real data is in.
|
||||
const [rootsReady, setRootsReady] = useState(false);
|
||||
|
||||
// Mirror the latest tree into a ref so refreshTree can read it without
|
||||
// becoming a new function on every state change.
|
||||
@@ -175,59 +193,31 @@ export function KnowledgeFolderTree({
|
||||
}, [roots]);
|
||||
|
||||
// Load root folders on mount or when knowledgeId / fileStatus changes.
|
||||
// If a folder is currently selected (currentFolderId set), also fetch its
|
||||
// ancestor chain and pre-expand every ancestor so the selected folder is
|
||||
// visible without the user having to re-expand the tree manually after
|
||||
// collapse → expand of the parent space.
|
||||
// Deliberately independent of currentFolderId: selecting a folder (click or
|
||||
// route change) must never rebuild the tree. The deep-link effect below
|
||||
// handles the one case where the selected folder isn't in the tree yet.
|
||||
useEffect(() => {
|
||||
if (!knowledgeId) return;
|
||||
let cancelled = false;
|
||||
setRootLoading(true);
|
||||
setRootsReady(false);
|
||||
(async () => {
|
||||
try {
|
||||
const { items } = await listKnowledgeFolders({
|
||||
space_id: knowledgeId, parent_id: null, file_status: fileStatus,
|
||||
});
|
||||
if (cancelled) return;
|
||||
let tree = mapToTree(items);
|
||||
|
||||
if (currentFolderId) {
|
||||
try {
|
||||
const parentPath = await getFolderParentPathApi(String(knowledgeId), currentFolderId);
|
||||
if (!cancelled && parentPath?.length > 0) {
|
||||
const ancestorIds = new Set(parentPath.map(p => Number(p.id)));
|
||||
// Walk the tree; for each ancestor, fetch its children
|
||||
// and recurse so deeper ancestors also get expanded.
|
||||
const expandChain = async (nodes: TreeNode[]): Promise<TreeNode[]> => {
|
||||
return Promise.all(nodes.map(async (n) => {
|
||||
if (!ancestorIds.has(n.id)) return n;
|
||||
try {
|
||||
const { items: kids } = await listKnowledgeFolders({
|
||||
space_id: knowledgeId, parent_id: n.id, file_status: fileStatus,
|
||||
});
|
||||
const children = await expandChain(mapToTree(kids));
|
||||
return { ...n, expanded: true, loading: false, children };
|
||||
} catch {
|
||||
return { ...n, expanded: true, loading: false, children: [] };
|
||||
}
|
||||
}));
|
||||
};
|
||||
tree = await expandChain(tree);
|
||||
}
|
||||
} catch {
|
||||
// ignore — fall through with collapsed tree
|
||||
}
|
||||
}
|
||||
|
||||
if (!cancelled) setRoots(tree);
|
||||
if (!cancelled) setRoots(mapToTree(items));
|
||||
} catch {
|
||||
if (!cancelled) setRoots([]);
|
||||
} finally {
|
||||
if (!cancelled) setRootLoading(false);
|
||||
if (!cancelled) {
|
||||
setRootLoading(false);
|
||||
setRootsReady(true);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [knowledgeId, fileStatus, currentFolderId]);
|
||||
}, [knowledgeId, fileStatus]);
|
||||
|
||||
/** Immutably update a node anywhere in the tree by id. */
|
||||
const updateNode = useCallback((
|
||||
@@ -244,12 +234,14 @@ export function KnowledgeFolderTree({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleExpand = useCallback((node: TreeNode) => {
|
||||
// Toggle collapse if already expanded
|
||||
if (node.expanded) {
|
||||
setRoots((prev) => updateNode(prev, node.id, (n) => ({ ...n, expanded: false })));
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* Expand a node without ever collapsing it. If its children were loaded
|
||||
* before (even while collapsed) this is a pure state toggle — no request;
|
||||
* only a never-loaded node fetches its own children (one level, same as
|
||||
* the expand arrow). Shared by the arrow and by folder-row clicks.
|
||||
*/
|
||||
const ensureExpanded = useCallback((node: TreeNode) => {
|
||||
if (node.expanded) return;
|
||||
|
||||
// If children already loaded, just toggle open
|
||||
if (Array.isArray(node.children)) {
|
||||
@@ -282,10 +274,84 @@ export function KnowledgeFolderTree({
|
||||
});
|
||||
}, [knowledgeId, fileStatus, updateNode]);
|
||||
|
||||
const handleExpand = useCallback((node: TreeNode) => {
|
||||
// Arrow keeps toggle semantics: collapse if already expanded.
|
||||
if (node.expanded) {
|
||||
setRoots((prev) => updateNode(prev, node.id, (n) => ({ ...n, expanded: false })));
|
||||
return;
|
||||
}
|
||||
ensureExpanded(node);
|
||||
}, [ensureExpanded, updateNode]);
|
||||
|
||||
// Clicking a folder row only selects it (route + highlight) — it never
|
||||
// expands/collapses children; that is exclusively the arrow's job. No tree
|
||||
// reload either: the row is already rendered, so its data is already local,
|
||||
// and the highlight follows the currentFolderId prop on re-render.
|
||||
const handleSelect = useCallback((node: TreeNode) => {
|
||||
onSelectFolder({ id: String(node.id), name: node.name });
|
||||
}, [onSelectFolder]);
|
||||
|
||||
// Deep-link catch-up: when currentFolderId points at a folder that is NOT in
|
||||
// the local tree (direct URL visit, breadcrumb jump into a never-expanded
|
||||
// branch), fetch its ancestor chain and expand just the missing levels.
|
||||
// Folder-row clicks never enter here — a clickable row is already in the tree,
|
||||
// so findNode succeeds and this effect exits with zero requests.
|
||||
const handledDeepLinkRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
// Wait for the root list to be genuinely loaded — rootLoading's initial
|
||||
// `false` on mount would otherwise let this run against an empty tree.
|
||||
if (!knowledgeId || !currentFolderId || !rootsReady) return;
|
||||
if (findNode(rootsRef.current, Number(currentFolderId))) return;
|
||||
// One attempt per folder id — if the chain fetch fails (or the id is
|
||||
// stale/deleted), don't refetch on every roots change.
|
||||
if (handledDeepLinkRef.current === currentFolderId) return;
|
||||
handledDeepLinkRef.current = currentFolderId;
|
||||
|
||||
let cancelled = false;
|
||||
let completed = false;
|
||||
(async () => {
|
||||
try {
|
||||
const parentPath = await getFolderParentPathApi(String(knowledgeId), currentFolderId);
|
||||
if (cancelled || !parentPath?.length) return;
|
||||
const ancestorIds = new Set(parentPath.map((p) => Number(p.id)));
|
||||
// Walk the current tree along the ancestor chain, reusing children
|
||||
// that are already loaded and fetching only the missing levels.
|
||||
const expandChain = async (nodes: TreeNode[]): Promise<TreeNode[]> => {
|
||||
return Promise.all(nodes.map(async (n) => {
|
||||
if (!ancestorIds.has(n.id)) return n;
|
||||
try {
|
||||
let children = n.children;
|
||||
if (!Array.isArray(children)) {
|
||||
const { items } = await listKnowledgeFolders({
|
||||
space_id: knowledgeId, parent_id: n.id, file_status: fileStatus,
|
||||
});
|
||||
children = mapToTree(items);
|
||||
}
|
||||
return { ...n, expanded: true, loading: false, children: await expandChain(children) };
|
||||
} catch {
|
||||
return { ...n, expanded: true, loading: false, children: n.children ?? [] };
|
||||
}
|
||||
}));
|
||||
};
|
||||
const fresh = await expandChain(rootsRef.current);
|
||||
if (!cancelled) setRoots(fresh);
|
||||
} catch {
|
||||
// ignore — leave the tree as is; the user can expand manually.
|
||||
} finally {
|
||||
completed = true;
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
// A run cancelled mid-flight (deps changed, StrictMode double-mount)
|
||||
// didn't actually expand anything — release the once-per-id mark so
|
||||
// the next run for this folder id can try again.
|
||||
if (!completed && handledDeepLinkRef.current === currentFolderId) {
|
||||
handledDeepLinkRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [knowledgeId, fileStatus, currentFolderId, rootsReady]);
|
||||
|
||||
// Re-fetch a freshly-loaded subtree, re-expanding nodes that were open before.
|
||||
const rebuildWithExpansion = useCallback(async (
|
||||
nodes: TreeNode[],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Outlined } from "bisheng-icons";
|
||||
import { useEffect, useState, type MouseEvent } from "react";
|
||||
import { useState, type MouseEvent } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { KnowledgeSpace, SpaceRole, SPACE_CHILDREN_STATUS_NUMS_EXCLUDE_FAILED } from "~/api/knowledge";
|
||||
import {
|
||||
@@ -63,6 +63,9 @@ export default function KnowledgeSpaceItem({
|
||||
}: KnowledgeSpaceItemProps) {
|
||||
const localize = useLocalize();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
// Initial expansion mirrors the mount-time active state (deep link / reload
|
||||
// restores the tree). After mount, clicking the space row only selects it —
|
||||
// expansion is exclusively the chevron's job (same rule as folder rows).
|
||||
const [expanded, setExpanded] = useState(isActive);
|
||||
// Right-click context menu mirrors the "..." action menu, positioned at the cursor.
|
||||
const [contextMenuOpen, setContextMenuOpen] = useState(false);
|
||||
@@ -75,11 +78,6 @@ export default function KnowledgeSpaceItem({
|
||||
const treeEnabled =
|
||||
bsConfig?.knowledge_space?.tree_structured_directory_display ?? true;
|
||||
|
||||
// Auto-expand when this space becomes active
|
||||
useEffect(() => {
|
||||
if (isActive) setExpanded(true);
|
||||
}, [isActive]);
|
||||
|
||||
// Only highlight the space row when this space is active AND no folder
|
||||
// inside it is selected — folders take over the active styling once chosen
|
||||
// so only one row in the tree appears active at a time.
|
||||
|
||||
@@ -177,6 +177,12 @@ export function KnowledgeSpaceSidebar({
|
||||
const departmentSpaceIds = new Set(departmentSpaces.map(s => s.id));
|
||||
const filteredCreatedSpaces = createdSpaces.filter(s => !departmentSpaceIds.has(s.id));
|
||||
const filteredJoinedSpaces = joinedSpaces.filter(s => !departmentSpaceIds.has(s.id));
|
||||
// An empty created/joined section stretches over the remaining list height
|
||||
// and centers its empty-state text (both empty → 50/50 split). Compact
|
||||
// dropdown keeps natural heights — it is a popover sized to content.
|
||||
const createdEmpty = !filteredCreatedSpaces.length;
|
||||
const joinedEmpty = !filteredJoinedSpaces.length;
|
||||
const stretchEmptySections = (createdEmpty || joinedEmpty) && !compactMode;
|
||||
const permissionSpaceIds = useMemo(
|
||||
() => Array.from(new Set([
|
||||
...departmentSpaces.map(s => s.id),
|
||||
@@ -416,7 +422,7 @@ export function KnowledgeSpaceSidebar({
|
||||
scroll container's visible width — that lets sticky-left/top
|
||||
keep them pinned to the viewport edges even while items
|
||||
horizontally overflow. */}
|
||||
<div className="w-max min-w-full">
|
||||
<div className={cn("w-max min-w-full", stretchEmptySections && "flex min-h-full flex-col")}>
|
||||
{compactMode ? (
|
||||
/* File-page title dropdown: same 3-section tree as the PC sidebar,
|
||||
ordered 部门 → 我创建的 → 我加入的. Per-row "..." menus stay hidden
|
||||
@@ -458,7 +464,7 @@ export function KnowledgeSpaceSidebar({
|
||||
<div className="space-y-1 px-3">
|
||||
{filteredCreatedSpaces.map(s => renderCompactItem(s, "created"))}
|
||||
{!filteredCreatedSpaces.length && (
|
||||
<div className="py-6 text-center text-sm text-[#818181]">{localize("com_knowledge.no_data")}</div>
|
||||
<div className="py-6 text-center text-sm text-[#999999]">{localize("com_knowledge.no_data")}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -480,7 +486,7 @@ export function KnowledgeSpaceSidebar({
|
||||
<div className="space-y-1 px-3">
|
||||
{filteredJoinedSpaces.map(s => renderCompactItem(s, "joined"))}
|
||||
{!filteredJoinedSpaces.length && (
|
||||
<div className="py-6 text-center text-sm text-[#818181]">{localize("com_knowledge.no_data")}</div>
|
||||
<div className="py-6 text-center text-sm text-[#999999]">{localize("com_knowledge.no_data")}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -510,7 +516,7 @@ export function KnowledgeSpaceSidebar({
|
||||
)}
|
||||
|
||||
{/* My created */}
|
||||
<div className="pb-4">
|
||||
<div className={cn("pb-4", stretchEmptySections && createdEmpty && !createdCollapsed && "flex min-h-0 flex-1 flex-col")}>
|
||||
<SectionHeader
|
||||
title={localize("com_knowledge.created_by_me")}
|
||||
collapsed={createdCollapsed}
|
||||
@@ -524,15 +530,20 @@ export function KnowledgeSpaceSidebar({
|
||||
mobile={mobilePageMode}
|
||||
/>
|
||||
{!createdCollapsed && (
|
||||
<div className={listRowClassName}>
|
||||
<div className={cn(listRowClassName, stretchEmptySections && createdEmpty && "flex min-h-0 flex-1 flex-col")}>
|
||||
{filteredCreatedSpaces.map(s => renderSpaceItem(s, "created"))}
|
||||
{!filteredCreatedSpaces.length && <div className="py-6 text-center text-sm text-[#818181]">{localize("com_knowledge.no_data")}</div>}
|
||||
{createdEmpty && (
|
||||
<div className={cn(
|
||||
"py-6 text-center text-sm text-[#999999]",
|
||||
stretchEmptySections && "flex flex-1 items-center justify-center",
|
||||
)}>{localize("com_knowledge.no_data")}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Joined */}
|
||||
<div className="pb-4">
|
||||
<div className={cn("pb-4", stretchEmptySections && joinedEmpty && !joinedCollapsed && "flex min-h-0 flex-1 flex-col")}>
|
||||
<SectionHeader
|
||||
title={localize("com_knowledge.joined_by_me")}
|
||||
collapsed={joinedCollapsed}
|
||||
@@ -544,9 +555,14 @@ export function KnowledgeSpaceSidebar({
|
||||
mobile={mobilePageMode}
|
||||
/>
|
||||
{!joinedCollapsed && (
|
||||
<div className={listRowClassName}>
|
||||
<div className={cn(listRowClassName, stretchEmptySections && joinedEmpty && "flex min-h-0 flex-1 flex-col")}>
|
||||
{filteredJoinedSpaces.map(s => renderSpaceItem(s, "joined"))}
|
||||
{!filteredJoinedSpaces.length && <div className="py-6 text-center text-sm text-[#818181]">{localize("com_knowledge.no_data")}</div>}
|
||||
{joinedEmpty && (
|
||||
<div className={cn(
|
||||
"py-6 text-center text-sm text-[#999999]",
|
||||
stretchEmptySections && "flex flex-1 items-center justify-center",
|
||||
)}>{localize("com_knowledge.no_data")}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,8 +7,8 @@ import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import type { AppConversation } from '~/@types/app';
|
||||
|
||||
import { DropdownPopup, OGDialog, Label } from '~/components';
|
||||
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
||||
import { DropdownPopup } from '~/components';
|
||||
import { useConfirm } from '~/Providers';
|
||||
import TodayItemIcon from '~/components/ui/icon/TodayItem';
|
||||
|
||||
type GuestConvoItemProps = {
|
||||
@@ -29,7 +29,7 @@ export function GuestConvoItem({ conv, isActive, onClick, onRename, onDelete }:
|
||||
const [isPopoverActive, setIsPopoverActive] = useState(false);
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [titleInput, setTitleInput] = useState(conv.title);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const confirm = useConfirm();
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const menuId = useId();
|
||||
@@ -83,10 +83,18 @@ export function GuestConvoItem({ conv, isActive, onClick, onRename, onDelete }:
|
||||
[conv.title],
|
||||
);
|
||||
|
||||
const confirmDelete = useCallback(() => {
|
||||
const handleDeleteClick = useCallback(async () => {
|
||||
const ok = await confirm({
|
||||
variant: 'destructive',
|
||||
title: localize('com_ui_delete_conversation'),
|
||||
description: `${localize('com_ui_delete_confirm')} "${conv.title}"`,
|
||||
confirmText: localize('com_ui_delete'),
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
onDelete(conv.id);
|
||||
setShowDeleteDialog(false);
|
||||
}, [conv.id, onDelete]);
|
||||
}, [confirm, localize, conv.title, conv.id, onDelete]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -96,7 +104,7 @@ export function GuestConvoItem({ conv, isActive, onClick, onRename, onDelete }:
|
||||
renaming ? 'bg-[#EEE]' : '',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (renaming || isPopoverActive || showDeleteDialog) return;
|
||||
if (renaming || isPopoverActive) return;
|
||||
onClick();
|
||||
}}
|
||||
>
|
||||
@@ -174,7 +182,7 @@ export function GuestConvoItem({ conv, isActive, onClick, onRename, onDelete }:
|
||||
label: localize('com_ui_delete'),
|
||||
onClick: () => {
|
||||
setIsPopoverActive(false);
|
||||
setShowDeleteDialog(true);
|
||||
handleDeleteClick();
|
||||
},
|
||||
icon: <Trash className="icon-sm mr-2 text-text-primary" />,
|
||||
hideOnClick: false,
|
||||
@@ -184,31 +192,6 @@ export function GuestConvoItem({ conv, isActive, onClick, onRename, onDelete }:
|
||||
]}
|
||||
menuId={menuId}
|
||||
/>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
{showDeleteDialog && (
|
||||
<OGDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog} triggerRef={deleteButtonRef}>
|
||||
<OGDialogTemplate
|
||||
showCloseButton={false}
|
||||
title={localize('com_ui_delete_conversation')}
|
||||
className="max-w-[450px]"
|
||||
main={
|
||||
<div className="flex w-full flex-col items-center gap-2">
|
||||
<div className="grid w-full items-center gap-2">
|
||||
<Label className="text-left text-sm font-medium">
|
||||
{localize('com_ui_delete_confirm')} <strong>{conv.title}</strong>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: confirmDelete,
|
||||
selectClasses: 'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white',
|
||||
selectText: localize('com_ui_delete'),
|
||||
}}
|
||||
/>
|
||||
</OGDialog>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -52,6 +52,8 @@ export type LinsightInfo = {
|
||||
}[];
|
||||
output_result: null | any;
|
||||
score: null | number;
|
||||
// 点赞/点踩 verdict on the task result: 0 none / 1 up / 2 down.
|
||||
liked?: null | number;
|
||||
has_reexecute: boolean;
|
||||
id: string;
|
||||
update_time: string;
|
||||
|
||||
@@ -1207,7 +1207,7 @@
|
||||
"model": "Model",
|
||||
"displayName": "Display Name",
|
||||
"modelDescription": "Description",
|
||||
"modelDescriptionPlaceholder": "Optional, shown in the workspace model picker",
|
||||
"modelDescriptionPlaceholder": "Shown in the model picker",
|
||||
"vision": "Vision",
|
||||
"visionText": "When enabled, the model will answer based on image content (supports PNG, JPEG, WEBP, non-animated GIF formats). Note that only multimodal models support this capability.",
|
||||
"webSearchPrompt": "Web Search Prompt",
|
||||
|
||||
@@ -1190,7 +1190,7 @@
|
||||
"model": "モデル",
|
||||
"displayName": "表示名",
|
||||
"modelDescription": "説明",
|
||||
"modelDescriptionPlaceholder": "任意。ワークスペースのモデル選択に表示されます",
|
||||
"modelDescriptionPlaceholder": "モデル選択に表示されます",
|
||||
"vision": "画像",
|
||||
"visionText": "有効にすると、モデルは画像コンテンツ(PNG、JPEG、WEBP、非アニメーションGIF形式をサポート)を組み合わせて回答します。この機能はマルチモーダルモデルのみでサポートされています。",
|
||||
"webSearchPrompt": "Web 検索プロンプト",
|
||||
|
||||
@@ -1196,7 +1196,7 @@
|
||||
"model": "模型",
|
||||
"displayName": "显示名称",
|
||||
"modelDescription": "描述信息",
|
||||
"modelDescriptionPlaceholder": "选填,将展示在工作台模型选择器中",
|
||||
"modelDescriptionPlaceholder": "将展示在模型选择器中",
|
||||
"vision": "视觉",
|
||||
"visionText": "开启后,模型将结合图像内容(支持PNG、JPEG、WEBP、非动画GIF格式)进行回答,注意仅多模态模型支持此能力。",
|
||||
"webSearchPrompt": "联网搜索提示词",
|
||||
|
||||
@@ -90,7 +90,7 @@ export const ModelManagement = forwardRef<HTMLDivElement[], ModelManagementProps
|
||||
|
||||
return (
|
||||
<div className="mt-2 border p-4 rounded-md bg-background">
|
||||
<div className="grid mb-4 items-center" style={{ gridTemplateColumns: "1.2fr 0.85fr 1fr 72px 116px 36px" }}>
|
||||
<div className="grid mb-4 items-center" style={{ gridTemplateColumns: "1.2fr 0.85fr 1.3fr 72px 116px 36px" }}>
|
||||
<div className="">
|
||||
<Label className="bisheng-label">{t('bench.model')}</Label>
|
||||
</div>
|
||||
@@ -117,7 +117,7 @@ export const ModelManagement = forwardRef<HTMLDivElement[], ModelManagementProps
|
||||
key={model.key}
|
||||
ref={(el) => setItemRef(el, index)}
|
||||
className="grid items-center mb-4"
|
||||
style={{ gridTemplateColumns: "1.2fr 0.85fr 1fr 72px 116px 36px" }}
|
||||
style={{ gridTemplateColumns: "1.2fr 0.85fr 1.3fr 72px 116px 36px" }}
|
||||
>
|
||||
<div className="pr-2" id={model.id}>
|
||||
{assistantLlmOptions.length > 0 ? (
|
||||
|
||||
@@ -211,7 +211,7 @@ export default function WorkbenchModel({ onBack }) {
|
||||
const inheritedFromRoot = !!linsightConfig?.inherited_from_root;
|
||||
const fallbackBlocked = !!linsightConfig?.fallback_blocked;
|
||||
return (
|
||||
<div className="max-w-[640px] mx-auto gap-y-4 flex flex-col mt-16 relative">
|
||||
<div className="max-w-[720px] mx-auto gap-y-4 flex flex-col mt-16 relative">
|
||||
<FallbackBlockedBanner visible={fallbackBlocked} />
|
||||
{inheritedFromRoot && (
|
||||
<div className="-mb-2 text-xs text-muted-foreground flex items-center">
|
||||
|
||||
Reference in New Issue
Block a user