feat(2.5.0): Bearer JWT in HTTP middleware, disabled-user kick, login/captcha UX, app share permission, local-dev tooling

- Align get_captcha use_captcha with async settings; exempt get_captcha from token_version checks

- Platform: 401 redirect to root when session existed; LDAP/captcha login fixes; i18n and org UI

- Client: can_share and permission UI; gateway dev proxy examples

- Docker: mount http_middleware for backend dev; local-dev middleware scripts

Made-with: Cursor
This commit is contained in:
RUCYancy
2026-04-23 20:41:02 +08:00
parent 2666951ccb
commit be8265c8ba
55 changed files with 1543 additions and 400 deletions
+15 -7
View File
@@ -34,6 +34,8 @@ BiSheng (毕昇) v2.5.0-dev — 面向企业的开源 LLM 应用 DevOps 平台
**关键原则**: 本地与 Docker 绝不运行同类服务,避免端口冲突。
**Docker 仅中间件 + 本机 bisheng / Gateway / 双前端**:一键脚本与端口对齐说明见 [`docker/local-dev/README.md`](docker/local-dev/README.md)。
## 开发命令
### 环境准备
@@ -49,12 +51,15 @@ uv sync --frozen --python /path/to/python
### 启动服务
```bash
# 1. Docker 存储服务 (远程服务器)
cd docker && docker compose -p bisheng up -d
# 停止与本地冲突的容器
docker stop bisheng-mysql bisheng-redis bisheng-backend bisheng-backend-worker bisheng-frontend
# 1a. 推荐:仅 Docker 中间件(本机跑 bisheng / 前端 / Gateway 时用)
# Windows: powershell -ExecutionPolicy Bypass -File docker/local-dev/start-middleware.ps1
# Linux/macOS: bash docker/local-dev/start-middleware.sh
# 2. 后端 API (端口 7860)
# 1b. 或:全量 compose 后再手动停掉与本地冲突的容器
cd docker && docker compose -p bisheng up -d
docker stop bisheng-backend bisheng-backend-worker bisheng-frontend
# 2. 后端 API (端口 7860config 须为相对 bisheng 包目录的文件名,例如 export config=config.yaml)
cd src/backend
.venv/bin/uvicorn bisheng.main:app --host 0.0.0.0 --port 7860 --workers 1 --no-access-log
@@ -76,8 +81,11 @@ cd src/backend
cd /opt/bisheng-gateway
mvn clean package -DskipTests
# 启动 (端口 8180,避免与 OpenFGA 8080 冲突)
java -jar target/gateway-0.0.1-SNAPSHOT.jar --spring.profiles.active=dev --server.port=8180
# 启动 (端口 8180,避免与 OpenFGA 8080 冲突;本地连 Docker 中间件用 local 配置)
java -jar target/gateway-0.0.1-SNAPSHOT.jar --spring.profiles.active=local --server.port=8180
# 组织同步到 bisheng:当前 bisheng 已无 /api/v2/group/syncGateway 通过 F014 HMAC 调 bisheng
# POST /api/v1/departments/sync。联调入口见 docker/local-dev/README.md §6(含一键脚本与冒烟命令)。
# 启用商业版功能需设置后端环境变量
export BISHENG_PRO=true # 在启动后端前设置,开启 /api/v1/user/sso 端点
+10 -5
View File
@@ -8,6 +8,16 @@ database_url:
# 普通模式:
redis_url: "redis://redis:6379/1"
# 与 docker-compose 中 openfga 服务对应(容器内网络)
openfga:
enabled: true
api_url: "http://openfga:8080"
# 网关 HMAC 推送验签(与 bisheng-gateway 的 bisheng.gateway-hmac-secret 一致)
sso_sync:
gateway_hmac_secret: "bisheng-local-hmac-20260422"
signature_header: "X-Signature"
# 集群模式或者哨兵模式(只能选其一):
# redis_url:
# mode: "cluster"
@@ -20,11 +30,6 @@ redis_url: "redis://redis:6379/1"
# sentinel_master: "mymaster"
# sentinel_password: encrypt(gAAAAABlp4b4c59FeVGF_OQRVf6NOUIGdxq8246EBD-b0hdK_jVKRs1x4PoAn0A6C5S6IiFKmWn0Nm5eBUWu-7jxcqw6TiVjQA==)
# db: 1
openfga:
# openfga的broken地址
enabled: true
api_url: "http://openfga:8080"
# celery的broken地址
celery_redis_url: "redis://redis:6379/2"
celery_task:
+3 -1
View File
@@ -30,8 +30,10 @@ start_default(){
}
if [ "$start_mode" = "api" ]; then
echo "Running database migrations..."
alembic upgrade head || echo "WARNING: alembic migration failed, continuing startup..."
echo "Starting API server..."
uvicorn bisheng.main:app --host 0.0.0.0 --port 7860 --no-access-log --workers 8
uvicorn bisheng.main:app --host 0.0.0.0 --port 7860 --no-access-log --workers 1
elif [ "$start_mode" = "knowledge" ]; then
echo "Starting Knowledge Celery worker..."
start_knowledge
+14 -5
View File
@@ -71,11 +71,12 @@ services:
backend:
container_name: bisheng-backend
image: dataelement/bisheng-backend:v2.4.0
image: dataelement/bisheng-backend:v2.5-local
ports:
- "7860:7860"
environment:
TZ: Asia/Shanghai
BS_SSO_SYNC__GATEWAY_HMAC_SECRET: "bisheng-local-hmac-20260422"
BS_MILVUS_CONNECTION_ARGS: '{"host":"milvus","port":"19530","user":"","password":"","secure":false}'
BS_MILVUS_IS_PARTITION: 'true'
BS_MILVUS_PARTITION_SUFFIX: '1'
@@ -91,6 +92,11 @@ services:
- ${DOCKER_VOLUME_DIRECTORY:-.}/bisheng/config/config.yaml:/app/bisheng/config.yaml
- ${DOCKER_VOLUME_DIRECTORY:-.}/bisheng/entrypoint.sh:/app/entrypoint.sh
- ${DOCKER_VOLUME_DIRECTORY:-.}/data/bisheng:/app/data
# 开发/热修:挂载本地 endpoints,使 GET /api/v1/all 等兼容路由无需重打镜像
- ../src/backend/bisheng/api/v1/endpoints.py:/app/bisheng/api/v1/endpoints.py:ro
# Bearer + 禁用账号校验在 http_middleware;镜像默认只读 Cookie,不挂则 Platform 永不被踢下线
- ../src/backend/bisheng/utils/http_middleware.py:/app/bisheng/utils/http_middleware.py:ro
- ../src/backend/bisheng/common/middleware/admin_scope.py:/app/bisheng/common/middleware/admin_scope.py:ro
security_opt:
- seccomp:unconfined
command: sh entrypoint.sh api # 启动api服务
@@ -106,14 +112,16 @@ services:
condition: service_healthy
redis:
condition: service_healthy
# openfga 官方镜像无 HEALTHCHECKCompose v5 无法用 service_healthy
openfga:
condition: service_healthy
condition: service_started
backend_worker:
container_name: bisheng-backend-worker
image: dataelement/bisheng-backend:v2.4.0
image: dataelement/bisheng-backend:v2.5-local
environment:
TZ: Asia/Shanghai
BS_SSO_SYNC__GATEWAY_HMAC_SECRET: "bisheng-local-hmac-20260422"
BS_MILVUS_CONNECTION_ARGS: '{"host":"milvus","port":"19530","user":"","password":"","secure":false}'
BS_MILVUS_IS_PARTITION: 'true'
BS_MILVUS_PARTITION_SUFFIX: '1'
@@ -139,11 +147,12 @@ services:
redis:
condition: service_healthy
openfga:
condition: service_healthy
condition: service_started
frontend:
container_name: bisheng-frontend
image: dataelement/bisheng-frontend:v2.4.0
# 注意:此处为 Hub 的 latest,未必与自建镜像 backend:v2.5-local 同版本;界面偏旧请 pull 最新或按源码 build(见 AGENTS / 部署文档)
image: dataelement/bisheng-frontend:latest
ports:
- "3001:3001"
environment:
+51
View File
@@ -0,0 +1,51 @@
CREATE DATABASE IF NOT EXISTS `bisheng_gateway` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
USE `bisheng_gateway`;
CREATE TABLE IF NOT EXISTS `gt_group_resource`
(
`id` int auto_increment primary key,
`group_id` int not null,
`resource_id` varchar(256) not null,
`resource_limit` int default 0 not null,
`resource_type` tinyint not null
);
CREATE TABLE IF NOT EXISTS `gt_sensitive_words`
(
`id` int auto_increment primary key,
`resource_id` varchar(256) not null,
`resource_type` tinyint null,
`auto_words` text null,
`words` text null,
`words_types` varchar(32) null,
`is_check` tinyint default 0 not null,
`create_time` datetime not null,
`update_time` datetime not null,
`logic_delete` tinyint default 0 not null,
`auto_reply` varchar(128) null
);
CREATE TABLE IF NOT EXISTS `gt_user_group`
(
`id` int auto_increment primary key,
`group_name` varchar(256) not null,
`admin_user` varchar(512) null,
`admin_user_id` varchar(512) null,
`group_limit` int default 0 not null,
`create_time` datetime not null,
`update_time` datetime not null,
`logic_delete` tinyint default 0 not null
);
CREATE TABLE IF NOT EXISTS `gt_block_record`
(
`id` int auto_increment primary key,
`chat_id` varchar(64) null,
`user_input` text null,
`system_out` text null,
`block_words` text null,
`create_time` datetime not null,
`update_time` datetime not null,
`resource_id` varchar(64) null
);
@@ -0,0 +1,32 @@
#Requires -Version 5.1
<#
.SYNOPSIS
在 Docker 容器 bisheng-mysql 中对库 bisheng 执行 reset-org-to-superadmin.sql。
.PARAMETER Container
MySQL 容器名,默认 bisheng-mysql。
.PARAMETER Password
root 密码,默认 1234(与本地 docker-compose 一致)。
#>
param(
[string] $Container = "bisheng-mysql",
[string] $Password = "1234"
)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
$sql = Join-Path $PSScriptRoot "reset-org-to-superadmin.sql"
if (-not (Test-Path $sql)) {
Write-Error "Missing SQL file: $sql"
}
Write-Host "Running reset on container '$Container' database bisheng ..." -ForegroundColor Cyan
Get-Content -LiteralPath $sql -Encoding UTF8 | docker exec -i $Container mysql -uroot "-p$Password" bisheng
if ($LASTEXITCODE -ne 0) {
Write-Error "mysql exited with code $LASTEXITCODE"
}
Write-Host "Done. Check kept_user_id / remaining_users in mysql output above." -ForegroundColor Green
@@ -0,0 +1,69 @@
-- =============================================================================
-- bisheng 库:清空「组织同步 + 部门 + 成员」相关数据,仅保留一名超级管理员
--
-- 保留用户规则(与产品约定一致):
-- 1) 优先保留「已绑定 role_id = 1AdminRole)」中 user_id 最小的一名用户;
-- 2) 若无人拥有 role_id=1,则保留 user 表中 user_id 最小的一行(兜底)。
--
-- 会删除 / 清空:
-- org_sync_log / org_sync_config、department*、user_department、
-- 除保留用户外的 user 及依赖外键的子表(channel、linsight_*、usergroup、userrole)、
-- user_link(非保留用户)、user_tenant(全表后仅为保留用户重建一条根租户记录)、invitecode 全表。
--
-- 不会动:tenant、role、知识库 flow 等业务主数据(仅去掉部门挂载关系)。
--
-- 执行前务必备份:
-- docker exec bisheng-mysql mysqldump -uroot -p1234 bisheng > backup-bisheng.sql
--
-- 执行示例(仓库根目录):
-- docker exec -i bisheng-mysql mysql -uroot -p1234 bisheng < docker/local-dev/reset-org-to-superadmin.sql
--
-- OpenFGA / Redis 中的权限元组或 org_sync 锁不会由此脚本清理;若你启用了 FGA,
-- 清理后请在文档环境执行一次全量同步或按运维流程重建元组。
-- =============================================================================
SET NAMES utf8mb4;
SET @keep := (
SELECT COALESCE(
(SELECT MIN(ur.user_id) FROM userrole ur WHERE ur.role_id = 1),
(SELECT MIN(u.user_id) FROM `user` u)
)
);
-- 空库保护:没有用户则只清组织表,不删 user
DELETE FROM org_sync_log;
DELETE FROM org_sync_config;
DELETE FROM department_knowledge_space;
DELETE FROM user_department;
DELETE FROM `department`;
DELETE FROM invitecode;
DELETE FROM user_link WHERE @keep IS NOT NULL AND user_id <> @keep;
DELETE FROM user_tenant WHERE @keep IS NOT NULL;
DELETE FROM channel WHERE @keep IS NOT NULL AND user_id <> @keep;
DELETE FROM linsight_session_version WHERE @keep IS NOT NULL AND user_id <> @keep;
DELETE FROM linsight_sop WHERE @keep IS NOT NULL AND user_id <> @keep;
DELETE FROM linsight_sop_record WHERE @keep IS NOT NULL AND user_id <> @keep;
DELETE FROM usergroup WHERE @keep IS NOT NULL AND user_id <> @keep;
DELETE FROM userrole WHERE @keep IS NOT NULL AND user_id <> @keep;
DELETE FROM `user` WHERE @keep IS NOT NULL AND user_id <> @keep;
INSERT INTO user_tenant (user_id, tenant_id, is_default, status, is_active)
SELECT @keep, 1, 1, 'active', 1
FROM DUAL
WHERE @keep IS NOT NULL;
DELETE FROM userrole WHERE @keep IS NOT NULL AND user_id = @keep AND role_id <> 1;
INSERT INTO userrole (user_id, role_id, tenant_id)
SELECT @keep, 1, 1
FROM DUAL
WHERE @keep IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM userrole ur WHERE ur.user_id = @keep AND ur.role_id = 1);
SELECT @keep AS kept_user_id, (SELECT COUNT(*) FROM `user`) AS remaining_users;
+8
View File
@@ -0,0 +1,8 @@
# 在 bisheng 仓库根目录执行:本机 bisheng API + 开启 SSOBISHENG_PRO
$ErrorActionPreference = "Stop"
$backend = Join-Path (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) "src\backend"
Set-Location $backend
$env:config = "config.yaml"
$env:BISHENG_PRO = "true"
$env:BS_SSO_SYNC__GATEWAY_HMAC_SECRET = "bisheng-local-hmac-20260422"
& ".\.venv\Scripts\python.exe" -m uvicorn bisheng.main:app --host 0.0.0.0 --port 7860 --workers 1 --no-access-log
+65
View File
@@ -0,0 +1,65 @@
# 一键:Docker 中间件 + bisheng API (BISHENG_PRO) + Celery worker + Gateway(各开新窗口)
# 在 bisheng 仓库根目录执行: powershell -ExecutionPolicy Bypass -File docker/local-dev/start-full-stack.ps1
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
$mvnBin = "$env:USERPROFILE\tools\apache-maven-3.9.9\bin"
if (Test-Path $mvnBin) { $env:Path = "$mvnBin;$env:Path" }
$backend = Join-Path $repoRoot "src\backend"
$gateway = Join-Path (Split-Path $repoRoot -Parent) "bisheng-gateway"
if (-not (Test-Path (Join-Path $gateway "pom.xml"))) {
$gateway = Join-Path $repoRoot "..\bisheng-gateway"
}
$javaExe = "java"
try { $javaExe = (Get-Command java -ErrorAction Stop).Source } catch { }
Write-Host "==> Middleware"
& "$PSScriptRoot\start-middleware.ps1"
if (Test-Path (Join-Path $gateway "pom.xml")) {
Write-Host "==> Maven package gateway"
Push-Location $gateway
mvn -q -DskipTests package
Pop-Location
}
$py = Join-Path $backend ".venv\Scripts\python.exe"
if (-not (Test-Path $py)) {
Write-Error "Missing $py — run: cd src/backend ; uv sync"
}
# 子进程内自行设置 $env:…(外层用反引号避免提前展开)
$apiCmd = "& { Set-Location '$backend'; `$env:config='config.yaml'; `$env:BISHENG_PRO='true'; `$env:BS_SSO_SYNC__GATEWAY_HMAC_SECRET='bisheng-local-hmac-20260422'; & '$py' -m uvicorn bisheng.main:app --host 0.0.0.0 --port 7860 --workers 1 --no-access-log }"
Write-Host "==> Start bisheng API (new window)"
Start-Process powershell -ArgumentList "-NoExit", "-Command", $apiCmd -WindowStyle Normal
Start-Sleep -Seconds 10
$celeryCmd = "& { Set-Location '$backend'; `$env:config='config.yaml'; `$env:BISHENG_PRO='true'; `$env:BS_SSO_SYNC__GATEWAY_HMAC_SECRET='bisheng-local-hmac-20260422'; & '$py' -m celery -A bisheng.worker.main worker -l info -c 4 -P threads -Q knowledge_celery,workflow_celery,celery -n dev@%COMPUTERNAME% }"
Write-Host "==> Start Celery worker (new window)"
Start-Process powershell -ArgumentList "-NoExit", "-Command", $celeryCmd -WindowStyle Normal
Start-Sleep -Seconds 3
$jar = Join-Path $gateway "target\gateway-0.0.1-SNAPSHOT.jar"
if (Test-Path $jar) {
$gwCmd = "& { Set-Location '$gateway'; & '$javaExe' -jar '.\target\gateway-0.0.1-SNAPSHOT.jar' --spring.profiles.active=local --server.port=8180 }"
Write-Host "==> Start Gateway (new window)"
Start-Process powershell -ArgumentList "-NoExit", "-Command", $gwCmd -WindowStyle Normal
} else {
Write-Warning "Gateway jar not found: $jar"
}
Start-Sleep -Seconds 15
Write-Host "==> HMAC smoke (via Gateway :8180)"
& $py (Join-Path $repoRoot "scripts\dev\gateway_hmac_org_sync_smoke.py") --base http://127.0.0.1:8180
Write-Host ""
Write-Host "bisheng: http://127.0.0.1:7860/health"
Write-Host "Gateway: http://127.0.0.1:8180/api/oauth2/list"
Write-Host "企业微信部门树 -> bisheng (F014): GET http://127.0.0.1:8180/api/group/test"
Write-Host "自定义 JSON -> bisheng: POST http://127.0.0.1:8180/api/group/sso-departments-raw"
Write-Host "Python 自签: & '$py' '$repoRoot\scripts\dev\gateway_hmac_org_sync_smoke.py' --base http://127.0.0.1:8180"
+18
View File
@@ -0,0 +1,18 @@
# 仅启动 Docker 中间件(不启 bisheng-backend / worker / frontend 容器)
# 在仓库根目录执行: powershell -File docker/local-dev/start-middleware.ps1
$ErrorActionPreference = "Stop"
$root = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$dockerDir = Join-Path $root "docker"
Set-Location $dockerDir
Write-Host "Stopping bisheng app containers if present..."
docker stop bisheng-backend bisheng-backend-worker bisheng-frontend 2>$null
Write-Host "Starting middleware (mysql -> openfga -> redis -> es -> milvus stack)..."
docker compose -f docker-compose.yml -p bisheng up -d mysql openfga-migrate openfga redis elasticsearch etcd minio milvus
Write-Host "Initializing bisheng_gateway schema (idempotent)..."
$sqlPath = Join-Path $PSScriptRoot "init-gateway-db.sql"
Get-Content -Raw $sqlPath | docker exec -i bisheng-mysql mysql -uroot -p1234
Write-Host "Done. MySQL :3306, Redis :6379, OpenFGA :8080, ES :9200, MinIO :9100, Milvus :19530"
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOT/docker"
echo "Stopping bisheng app containers if present..."
docker stop bisheng-backend bisheng-backend-worker bisheng-frontend 2>/dev/null || true
echo "Starting middleware..."
docker compose -f docker-compose.yml -p bisheng up -d mysql openfga-migrate openfga redis elasticsearch etcd minio milvus
echo "Initializing bisheng_gateway schema (idempotent)..."
docker exec -i bisheng-mysql mysql -uroot -p1234 < "$ROOT/docker/local-dev/init-gateway-db.sql"
echo "Done."
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Smoke test: F014 POST /api/v1/departments/sync with HMAC (via Gateway or direct bisheng).
Examples:
python scripts/dev/gateway_hmac_org_sync_smoke.py --base http://127.0.0.1:8180
python scripts/dev/gateway_hmac_org_sync_smoke.py --base http://127.0.0.1:7860
"""
from __future__ import annotations
import argparse
import hashlib
import hmac
import json
import sys
import urllib.error
import urllib.request
def sign(method: str, path: str, raw_body: bytes, secret: str) -> str:
msg = f"{method.upper()}\n{path}\n".encode("utf-8") + (raw_body or b"")
return hmac.new(secret.encode("utf-8"), msg, hashlib.sha256).hexdigest()
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("--base", default="http://127.0.0.1:8180", help="Gateway or bisheng base URL")
p.add_argument(
"--secret",
default="bisheng-local-hmac-20260422",
help="Must match bisheng sso_sync.gateway_hmac_secret",
)
p.add_argument("--path", default="/api/v1/departments/sync")
args = p.parse_args()
base = args.base.rstrip("/")
path = args.path
if not path.startswith("/"):
path = "/" + path
ts = 1_710_000_000
body_obj = {
"upsert": [
{
"external_id": "smoke-dept-1",
"name": "HMAC Smoke Dept",
"parent_external_id": None,
"sort": 0,
"ts": ts,
}
],
"remove": [],
"source_ts": ts,
}
raw = json.dumps(body_obj, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
sig = sign("POST", path, raw, args.secret)
url = base + path
req = urllib.request.Request(
url,
data=raw,
method="POST",
headers={
"Content-Type": "application/json; charset=utf-8",
"X-Signature": sig,
},
)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
out = resp.read().decode("utf-8", errors="replace")
print(resp.status, out[:2000])
return 0 if resp.status == 200 else 1
except urllib.error.HTTPError as e:
err = e.read().decode("utf-8", errors="replace")
print(e.code, err[:2000], file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+10 -3
View File
@@ -27,6 +27,7 @@ from bisheng.database.models.role_access import AccessType
from bisheng.database.models.session import MessageSessionDao
from bisheng.database.models.tag import TagDao
from bisheng.knowledge.domain.models.knowledge import KnowledgeDao
from bisheng.permission.domain.workflow_app_permission import user_may_share_app
from bisheng.llm.domain.services import LLMService
from bisheng.share_link.domain.models.share_link import ShareLink
from bisheng.tool.domain.models.gpts_tools import GptsToolsDao, GptsTools
@@ -117,10 +118,12 @@ class AssistantService(BaseService, AssistantUtils):
tool_list, flow_list, knowledge_list = cls.get_link_info(tool_list, flow_list,
knowledge_list)
assistant.logo = await cls.get_logo_share_link_async(assistant.logo)
can_share = await user_may_share_app(login_user, 'assistant', assistant_id)
return AssistantInfo(**assistant.model_dump(),
tool_list=tool_list,
flow_list=flow_list,
knowledge_list=knowledge_list)
knowledge_list=knowledge_list,
can_share=can_share)
@classmethod
async def get_one_assistant(cls, assistant_id: str) -> Optional[Assistant]:
@@ -181,10 +184,12 @@ class AssistantService(BaseService, AssistantUtils):
if shared_children:
assistant.is_shared = True
can_share = await user_may_share_app(login_user, 'assistant', str(assistant.id))
return AssistantInfo(**assistant.model_dump(),
tool_list=[],
flow_list=[],
knowledge_list=[])
knowledge_list=[],
can_share=can_share)
@classmethod
def create_assistant_hook(cls, request: Request, assistant: Assistant, user_payload: UserPayload) -> bool:
@@ -315,10 +320,12 @@ class AssistantService(BaseService, AssistantUtils):
tool_list, flow_list, knowledge_list = cls.get_link_info(req.tool_list, req.flow_list,
req.knowledge_list)
cls.update_assistant_hook(request, login_user, assistant)
can_share = await user_may_share_app(login_user, 'assistant', str(assistant.id))
return AssistantInfo(**assistant.model_dump(),
tool_list=tool_list,
flow_list=flow_list,
knowledge_list=knowledge_list)
knowledge_list=knowledge_list,
can_share=can_share)
@classmethod
def update_assistant_hook(cls, request: Request, login_user: UserPayload, assistant: Assistant) -> bool:
+5 -1
View File
@@ -3,6 +3,7 @@ import copy
from typing import List, Dict, AsyncGenerator, Union
from fastapi import Request
from fastapi.encoders import jsonable_encoder
from loguru import logger
from bisheng.api.services.audit_log import AuditLogService
@@ -21,6 +22,7 @@ from bisheng.database.models.flow import FlowDao, FlowStatus, Flow, FlowType
from bisheng.database.models.flow_version import FlowVersionDao, FlowVersionRead, FlowVersion
from bisheng.database.models.group_resource import GroupResourceDao, ResourceTypeEnum, GroupResource
from bisheng.database.models.role_access import AccessType
from bisheng.permission.domain.workflow_app_permission import user_may_share_app
from bisheng.database.models.session import MessageSessionDao
from bisheng.database.models.user_group import UserGroupDao
from bisheng.share_link.domain.models.share_link import ShareLink
@@ -187,7 +189,9 @@ class FlowService(BaseService):
flow_info.logo = await cls.get_logo_share_link_async(flow_info.logo)
return resp_200(data=flow_info)
payload = jsonable_encoder(flow_info)
payload['can_share'] = await user_may_share_app(login_user, 'workflow', flow_id)
return resp_200(data=payload)
@classmethod
async def get_compare_tasks(cls, user: UserPayload, req: FlowCompareReq) -> List:
+43 -6
View File
@@ -15,8 +15,11 @@ from bisheng.common.errcode.http_error import NotFoundError, UnAuthorizedError
from bisheng.common.services import telemetry_service
from bisheng.common.services.base import BaseService
from bisheng.core.logger import trace_id_var
from bisheng.database.models.flow import FlowDao, FlowStatus, FlowType, Flow
from bisheng.database.models.flow import UserLinkType
from bisheng.database.models.flow import Flow, FlowDao, FlowStatus, FlowType, UserLinkType
from bisheng.permission.domain.workflow_app_permission import (
batch_user_may_share_app,
object_type_for_flow_type,
)
from bisheng.database.models.flow_version import FlowVersionDao
from bisheng.database.models.group_resource import ResourceTypeEnum
from bisheng.database.models.role_access import AccessType
@@ -78,6 +81,36 @@ class WorkFlowService(BaseService):
one['logo'] = cls.get_logo_share_link(one['logo'])
return data
@classmethod
async def aenrich_apps_can_share(cls, user: UserPayload, data: list[dict], managed: bool = False) -> list[dict]:
"""Set ``can_share`` from ReBAC relation-model ``share_app`` (fail-closed when unknown type)."""
if not data:
return data
if user.is_admin() or managed:
for one in data:
one['can_share'] = True
return data
entries: list[tuple[dict, Optional[str]]] = []
pairs: list[tuple[str, str]] = []
for one in data:
ot = object_type_for_flow_type(int(one.get('flow_type') or 0))
entries.append((one, ot))
if ot:
pairs.append((ot, str(one['id'])))
if not pairs:
for one, ot in entries:
one['can_share'] = False
return data
flags = await batch_user_may_share_app(user, pairs)
fi = 0
for one, ot in entries:
if not ot:
one['can_share'] = False
else:
one['can_share'] = bool(flags[fi])
fi += 1
return data
@classmethod
async def get_all_flows(cls, user: UserPayload, name: str, status: int, tag_id: Optional[int],
flow_type: Optional[int], page: int = 1, page_size: int = 10,
@@ -118,6 +151,7 @@ class WorkFlowService(BaseService):
end_index = start_index + page_size
data = data[start_index:end_index]
data = cls.add_extra_field(user, data, managed)
data = await cls.aenrich_apps_can_share(user, data, managed)
return data, total
@@ -346,9 +380,9 @@ class WorkFlowService(BaseService):
return workflow_event
@classmethod
def get_frequently_used_flows(cls, user: UserPayload, user_link_type: str,
page: int = 1,
page_size: int = 8) -> (list[dict], int):
async def get_frequently_used_flows(cls, user: UserPayload, user_link_type: str,
page: int = 1,
page_size: int = 8) -> (list[dict], int):
"""
Get common skills
"""
@@ -384,6 +418,7 @@ class WorkFlowService(BaseService):
data = data[start_index:end_index]
data = cls.add_extra_field(user, data)
data = await cls.aenrich_apps_can_share(user, data)
return data, total
@@ -398,7 +433,7 @@ class WorkFlowService(BaseService):
return is_new
@classmethod
def get_uncategorized_flows(
async def get_uncategorized_flows(
cls,
user: UserPayload,
page: int = 1,
@@ -436,6 +471,8 @@ class WorkFlowService(BaseService):
for one in data:
one['logo'] = cls.get_logo_share_link(one['logo'])
data = await cls.aenrich_apps_can_share(user, data)
return data, total
@classmethod
+1
View File
@@ -308,6 +308,7 @@ class AssistantInfo(AssistantBase):
tool_list: List[GptsToolsRead] = Field(default_factory=list, description='Tools for assistantsIDVertical')
flow_list: List[FlowRead] = Field(default_factory=list, description='Skills for assistantsIDVertical')
knowledge_list: List[KnowledgeRead] = Field(default_factory=list, description='The knowledge base uponIDVertical')
can_share: bool = Field(default=False, description='Current user may copy app share link (relation-model share_app)')
class FlowVersionCreate(BaseModel):
@@ -42,6 +42,7 @@ from bisheng.core.context.tenant import (
from bisheng.utils.http_middleware import (
_check_is_global_super,
_decode_jwt_subject,
_extract_http_access_token,
)
@@ -78,7 +79,7 @@ class AdminScopeMiddleware(BaseHTTPMiddleware):
# JWT decode, no Redis read, no FGA check.
return await call_next(request)
token = request.cookies.get('access_token_cookie')
token = _extract_http_access_token(request)
if not token:
return await call_next(request)
@@ -0,0 +1,250 @@
"""Fine-grained app (workflow / assistant) permissions aligned with the platform
relation-model template (e.g. ``share_app`` requires inclusion in the grant model).
Used by workstation / chat list payloads and flow detail APIs for UI gating.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Dict, List, Optional, Set, Tuple
from bisheng.database.models.department import DepartmentDao, UserDepartmentDao
logger = logging.getLogger(__name__)
# Mirrors ``RolesAndPermissions.tsx`` RELATION_LEVEL / MODEL_LEVEL for the 应用/工作流 module.
_RELATION_ORDER = {'can_read': 1, 'can_edit': 2, 'can_manage': 3, 'can_delete': 4}
_MODEL_ORDER = {'viewer': 1, 'editor': 2, 'manager': 3, 'owner': 4}
# (permission_id, minimum template relation) — same ids as platform TEMPLATE_SECTIONS.
_APP_PERMISSION_DEFINITIONS: List[Tuple[str, str]] = [
('view_app', 'can_read'),
('use_app', 'can_read'),
('edit_app', 'can_edit'),
('delete_app', 'can_delete'),
('publish_app', 'can_manage'),
('unpublish_app', 'can_manage'),
('share_app', 'can_manage'),
('manage_app_owner', 'can_manage'),
('manage_app_manager', 'can_manage'),
('manage_app_viewer', 'can_manage'),
]
# get_permission_level() returns owner | can_manage | can_edit | can_read
_PERMISSION_LEVEL_TO_FG_RELATION = {
'owner': 'owner',
'can_manage': 'manager',
'can_edit': 'editor',
'can_read': 'viewer',
}
SHARE_APP_PERMISSION_ID = 'share_app'
def default_app_permission_ids_for_relation(relation: str) -> Set[str]:
"""Default permission ids for a built-in FGA relation (viewer/editor/manager/owner)."""
ml = _MODEL_ORDER.get(relation or '', 0)
out: Set[str] = set()
for pid, req in _APP_PERMISSION_DEFINITIONS:
if ml >= _RELATION_ORDER.get(req, 99):
out.add(pid)
return out
def _permission_ids_for_relation(relation: str, model: Optional[dict]) -> Set[str]:
if model is not None:
perms = model.get('permissions') or []
if perms:
return set(perms)
if model.get('is_system'):
return default_app_permission_ids_for_relation(str(model.get('relation') or ''))
return set()
return default_app_permission_ids_for_relation(relation or '')
def _user_matches_binding(binding: dict, tuple_user: str, user_subject_strings: Set[str]) -> bool:
if tuple_user not in user_subject_strings:
return False
st = binding.get('subject_type')
sid = binding.get('subject_id')
expected = (
f'user:{sid}'
if st == 'user'
else f'{st}:{sid}#member'
)
return tuple_user == expected
async def _binding_department_paths(bindings: List[dict]) -> Dict[int, str]:
department_ids = {
int(b['subject_id'])
for b in bindings
if b.get('subject_type') == 'department' and b.get('include_children')
}
if not department_ids:
return {}
departments = await DepartmentDao.aget_by_ids(list(department_ids))
return {d.id: (d.path or '') for d in departments or []}
async def _resolve_binding_for_tuple(
resource_type: str,
resource_id: str,
tuple_user: str,
relation: str,
bindings: List[dict],
binding_department_paths: Dict[int, str],
user_subject_strings: Set[str],
) -> Optional[dict]:
exact_subject_type = 'user'
exact_subject_id = None
if tuple_user.startswith('user_group:'):
exact_subject_type = 'user_group'
exact_subject_id = int(tuple_user.split(':', 1)[1].split('#', 1)[0])
elif tuple_user.startswith('department:'):
exact_subject_type = 'department'
exact_subject_id = int(tuple_user.split(':', 1)[1].split('#', 1)[0])
elif tuple_user.startswith('user:'):
exact_subject_id = int(tuple_user.split(':', 1)[1])
for binding in bindings:
if binding.get('resource_type') != resource_type or str(binding.get('resource_id')) != str(resource_id):
continue
if binding.get('relation') != relation:
continue
if exact_subject_id is not None and not binding.get('include_children'):
if (
binding.get('subject_type') == exact_subject_type
and int(binding.get('subject_id')) == exact_subject_id
and _user_matches_binding(binding, tuple_user, user_subject_strings)
):
return binding
if tuple_user.startswith('department:'):
tuple_department_id = int(tuple_user.split(':', 1)[1].split('#', 1)[0])
tuple_department_rows = await DepartmentDao.aget_by_ids([tuple_department_id])
tuple_department_path = tuple_department_rows[0].path if tuple_department_rows else ''
for binding in bindings:
if binding.get('resource_type') != resource_type or str(binding.get('resource_id')) != str(resource_id):
continue
if binding.get('relation') != relation:
continue
if binding.get('subject_type') != 'department' or not binding.get('include_children'):
continue
binding_path = binding_department_paths.get(int(binding.get('subject_id')))
if binding_path and tuple_department_path and tuple_department_path.startswith(binding_path):
return binding
return None
async def _collect_user_subject_strings(login_user) -> Set[str]:
out = {f'user:{login_user.user_id}'}
group_ids = await login_user.get_user_group_ids(login_user.user_id)
out.update(f'user_group:{gid}#member' for gid in (group_ids or []))
uds = await UserDepartmentDao.aget_user_departments(login_user.user_id)
out.update(f'department:{ud.department_id}#member' for ud in (uds or []))
return out
async def get_effective_app_permission_ids(
login_user,
object_type: str,
object_id: str,
) -> Set[str]:
"""Effective fine-grained permission ids for the current user on one app resource."""
from bisheng.permission.domain.services.permission_service import PermissionService
if login_user.is_admin():
return {pid for pid, _ in _APP_PERMISSION_DEFINITIONS}
user_subject_strings = await _collect_user_subject_strings(login_user)
from bisheng.permission.api.endpoints.resource_permission import (
_get_bindings,
_get_relation_models,
_normalize_model_dict,
)
raw_models = await _get_relation_models()
model_map = {m['id']: _normalize_model_dict(m) for m in raw_models}
bindings = await _get_bindings()
binding_department_paths = await _binding_department_paths(bindings)
effective: Set[str] = set()
fga = PermissionService._get_fga()
if fga is None:
level = await PermissionService.get_permission_level(
user_id=login_user.user_id,
object_type=object_type,
object_id=str(object_id),
login_user=login_user,
)
relation = _PERMISSION_LEVEL_TO_FG_RELATION.get(level or '')
return _permission_ids_for_relation(relation or '', None)
try:
tuples = await fga.read_tuples(object=f'{object_type}:{object_id}')
except Exception as e: # noqa: BLE001
logger.warning('read_tuples failed for %s:%s: %s', object_type, object_id, e)
tuples = []
for tuple_data in tuples or []:
tuple_user = tuple_data.get('user')
relation = tuple_data.get('relation')
if not tuple_user or not relation or tuple_user not in user_subject_strings:
continue
binding = await _resolve_binding_for_tuple(
object_type,
str(object_id),
tuple_user,
relation,
bindings,
binding_department_paths,
user_subject_strings,
)
model = model_map.get(binding.get('model_id')) if binding and binding.get('model_id') else None
effective.update(_permission_ids_for_relation(relation, model))
if effective:
return effective
level = await PermissionService.get_permission_level(
user_id=login_user.user_id,
object_type=object_type,
object_id=str(object_id),
login_user=login_user,
)
relation = _PERMISSION_LEVEL_TO_FG_RELATION.get(level or '')
return _permission_ids_for_relation(relation or '', None)
async def user_may_share_app(login_user, object_type: str, object_id: str) -> bool:
"""True if the user's relation model includes ``share_app`` on this resource."""
perms = await get_effective_app_permission_ids(login_user, object_type, object_id)
return SHARE_APP_PERMISSION_ID in perms
def object_type_for_flow_type(flow_type: int) -> Optional[str]:
from bisheng.database.models.flow import FlowType
if flow_type == FlowType.WORKFLOW.value:
return 'workflow'
if flow_type == FlowType.ASSISTANT.value:
return 'assistant'
return None
async def batch_user_may_share_app(
login_user,
items: List[Tuple[str, str]],
) -> List[bool]:
"""Parallel ``user_may_share_app`` for list enrichment (same login_user)."""
if not items:
return []
if login_user.is_admin():
return [True] * len(items)
results = await asyncio.gather(
*[user_may_share_app(login_user, ot, oid) for ot, oid in items],
)
return list(results)
+38 -2
View File
@@ -238,6 +238,35 @@ async def _department_admin_scoped_user_ids(user_id: int) -> Optional[List[int]]
return list({int(uid) for uid in rows})
def _primary_department_id_map_for_user_ids(user_ids: List[int]) -> Dict[int, Optional[int]]:
"""user_id -> ``department.id``(主部门),供前端组织树挂载。
``user.dept_id`` 字段为历史/业务侧字符串,与 ``department.id`` 不一定一致;
选人组件应按 ``user_department`` 关系挂载到树节点。
"""
ids = [int(x) for x in user_ids if x is not None]
if not ids:
return {}
with get_sync_db_session() as session:
rows = session.exec(
select(UserDepartment).where(col(UserDepartment.user_id).in_(ids))
).all()
by_uid: Dict[int, List[UserDepartment]] = {}
for ud in rows or []:
uid = int(ud.user_id)
by_uid.setdefault(uid, []).append(ud)
out: Dict[int, Optional[int]] = {}
for uid in ids:
lst = by_uid.get(uid) or []
if not lst:
out[uid] = None
continue
prim = [x for x in lst if int(x.is_primary) == 1]
chosen = prim[0] if prim else lst[0]
out[uid] = int(chosen.department_id)
return out
@router.get('/user/list', status_code=201)
async def list_user(*,
name: Optional[str] = None,
@@ -301,11 +330,14 @@ async def list_user(*,
user_ids = list(set(roles_user_ids))
users, total_count = UserDao.filter_users(user_ids, name, page_num, page_size)
uid_list = [int(one.user_id) for one in users if getattr(one, "user_id", None) is not None]
primary_dept_by_user = _primary_department_id_map_for_user_ids(uid_list)
res = []
role_dict = {}
group_dict = {}
for one in users:
one_data = one.model_dump()
one_data["department_id"] = primary_dept_by_user.get(int(one.user_id)) if one.user_id is not None else None
one_data["avatar"] = UserService.get_avatar_share_link_sync(one_data.get("avatar"))
user_roles = get_user_roles(one, role_dict)
user_groups = get_user_groups(one, group_dict)
@@ -714,11 +746,15 @@ async def get_captcha():
redis_client = await get_redis_client()
await redis_client.aset(key, chr_4, expiration=300)
# Add configuration, whether the verification code must be used
# 与 ``UserService.user_login`` 中 ``if await settings.aget_from_db('use_captcha'):`` 同源同判:
# 曾用同步 ``get_from_db`` 时,在部分环境/事件循环下与异步登录读取不一致,导致前端 ``user_capthca=false``
# 不渲染验证码,但登录仍走验证码校验并报「验证码错误」。
use_raw = await settings.aget_from_db('use_captcha')
user_captcha_required = True if use_raw else False
return resp_200({
'captcha_key': key,
'captcha': capthca_b64,
'user_capthca': settings.get_from_db('use_captcha') or False
'user_capthca': user_captcha_required,
})
+36 -1
View File
@@ -19,6 +19,8 @@ TENANT_CHECK_EXEMPT_PATHS = (
'/api/v1/user/sso',
'/api/v1/user/ldap',
'/api/v1/user/public_key',
# 登录页拉验证码;若仍带失效 Bearer,不应走 token_version 否则永远 19103、前端拿不到 user_capthca
'/api/v1/user/get_captcha',
'/api/v1/user/switch-tenant',
'/api/v1/user/tenants',
'/api/v1/env',
@@ -49,6 +51,22 @@ def _decode_jwt_subject(token: str) -> Optional[dict]:
return None
def _extract_http_access_token(request: Request) -> Optional[str]:
"""Resolve JWT: HttpOnly cookie (e.g. server-rendered) or ``Authorization: Bearer`` (platform SPA).
Platform axios stores the token in ``localStorage`` and sends Bearer headers;
skipping Bearer caused ``token_version`` invalidation (account disable) to never
run for logged-in SPA sessions.
"""
token = request.cookies.get('access_token_cookie')
if token:
return token
auth = (request.headers.get('Authorization') or '').strip()
if auth.lower().startswith('bearer '):
return (auth[7:].strip() or None)
return None
def _extract_tenant_id_from_token(token: str) -> int:
"""Decode JWT token and extract tenant_id. Returns DEFAULT_TENANT_ID on failure."""
return _tenant_id_from_subject(_decode_jwt_subject(token))
@@ -210,6 +228,23 @@ async def _apply_token_version_and_visible(
},
)
if user_id:
try:
from bisheng.user.domain.models.user import UserDao
row = await UserDao.aget_user(int(user_id))
if row is not None and int(row.delete or 0) == 1:
return JSONResponse(
status_code=401,
content={
'status_code': 19104,
'status_message': 'account disabled — please contact administrator',
'data': None,
},
)
except Exception as exc: # noqa: BLE001
logger.debug('account-status check failed user_id=%s: %s', user_id, exc)
from bisheng.core.context.tenant import set_visible_tenant_ids
try:
tenant_id = int(subject.get('tenant_id', 0) or 0)
@@ -237,7 +272,7 @@ class CustomMiddleware(BaseHTTPMiddleware):
# Tenant context injection from JWT cookie. Decode the JWT once and
# share it with the F012 token_version + visible_tenant_ids step so
# the same token isn't decoded twice on the hot path.
token = request.cookies.get('access_token_cookie')
token = _extract_http_access_token(request)
decoded_subject = _decode_jwt_subject(token) if token else None
tenant_id = _set_tenant_context(token, decoded_subject=decoded_subject)
@@ -3,6 +3,7 @@ from typing import Optional
from fastapi import APIRouter, Body
from bisheng.api.services.workflow import WorkFlowService
from bisheng.permission.domain.workflow_app_permission import batch_user_may_share_app, object_type_for_flow_type
from bisheng.api.v1.schemas import ChatList, FrequentlyUsedChat, UnifiedResponseModel, UsedAppPin, resp_200
from bisheng.common.errcode.http_error import UnAuthorizedError
from bisheng.common.errcode.workstation import AgentAlreadyExistsError, UsedAppNotFoundError, UsedAppNotOnlineError
@@ -21,7 +22,7 @@ router = APIRouter()
@router.get('/app/recommended')
def get_recommended_apps(login_user=LoginUserDep):
async def get_recommended_apps(login_user=LoginUserDep):
"""Return admin-configured recommended apps.
- Admins (config page): return every configured app so the selection can echo
@@ -48,17 +49,18 @@ def get_recommended_apps(login_user=LoginUserDep):
data.sort(key=lambda x: app_order.get(x['id'], len(app_ids)))
data = WorkFlowService.add_extra_field(login_user, data)
data = await WorkFlowService.aenrich_apps_can_share(login_user, data)
return resp_200(data=data)
@router.get('/app/frequently_used')
def get_frequently_used_chat(
async def get_frequently_used_chat(
login_user=LoginUserDep,
user_link_type: Optional[str] = 'app',
page: Optional[int] = 1,
limit: Optional[int] = 8,
):
data, _ = WorkFlowService.get_frequently_used_flows(login_user, user_link_type, page, limit)
data, _ = await WorkFlowService.get_frequently_used_flows(login_user, user_link_type, page, limit)
return resp_200(data=data)
@@ -81,13 +83,13 @@ def delete_frequently_used_chat(
@router.get('/app/uncategorized')
def get_uncategorized_chat(
async def get_uncategorized_chat(
login_user=LoginUserDep,
page: Optional[int] = 1,
limit: Optional[int] = 8,
keyword: Optional[str] = None,
):
data, _ = WorkFlowService.get_uncategorized_flows(login_user, page, limit, keyword)
data, _ = await WorkFlowService.get_uncategorized_flows(login_user, page, limit, keyword)
return resp_200(data=data)
@@ -133,6 +135,23 @@ async def get_used_apps(login_user=LoginUserDep, page: int = 1, limit: int = 20)
app['tags'] = resource_tag_dict.get(app_id, [])
result.append(app)
for app in result:
app['can_share'] = False
share_pairs = []
share_idx = []
for idx, app in enumerate(result):
ot = object_type_for_flow_type(int(app.get('flow_type') or 0))
if ot:
share_pairs.append((ot, str(app['id'])))
share_idx.append(idx)
if login_user.is_admin():
for app in result:
app['can_share'] = True
elif share_pairs:
flags = await batch_user_may_share_app(login_user, share_pairs)
for j, app_i in enumerate(share_idx):
result[app_i]['can_share'] = bool(flags[j])
total = len(result)
start_index = (page - 1) * limit
end_index = start_index + limit
+8
View File
@@ -0,0 +1,8 @@
**/node_modules
**/build
**/dist
**/.git
**/.cache
**/.turbo
**/*.log
.DS_Store
+2
View File
@@ -10,6 +10,8 @@ export interface AppItem {
is_pinned?: boolean; // Pinned state from backend
last_chat_time?: string; // ISO date of last conversation
last_chat_id?: string; // Last conversation ID (for "continue chat")
/** ReBAC relation-model ``share_app`` — hide share UI when false */
can_share?: boolean;
}
/** Tag/category */
+1
View File
@@ -15,6 +15,7 @@ export interface FlowData {
status: number;
update_time: string; // ISO 8601 format date string
user_id: null | string; // Assuming it can be null or string
can_share?: boolean;
}
//
@@ -1,32 +1,11 @@
import { useLocalize } from "~/hooks";
import type { RelationLevel } from "~/api/permission";
import { cn } from "~/utils";
const LEVEL_STYLES: Record<RelationLevel, string> = {
owner: "bg-purple-100 text-purple-700 border-purple-200",
manager: "bg-blue-100 text-blue-700 border-blue-200",
editor: "bg-green-100 text-green-700 border-green-200",
viewer: "bg-gray-100 text-gray-700 border-gray-200",
};
interface PermissionBadgeProps {
level: RelationLevel | null | undefined;
className?: string;
}
export function PermissionBadge({ level, className }: PermissionBadgeProps) {
const localize = useLocalize();
if (!level) return null;
return (
<span
className={cn(
"inline-flex items-center rounded-md border px-1.5 py-0 text-[11px] font-normal",
LEVEL_STYLES[level],
className
)}
>
{localize(`com_permission.level_${level}`)}
</span>
);
/** 列表等场景不展示权限关系角标;保留组件以兼容调用处。 */
export function PermissionBadge(_props: PermissionBadgeProps) {
return null;
}
@@ -13,7 +13,6 @@ import type {
import { Building2, Loader2, RotateCcw, Trash2, User, Users } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useLocalize } from "~/hooks";
import { PermissionBadge } from "./PermissionBadge";
import { RelationModelOption, RelationSelect } from "./RelationSelect";
const SUBJECT_ICONS = {
@@ -239,7 +238,7 @@ export function PermissionListTab({
</td>
<td className="py-2">
{isOwner ? (
<PermissionBadge level="owner" />
<span className="text-sm text-gray-500">{localize("com_permission.level_owner")}</span>
) : (
<RelationSelect
value={entry.model_id || entry.relation}
@@ -98,6 +98,7 @@ export function SideNav() {
// e.g. right after deleting the last conversation, chatState is cleared but the
// sidebar card should still show the app's name / logo / description.
const flowData = chatState?.flow ?? currentApp;
const showShareApp = flowData?.can_share === true;
return (
<div className="relative w-[280px] h-full bg-white border-r border-[#ececec] flex flex-col gap-4 px-2 py-2 overflow-hidden text-[#212121]">
@@ -170,17 +171,19 @@ export function SideNav() {
</div>
<div className="flex items-center justify-center gap-[4px]">
<button
onClick={shareApp}
type="button"
className="flex-1 min-w-0 h-[28px] flex items-center justify-center gap-1 bg-white border border-[#ececec] rounded-[6px] text-[14px] leading-[22px] hover:bg-gray-50 transition-colors touch-mobile:px-2"
>
{localize('com_app_share_app')}
</button>
{showShareApp ? (
<button
onClick={shareApp}
type="button"
className="flex-1 min-w-0 h-[28px] flex items-center justify-center gap-1 bg-white border border-[#ececec] rounded-[6px] text-[14px] leading-[22px] hover:bg-gray-50 transition-colors touch-mobile:px-2"
>
{localize('com_app_share_app')}
</button>
) : null}
<button
onClick={createNewChat}
type="button"
className="flex-1 min-w-0 h-[28px] flex items-center justify-center gap-1 bg-white border border-[#ececec] rounded-[6px] text-[14px] leading-[22px] hover:bg-gray-50 transition-colors max-[576px]:px-2"
className={`min-w-0 h-[28px] flex items-center justify-center gap-1 bg-white border border-[#ececec] rounded-[6px] text-[14px] leading-[22px] hover:bg-gray-50 transition-colors max-[576px]:px-2 ${showShareApp ? 'flex-1' : 'w-full'}`}
>
{localize('com_knowledge_start_new_chat')}
</button>
@@ -12,6 +12,7 @@ import {
currentAppInfoState,
sidebarVisibleState,
} from '~/pages/appChat/store/appSidebarAtoms';
import { currentChatState } from '~/pages/appChat/store/atoms';
import { generateUUID } from '~/utils';
import { useLocalize } from '~/hooks';
@@ -32,6 +33,7 @@ export function useAppSidebar() {
showToastRef.current = showToast;
const currentApp = useRecoilValue(currentAppInfoState);
const chatState = useRecoilValue(currentChatState);
const setCurrentApp = useSetRecoilState(currentAppInfoState);
const [conversations, setConversations] = useRecoilState(appConversationsState);
const [sidebarVisible, setSidebarVisible] = useRecoilState(sidebarVisibleState);
@@ -119,6 +121,11 @@ export function useAppSidebar() {
/** Share the current app */
const shareApp = useCallback(async () => {
if (!flowId) return;
const fromChat =
chatState?.flow && String(chatState.flow.id) === String(flowId) ? chatState.flow.can_share : undefined;
const fromSidebar =
currentApp && String(currentApp.id) === String(flowId) ? currentApp.can_share : undefined;
if (fromChat !== true && fromSidebar !== true) return;
const url = getAppShareUrl(flowId, flowType || '');
try {
await copyText(url);
@@ -126,7 +133,7 @@ export function useAppSidebar() {
} catch {
showToast?.({ message: '复制失败', severity: NotificationSeverity.ERROR });
}
}, [flowId, flowType, showToast]);
}, [flowId, flowType, showToast, chatState?.flow, currentApp]);
// Guard: auto-select runs only once per flowId (on initial mount).
// This prevents re-triggering when the user creates a new chat or switches conversations.
@@ -149,10 +156,11 @@ export function useAppSidebar() {
setCurrentApp({
id: data.id ?? flowId,
name: data.name ?? '',
description: data.description ?? '',
description: data.description ?? data.desc ?? '',
logo: data.logo ?? '',
flow_type: Number(data.flow_type ?? numericType),
user_id: data.user_id ?? '',
can_share: data.can_share === true,
} as AppItem);
} catch {
// silent — sidebar falls back to placeholder text
@@ -68,28 +68,30 @@ export function AgentCard({
<ChannelPinGrayIcon className="size-[16px] shrink-0" />
)}
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className="inline-flex size-6 items-center justify-center rounded-[6px] border border-[#E5E6EB] bg-white text-[#86909C] hover:bg-[#F7F8FA]"
aria-label={localize('com_ui_more')}
>
<MoreVertical className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[120px]">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onShare(agent);
}}
>
{localize('com_app_share_app')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{agent.can_share === true ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className="inline-flex size-6 items-center justify-center rounded-[6px] border border-[#E5E6EB] bg-white text-[#86909C] hover:bg-[#F7F8FA]"
aria-label={localize('com_ui_more')}
>
<MoreVertical className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[120px]">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onShare(agent);
}}
>
{localize('com_app_share_app')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
) : (
<TooltipProvider delayDuration={200}>
@@ -163,16 +165,18 @@ export function AgentCard({
</div>
) : (
<div className="hidden h-[28px] w-full min-w-0 items-stretch justify-center gap-1 group-hover/card:flex coarse-pointer:flex">
<Button
onClick={(e) => {
e.stopPropagation();
onShare(agent);
}}
variant="outline"
className="flex-1 min-w-0 justify-center items-center h-full max-h-full rounded-[6px] px-2 py-0 text-[14px] font-normal"
>
{localize('com_app_share_app')}
</Button>
{agent.can_share === true ? (
<Button
onClick={(e) => {
e.stopPropagation();
onShare(agent);
}}
variant="outline"
className="flex-1 min-w-0 justify-center items-center h-full max-h-full rounded-[6px] px-2 py-0 text-[14px] font-normal"
>
{localize('com_app_share_app')}
</Button>
) : null}
<Button
onClick={(e) => {
e.stopPropagation();
@@ -49,12 +49,14 @@ const ExploreCard = ({ agent, onClick, onShare }: { agent: any, onClick: (agent:
{/* 按纽区域:平时隐藏,hover时显示 */}
<div className="hidden group-hover:flex flex-[1_0_0] gap-[4px] items-center justify-center min-h-px w-full mt-auto">
<button
onClick={(e) => { e.stopPropagation(); onShare(agent); }}
className="bg-white border border-[#ececec] flex flex-[1_0_0] h-[28px] items-center justify-center px-[10px] rounded-[6px] text-[#212121] text-[14px] font-['PingFang_SC'] hover:bg-gray-50 transition-colors"
>
{localize('com_app_share_app')}
</button>
{agent.can_share === true ? (
<button
onClick={(e) => { e.stopPropagation(); onShare(agent); }}
className="bg-white border border-[#ececec] flex flex-[1_0_0] h-[28px] items-center justify-center px-[10px] rounded-[6px] text-[#212121] text-[14px] font-['PingFang_SC'] hover:bg-gray-50 transition-colors"
>
{localize('com_app_share_app')}
</button>
) : null}
<button
onClick={(e) => { e.stopPropagation(); onClick(agent); }}
className="bg-[#335cff] flex flex-[1_0_0] h-[28px] items-center justify-center px-[10px] rounded-[6px] text-white text-[14px] font-['PingFang_SC'] hover:bg-blue-600 transition-colors"
@@ -164,6 +166,7 @@ export default function ExplorePlaza() {
}
const handleShare = async (agent: any) => {
if (agent.can_share !== true) return;
const shareUrl = getAppShareUrl(agent.id, agent.flow_type || agent.type);
try {
await copyText(shareUrl);
@@ -75,6 +75,7 @@ export function useAppCenter() {
/** Copy share link to clipboard */
const shareApp = useCallback(
async (app: AppItem) => {
if (app.can_share !== true) return;
const url = getAppShareUrl(app.id, app.flow_type);
try {
await copyText(url);
+14 -14
View File
@@ -3,7 +3,7 @@ import * as http from 'node:http';
import path from 'path';
import { visualizer } from "rollup-plugin-visualizer";
import type { Plugin } from 'vite';
import { defineConfig } from 'vite';
import { defineConfig, loadEnv } from 'vite';
import { compression } from 'vite-plugin-compression2';
import { nodePolyfills } from 'vite-plugin-node-polyfills';
import { VitePWA } from 'vite-plugin-pwa';
@@ -15,10 +15,9 @@ const app_env = {
BISHENG_HOST: '/admin'
}
const minioTarget = 'http://192.168.106.116:9000';
const minioPathRE = /^\/(?:workspace\/)?bisheng(?:\/|$)/;
function minioFileProxyPlugin(): Plugin {
function minioFileProxyPlugin(minioTarget: string): Plugin {
return {
name: 'bisheng:minio-file-proxy',
apply: 'serve',
@@ -70,7 +69,12 @@ function minioFileProxyPlugin(): Plugin {
}
// https://vitejs.dev/config/
export default defineConfig(({ command }) => ({
export default defineConfig(({ command, mode }) => {
const env = loadEnv(mode, path.join(__dirname, '..'));
const minioTarget = env.VITE_DEV_MINIO_TARGET || 'http://127.0.0.1:9100';
const apiTarget = env.VITE_DEV_API_TARGET || 'http://127.0.0.1:7860';
return {
base: app_env.BASE_URL || '/',
define: {
__APP_ENV__: JSON.stringify(app_env)
@@ -80,13 +84,8 @@ export default defineConfig(({ command }) => ({
port: 4001,
strictPort: false,
proxy: {
// '^/api/': {
// target: 'http://192.168.106.116:7861',
// // target: 'http://localhost:3080',
// changeOrigin: true,
// },
'^(/workspace)?/bisheng': {
target: 'http://192.168.106.116:9000',
target: minioTarget,
changeOrigin: true,
secure: false,
rewrite: (path) => {
@@ -94,7 +93,7 @@ export default defineConfig(({ command }) => ({
},
},
'/workspace/api': {
target: 'http://localhost:7860',
target: apiTarget,
changeOrigin: true,
secure: false,
ws: true,
@@ -108,7 +107,7 @@ export default defineConfig(({ command }) => ({
},
},
'/workspace/tmp-dir': {
target: 'http://192.168.106.116:9000',
target: minioTarget,
changeOrigin: true,
secure: false,
rewrite: (path) => {
@@ -121,7 +120,7 @@ export default defineConfig(({ command }) => ({
envDir: '../',
envPrefix: ['VITE_', 'SCRIPT_', 'DOMAIN_', 'ALLOW_'],
plugins: [
minioFileProxyPlugin(),
minioFileProxyPlugin(minioTarget),
react(),
nodePolyfills(),
VitePWA({
@@ -379,7 +378,8 @@ export default defineConfig(({ command }) => ({
$fonts: path.resolve(__dirname, 'public/fonts'),
},
},
}));
};
});
interface SourcemapExclude {
excludeNodeModules?: boolean;
@@ -0,0 +1,4 @@
# client 的 vite envDir 为 src/frontend,因此本文件应复制为同目录下:
# src/frontend/.env.development.local
VITE_DEV_API_TARGET=http://127.0.0.1:8180
VITE_DEV_MINIO_TARGET=http://127.0.0.1:9100
@@ -0,0 +1,4 @@
# 复制为 .env.development.local 后生效(Vite 会加载)
# 前端 API 走本机 Gateway,由 Gateway 再代理到 bisheng :7860
VITE_PROXY_TARGET=http://127.0.0.1:8180
VITE_MINIO_PROXY_TARGET=http://127.0.0.1:9100
@@ -909,6 +909,7 @@
"use": "Use",
"useAll": "Use All",
"allAppTypes": "All Application Types",
"allAppStatus": "All statuses",
"assistantConfiguration": "Assistant Configuration",
"assistantPortrait": "Assistant Portrait",
"portraitOptimization": "Assistant portrait optimization",
@@ -2011,7 +2012,7 @@
"department.confirmRemoveMember": "Are you sure you want to remove this member from the department?",
"department.primary": "Primary",
"department.secondary": "Secondary",
"department.memberType": "Relationship",
"department.memberType": "Affiliation",
"department.search": "Search department",
"department.searchMember": "Search member",
"department.synced": "Synced",
@@ -895,6 +895,7 @@
"use": "使用",
"useAll": "すべて使用",
"allAppTypes": "すべてのアプリタイプ",
"allAppStatus": "すべての状態",
"assistantConfiguration": "アシスタント設定",
"assistantPortrait": "アシスタント画像",
"portraitOptimization": "アシスタント画像の最適化",
@@ -1968,7 +1969,7 @@
"department.confirmRemoveMember": "このメンバーを部門から削除してよろしいですか?",
"department.primary": "主部門",
"department.secondary": "兼務部門",
"department.memberType": "関係タイプ",
"department.memberType": "所属関係",
"department.search": "部門検索",
"department.searchMember": "メンバー検索",
"department.synced": "同期済み",
@@ -903,6 +903,7 @@
"use": "使用",
"useAll": "全部使用",
"allAppTypes": "全部应用类型",
"allAppStatus": "全部状态",
"assistantConfiguration": "助手配置",
"assistantPortrait": "助手画像",
"portraitOptimization": "助手画像优化",
@@ -1968,7 +1969,7 @@
"department.confirmRemoveMember": "确认将该成员从部门中移除?",
"department.primary": "主部门",
"department.secondary": "附属部门",
"department.memberType": "关系类型",
"department.memberType": "所属关系",
"department.search": "搜索部门",
"department.searchMember": "搜索成员",
"department.synced": "第三方同步",
@@ -1,35 +1,11 @@
import { Badge } from "@/components/bs-ui/badge"
import { cname } from "@/components/bs-ui/utils"
import { useTranslation } from "react-i18next"
import { RelationLevel } from "./types"
const LEVEL_STYLES: Record<RelationLevel, string> = {
owner: 'bg-purple-100 text-purple-700 border-purple-200',
manager: 'bg-blue-100 text-blue-700 border-blue-200',
editor: 'bg-green-100 text-green-700 border-green-200',
viewer: 'bg-gray-100 text-gray-700 border-gray-200',
}
interface PermissionBadgeProps {
level: RelationLevel | null | undefined
className?: string
}
export function PermissionBadge({ level, className }: PermissionBadgeProps) {
const { t } = useTranslation('permission')
if (!level) return null
return (
<Badge
variant="outline"
className={cname(
'text-[11px] px-1.5 py-0 font-normal',
LEVEL_STYLES[level],
className,
)}
>
{t(`level.${level}`)}
</Badge>
)
/** 产品要求:列表/看板等场景不再展示「所有者 / 可编辑」等关系角标,保留组件以兼容旧调用处。 */
export function PermissionBadge(_props: PermissionBadgeProps) {
return null
}
@@ -13,7 +13,6 @@ import { captureAndAlertRequestErrorHoc } from "@/controllers/request"
import { Building2, Loader2, RotateCcw, Trash2, User, Users } from "lucide-react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { PermissionBadge } from "./PermissionBadge"
import { RelationModelOption, RelationSelect } from "./RelationSelect"
import { PermissionEntry, RelationLevel, ResourceType } from "./types"
@@ -261,7 +260,7 @@ export function PermissionListTab({ resourceType, resourceId, refreshKey }: Perm
</TableCell>
<TableCell>
{isOwner ? (
<PermissionBadge level="owner" />
<span className="text-sm text-muted-foreground">{t('level.owner')}</span>
) : (
<RelationSelect
value={entry.model_id || entry.relation}
@@ -0,0 +1,373 @@
import { Button } from "@/components/bs-ui/button"
import { Checkbox } from "@/components/bs-ui/checkBox"
import { SearchInput } from "@/components/bs-ui/input"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/bs-ui/popover"
import { getDepartmentMembersApi, getDepartmentTreeApi } from "@/controllers/API/department"
import { getUsersApi } from "@/controllers/API/user"
import { captureAndAlertRequestErrorHoc } from "@/controllers/request"
import type { DepartmentTreeNode } from "@/types/api/department"
import { Building2, ChevronDown, ChevronRight, User as UserIcon } from "lucide-react"
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"
import { useTranslation } from "react-i18next"
export type DepartmentUserOption = { label: string; value: number }
interface DepartmentUsersSelectProps {
value: DepartmentUserOption[]
onChange: (v: DepartmentUserOption[]) => void
multiple?: boolean
disabled?: boolean
lockedValues?: number[]
placeholder?: string
searchPlaceholder?: string
className?: string
}
type UserListItem = {
user_id: number
user_name: string
dept_id?: number | string | null
department_id?: number | null
}
function resolveTreeDepartmentId(
u: UserListItem,
deptBusinessKeyToId: Map<string, number>,
): number | null {
const primary = u.department_id
if (primary != null && Number.isFinite(Number(primary))) return Math.trunc(Number(primary))
const raw = u.dept_id
if (raw == null || raw === "") return null
if (typeof raw === "number" && Number.isFinite(raw)) return Math.trunc(raw)
const s = String(raw).trim()
const asNum = Number(s)
if (Number.isFinite(asNum) && String(asNum) === s) return Math.trunc(asNum)
const hit = deptBusinessKeyToId.get(s)
return hit != null ? hit : null
}
const TREE_INDENT_PER_LEVEL = 22
export default function DepartmentUsersSelect({
value,
onChange,
multiple = true,
disabled = false,
lockedValues = [],
placeholder,
searchPlaceholder,
className = "",
}: DepartmentUsersSelectProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [tree, setTree] = useState<DepartmentTreeNode[]>([])
const [keyword, setKeyword] = useState("")
const [expanded, setExpanded] = useState<Set<number>>(new Set())
const [loadingTree, setLoadingTree] = useState(false)
const [searchingUsers, setSearchingUsers] = useState(false)
const [searchedUsers, setSearchedUsers] = useState<UserListItem[]>([])
const [deptUsersMap, setDeptUsersMap] = useState<Record<number, DepartmentUserOption[]>>({})
const [loadingDeptIds, setLoadingDeptIds] = useState<Set<number>>(new Set())
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const searchAbortRef = useRef<AbortController | null>(null)
const selectedMap = useMemo(() => {
const map = new Map<number, DepartmentUserOption>()
for (const v of value || []) map.set(Number(v.value), { ...v, value: Number(v.value) })
return map
}, [value])
const lockedSet = useMemo(() => new Set(lockedValues.map((x) => Number(x))), [lockedValues])
const loadTree = useCallback(async () => {
setLoadingTree(true)
try {
const res = await captureAndAlertRequestErrorHoc(getDepartmentTreeApi())
if (Array.isArray(res)) {
setTree(res.filter((n) => n.status !== "archived"))
const rootIds = new Set<number>()
for (const n of res) rootIds.add(n.id)
setExpanded(rootIds)
}
} finally {
setLoadingTree(false)
}
}, [])
const loadDeptUsers = useCallback(async (node: DepartmentTreeNode) => {
const did = Number(node.id)
if (!did) return
if (loadingDeptIds.has(did)) return
if (deptUsersMap[did]) return
setLoadingDeptIds((prev) => new Set([...prev, did]))
try {
const res = await getDepartmentMembersApi(node.dept_id, {
page: 1,
limit: 200,
keyword: "",
})
const users = (res?.data || []).map((u) => ({
value: Number(u.user_id),
label: u.user_name,
}))
setDeptUsersMap((prev) => ({ ...prev, [did]: users }))
} finally {
setLoadingDeptIds((prev) => {
const next = new Set(prev)
next.delete(did)
return next
})
}
}, [deptUsersMap, loadingDeptIds])
useEffect(() => {
if (!open) return
if (tree.length === 0) void loadTree()
}, [open, tree.length, loadTree])
useEffect(() => {
return () => {
if (searchTimerRef.current) clearTimeout(searchTimerRef.current)
searchAbortRef.current?.abort()
}
}, [])
const runUserSearch = useCallback(async (q: string) => {
searchAbortRef.current?.abort()
const ac = new AbortController()
searchAbortRef.current = ac
setSearchingUsers(true)
try {
const res = await getUsersApi(
{ name: q, page: 1, pageSize: 200 },
{ signal: ac.signal }
)
if (!ac.signal.aborted) setSearchedUsers((res?.data || []) as UserListItem[])
} catch {
// ignore abort/network
} finally {
if (!ac.signal.aborted) setSearchingUsers(false)
}
}, [])
const handleKeywordChange = (next: string) => {
setKeyword(next)
if (searchTimerRef.current) clearTimeout(searchTimerRef.current)
if (!next.trim()) {
setSearchedUsers([])
setSearchingUsers(false)
return
}
searchTimerRef.current = setTimeout(() => {
void runUserSearch(next.trim())
}, 300)
}
const toggleExpand = (id: number) => {
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const setPicked = (user: DepartmentUserOption) => {
const id = Number(user.value)
if (lockedSet.has(id)) return
if (multiple) {
if (selectedMap.has(id)) {
onChange((value || []).filter((x) => Number(x.value) !== id))
} else {
onChange([...(value || []), { value: id, label: user.label }])
}
return
}
onChange([{ value: id, label: user.label }])
setOpen(false)
}
const displayText = useMemo(() => {
if (!value?.length) return placeholder || t("system.selectUser")
if (value.length === 1) return value[0].label
return `${t("system.selectUser")} (${value.length})`
}, [placeholder, t, value])
const keywordTrim = keyword.trim()
const deptBusinessKeyToId = useMemo(() => {
const m = new Map<string, number>()
const walk = (nodes: DepartmentTreeNode[]) => {
for (const n of nodes) {
m.set(String(n.dept_id), n.id)
if (n.children?.length) walk(n.children)
}
}
walk(tree)
return m
}, [tree])
const searchedByDept = useMemo(() => {
const map = new Map<number, DepartmentUserOption[]>()
if (!keywordTrim) return map
for (const u of searchedUsers || []) {
const did = resolveTreeDepartmentId(u, deptBusinessKeyToId)
if (did == null) continue
const row = { value: Number(u.user_id), label: u.user_name }
const arr = map.get(did) || []
if (!arr.some((x) => x.value === row.value)) arr.push(row)
map.set(did, arr)
}
return map
}, [deptBusinessKeyToId, keywordTrim, searchedUsers])
/** 有搜索词时:只按「用户名」命中(getUsersApi 的 name + 返回行的 department_id / dept_id 挂树),不按部门名过滤 */
const nodeMatches = useCallback((n: DepartmentTreeNode): boolean => {
if (!keywordTrim) return true
const direct = (searchedByDept.get(n.id) || []).length > 0
if (direct) return true
return (n.children || []).some(nodeMatches)
}, [keywordTrim, searchedByDept])
useEffect(() => {
if (!keywordTrim) return
const ids = new Set<number>()
const walk = (nodes: DepartmentTreeNode[]) => {
for (const n of nodes) {
if (nodeMatches(n)) {
ids.add(n.id)
if (n.children?.length) walk(n.children)
}
}
}
walk(tree)
setExpanded(ids)
}, [keywordTrim, nodeMatches, tree])
const renderNode = (node: DepartmentTreeNode, depth: number): ReactNode => {
if (node.status === "archived") return null
if (!nodeMatches(node)) return null
const did = Number(node.id)
const hasChildren = Boolean(node.children?.length)
const isExpanded = expanded.has(did)
const shouldShowRows = !hasChildren || isExpanded
const users = keywordTrim ? (searchedByDept.get(did) || []) : (deptUsersMap[did] || [])
if (!keywordTrim && shouldShowRows && !deptUsersMap[did] && !loadingDeptIds.has(did)) {
void loadDeptUsers(node)
}
return (
<div key={did}>
<div
className="group flex items-center rounded-md py-1.5 pl-1.5 pr-2 text-sm hover:bg-accent"
>
<div className="relative shrink-0 self-stretch" style={{ width: depth * TREE_INDENT_PER_LEVEL }} aria-hidden>
{depth > 0 && (
<span className="pointer-events-none absolute bottom-1 right-0 top-1 w-px bg-border" aria-hidden />
)}
</div>
{hasChildren ? (
<button
className="mr-1 flex h-4 w-4 shrink-0 items-center justify-center rounded p-0.5 hover:bg-muted"
onClick={(e) => {
e.stopPropagation()
toggleExpand(did)
}}
>
{isExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</button>
) : (
<span className="mr-1 block h-4 w-4 shrink-0" />
)}
<Building2 className="mr-1.5 h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate font-medium">{node.name}</span>
</div>
{shouldShowRows && (
<>
{(loadingDeptIds.has(did) && !keywordTrim) && (
<div
className="flex items-center py-1 pl-1.5 pr-2 text-xs text-muted-foreground"
>
<div className="relative shrink-0 self-stretch" style={{ width: (depth + 1) * TREE_INDENT_PER_LEVEL }} aria-hidden>
<span className="pointer-events-none absolute bottom-1 right-0 top-1 w-px bg-border" aria-hidden />
</div>
{t("loading", { ns: "bs" })}
</div>
)}
{users.map((u) => {
const selected = selectedMap.has(Number(u.value))
const locked = lockedSet.has(Number(u.value))
return (
<div
key={`${did}-${u.value}`}
className={`flex items-center rounded-md py-1.5 pl-1.5 pr-2 text-sm ${locked ? "opacity-60" : "cursor-pointer hover:bg-accent"}`}
onClick={() => setPicked(u)}
>
<div className="relative shrink-0 self-stretch" style={{ width: (depth + 1) * TREE_INDENT_PER_LEVEL }} aria-hidden>
<span className="pointer-events-none absolute bottom-1 right-0 top-1 w-px bg-border" aria-hidden />
</div>
<Checkbox checked={selected} disabled={locked} onCheckedChange={() => setPicked(u)} />
<UserIcon className="h-3.5 w-3.5 text-muted-foreground" />
<span className="truncate">{u.label}</span>
</div>
)
})}
{hasChildren && node.children!.map((c) => renderNode(c, depth + 1))}
</>
)}
</div>
)
}
return (
<Popover open={open} onOpenChange={setOpen} modal={false}>
<div className={`w-full ${className}`.trim()}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
disabled={disabled}
className="h-auto min-h-9 w-full justify-between px-3 py-1.5 font-normal"
>
<span className="min-w-0 flex-1 truncate text-left">{displayText}</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-60" />
</Button>
</PopoverTrigger>
</div>
<PopoverContent
align="start"
sideOffset={4}
className="w-[min(100vw-2rem,520px)] max-w-[min(100vw-2rem,520px)] p-2"
>
<div className="flex max-h-[520px] flex-col gap-2">
<SearchInput
placeholder={searchPlaceholder || t("system.searchUser")}
className="mb-1"
value={keyword}
onChange={(e) => handleKeywordChange(e.target.value)}
/>
<div className="min-h-[140px] max-h-[420px] overflow-y-auto rounded-md border">
{loadingTree ? (
<div className="py-4 text-center text-sm text-muted-foreground">{t("loading", { ns: "bs" })}</div>
) : tree.length === 0 ? (
<div className="py-4 text-center text-sm text-muted-foreground">{t("system.treeDepartmentSelectEmpty")}</div>
) : (
<>
{searchingUsers && keywordTrim ? (
<div className="px-2 py-1 text-xs text-muted-foreground">{t("loading", { ns: "bs" })}</div>
) : null}
{tree.map((n) => renderNode(n, 0))}
</>
)}
</div>
</div>
</PopoverContent>
</Popover>
)
}
@@ -1,48 +1,17 @@
import MultiSelect from "@/components/bs-ui/select/multi";
import { getUsersApi } from "@/controllers/API/user";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import DepartmentUsersSelect, { DepartmentUserOption } from "./DepartmentUsersSelect";
export default function UsersSelect({ multiple = false, lockedValues = [], value, disabled = false, onChange, children }:
{ multiple?: boolean, lockedValues?: any[], value: any, disabled?: boolean, onChange: (a: any) => any, children?: (fun: any) => React.ReactNode }) {
export default function UsersSelect({ multiple = false, lockedValues = [], value, disabled = false, onChange }:
{ multiple?: boolean, lockedValues?: any[], value: any, disabled?: boolean, onChange: (a: any) => any }) {
const mappedValue: DepartmentUserOption[] = (value || []).map((v: any) => ({
label: String(v?.label ?? ''),
value: Number(v?.value),
})).filter((x) => x.label && Number.isFinite(x.value))
const { t } = useTranslation()
const [options, setOptions] = useState<any>([]);
const originOptionsRef = useRef([])
const pageRef = useRef(1)
const reload = (page, name) => {
getUsersApi({ page, pageSize: 40, name }).then(res => {
pageRef.current = page
originOptionsRef.current = res.data
const opts = res.data.map(el => ({ label: el.user_name, value: Number(el.user_id) }))
setOptions(_ops => page > 1 ? [..._ops, ...opts] : opts)
})
}
useEffect(() => {
reload(1, '')
}, [])
// 加载更多
const loadMore = (name) => {
reload(pageRef.current + 1, name)
}
return <MultiSelect
contentClassName=" max-w-[630px]"
return <DepartmentUsersSelect
multiple={multiple}
value={value}
lockedValues={lockedValues}
value={mappedValue}
lockedValues={(lockedValues || []).map((x: any) => Number(x))}
disabled={disabled}
options={options}
placeholder={t('system.selectUser')}
searchPlaceholder={t('system.searchUser')}
onChange={onChange}
onLoad={() => reload(1, '')}
onSearch={(val) => reload(1, val)}
onScrollLoad={(val) => loadMore(val)}
>
{children?.(reload)}
</MultiSelect>
/>
};
@@ -134,6 +134,8 @@ export function UserProvider({ children }: { children: ReactNode }) {
// 是否有访问后台权限
if (/^(\/\w+)?\/chat/.test(location.pathname)) return // 排除免登陆
const BASE_URL = __APP_ENV__.BASE_URL
// v2.5 角色菜单使用 admin 作为管理端父级;旧数据可能仍为 backendWebMenuResource 遗留)
const adminMenuKeys = new Set([
'backend',
@@ -147,14 +149,20 @@ export function UserProvider({ children }: { children: ReactNode }) {
'system_config',
'mark_task',
])
// 部门管理员在 v2.5 允许进入管理端(至少可管理其权限范围内数据);
// 仅依赖 web_menu 会把这类账号误判成无权限(error=90001)。
const canAccessPlatform =
res.role === 'admin' || web_menu.some((k: string) => adminMenuKeys.has(k))
res.role === 'admin'
|| Boolean(res.is_department_admin)
|| Boolean(res.can_manage_user_groups)
|| web_menu.some((k: string) => adminMenuKeys.has(k))
if (!canAccessPlatform) {
location.href = `${location.origin}/workspace/c/new?error=90001`;
return;
// 避免把用户踢回工作台并携带 error=90001(影响登录体验);
// 无后台权限时在管理端内落到 403,由后端接口继续做细粒度鉴权。
history.pushState(null, '', BASE_URL + '/403')
return
}
const BASE_URL = __APP_ENV__.BASE_URL
const pathName = location.pathname.replace(BASE_URL, '');
// Jump to the route based on permissions
@@ -169,14 +169,15 @@ export async function readFlowsFromDatabase(page: number = 1, pageSize: number =
}
/* app list */
export async function getAppsApi({ page = 1, pageSize = 20, keyword, tag_id = -1, type, managed }) {
export async function getAppsApi({ page = 1, pageSize = 20, keyword, tag_id = -1, type, managed, status }) {
const tagIdStr = tag_id === -1 ? '' : `&tag_id=${tag_id}`
const map = { assistant: 5, skill: 1, flow: 10 }
const flowType = map[type] ? `&flow_type=${map[type]}` : ''
const managedStr = (managed !== undefined && managed !== null && managed !== '')
? `&managed=${managed}`
: '';
const { data, total }: { data: any[], total: number } = await axios.get(`/api/v1/workflow/list?page_num=${page}&page_size=${pageSize}&name=${keyword}${tagIdStr}${flowType}${managedStr}`);
const statusStr = (status === 1 || status === 2) ? `&status=${status}` : ''
const { data, total }: { data: any[], total: number } = await axios.get(`/api/v1/workflow/list?page_num=${page}&page_size=${pageSize}&name=${keyword ?? ''}${tagIdStr}${flowType}${managedStr}${statusStr}`);
const newData = data.map(item => {
if (item.flow_type !== 5) return item
return {
@@ -102,17 +102,25 @@ customAxios.interceptors.response.use(function (response) {
}, function (error) {
console.error('application error :>> ', error);
if (error.response?.status === 401) {
// cookie expires
// 必须在 remove 之前读取:从未持有 ws_token/UUR_INFO 时 401(如登录页拉 /user/info)不应整页跳转,否则会死循环。
const hadSession =
!!localStorage.getItem('ws_token')
|| !!localStorage.getItem('UUR_INFO');
// cookie / Bearer 失效(含 token_version 失效、账号禁用)
localStorage.removeItem('ws_token');
console.error('登录过期 :>> ');
const thirdPartyLoginUrl = localStorage.getItem('THIRD_PARTY_LOGIN_URL');
if (thirdPartyLoginUrl) {
localStorage.removeItem('UUR_INFO');
window.location.href = thirdPartyLoginUrl;
return Promise.reject('登录过期');
}
const UUR_INFO = 'UUR_INFO'
const infoStr = localStorage.getItem(UUR_INFO)
localStorage.removeItem(UUR_INFO)
infoStr && location.reload()
localStorage.removeItem('UUR_INFO');
// 仅「曾有过登录态」时再回根路径,避免深路径 URL 上叠登录页且状态错乱。
if (hadSession) {
const base = (__APP_ENV__.BASE_URL || '').replace(/\/$/, '');
window.location.href = `${base}/`;
}
return Promise.reject('登录过期,请重新登录');
}
if (error.code === "ERR_CANCELED") return Promise.reject(error);
@@ -1,7 +1,6 @@
import CardComponent from "@/components/bs-comp/cardComponent";
import AppAvator from "@/components/bs-comp/cardComponent/avatar";
import LabelShow from "@/components/bs-comp/cardComponent/LabelShow";
import { PermissionBadge } from "@/components/bs-comp/permission/PermissionBadge";
import { PermissionDialog } from "@/components/bs-comp/permission/PermissionDialog";
import { usePermissionLevels } from "@/components/bs-comp/permission/usePermissionLevels";
import { RelationLevel } from "@/components/bs-comp/permission/types";
@@ -35,6 +34,27 @@ import { useCreateTemp, useErrorPrompt, useQueryLabels } from "./hook";
import CardSelectVersion from "./skills/CardSelectVersion";
import CreateTemp from "./skills/CreateTemp";
/** 按应用上线(2)/下线(1)状态筛选,与后端 ``/api/v1/workflow/list?status=`` 一致 */
export const SelectAppStatus = ({ defaultValue = 'all', onChange }: { defaultValue?: string; onChange: (v: string) => void }) => {
const [value, setValue] = useState(defaultValue)
const { t } = useTranslation()
return (
<Select value={value} onValueChange={(v) => { onChange(v); setValue(v) }}>
<SelectTrigger className="max-w-36 min-w-[9rem]">
<SelectValue placeholder={t('build.allAppStatus')} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">{t('build.allAppStatus')}</SelectItem>
<SelectItem value="2">{t('build.online')}</SelectItem>
<SelectItem value="1">{t('build.offline')}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
)
}
export const SelectType = ({ all = false, defaultValue = 'all', onChange }) => {
const [value, setValue] = useState<string>(defaultValue)
const { t } = useTranslation();
@@ -292,6 +312,11 @@ export default function apps() {
tempTypeRef.current = v
filterData({ type: v })
}} />
<SelectAppStatus
onChange={(v) => {
filterData({ status: v === 'all' ? undefined : Number(v) })
}}
/>
<SelectSearch
value={!selectLabel.value ? '' : selectLabel.value}
options={allOptions}
@@ -357,7 +382,6 @@ export default function apps() {
onDelete={canDelete(item.id) ? handleDelete : undefined}
onSetting={(item) => handleSetting(item)}
onPermission={canManage(item.id) ? handleOpenPermission : undefined}
permissionBadge={<PermissionBadge level={permLevels[String(item.id)]} />}
// PRD:「可编辑」含上线/下线;与 ReBAC can_edit 对齐(非仅 owner/manager
showSwitch={canEdit(item.id)}
showCopy={canCreateApp && canRead(item.id)}
@@ -1,6 +1,5 @@
import { LoadIcon } from "@/components/bs-icons";
import { LoadingIcon } from "@/components/bs-icons/loading";
import { PermissionBadge } from "@/components/bs-comp/permission/PermissionBadge";
import { PermissionDialog } from "@/components/bs-comp/permission/PermissionDialog";
import { canManageResource, usePermissionLevels } from "@/components/bs-comp/permission/usePermissionLevels";
import { Accordion } from "@/components/bs-ui/accordion";
@@ -188,7 +187,6 @@ const TabTools = ({ select = null, onSelect }: TabToolsProps) => {
onPermission={canManageResource(permLevels, el.id)
? (tool) => { setPermTarget({ id: String(tool.id), name: tool.name }); setPermDialogOpen(true); }
: null}
permissionBadge={<PermissionBadge level={permLevels[String(el.id)]} />}
></ToolItem>
))
) : (
@@ -1,6 +1,5 @@
"use client"
import { PermissionBadge } from "@/components/bs-comp/permission/PermissionBadge"
import { PermissionDialog } from "@/components/bs-comp/permission/PermissionDialog"
import { canManageResource, usePermissionLevels } from "@/components/bs-comp/permission/usePermissionLevels"
import { bsConfirm } from "@/components/bs-ui/alertDialog/useConfirm"
@@ -238,7 +237,6 @@ export function DashboardSidebar({
onPermission={canManageResource(permLevels, dashboard.id)
? (d) => { setPermTarget({ id: String(d.id), name: d.title }); setPermDialogOpen(true); }
: undefined}
permissionBadge={<PermissionBadge level={permLevels[String(dashboard.id)]} />}
/>
))
)}
@@ -8,17 +8,16 @@ import {
} from "@/components/bs-ui/dialog"
import { Input } from "@/components/bs-ui/input"
import { Label } from "@/components/bs-ui/label"
import MultiSelect from "@/components/bs-ui/select/multi"
import { toast } from "@/components/bs-ui/toast/use-toast"
import DepartmentUsersSelect, {
DepartmentUserOption,
} from "@/components/bs-comp/selectComponent/DepartmentUsersSelect"
import { createDepartmentApi } from "@/controllers/API/department"
import { getUsersApi } from "@/controllers/API/user"
import { captureAndAlertRequestErrorHoc } from "@/controllers/request"
import { DepartmentTreeNode } from "@/types/api/department"
import { useCallback, useEffect, useRef, useState } from "react"
import { useCallback, useState } from "react"
import { useTranslation } from "react-i18next"
type AdminOption = { value: string; label: string }
interface CreateDepartmentDialogProps {
tree: DepartmentTreeNode[]
defaultParentId: number | null
@@ -35,72 +34,9 @@ export function CreateDepartmentDialog({
const { t } = useTranslation()
const [name, setName] = useState("")
const [parentId, setParentId] = useState<number | null>(defaultParentId)
const [adminSelectValue, setAdminSelectValue] = useState<AdminOption[]>([])
const [userSearchOptions, setUserSearchOptions] = useState<AdminOption[]>([])
const [adminSelectValue, setAdminSelectValue] = useState<DepartmentUserOption[]>([])
const [loading, setLoading] = useState(false)
const adminSelectValueRef = useRef<AdminOption[]>([])
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const searchAbortRef = useRef<AbortController | null>(null)
useEffect(() => {
adminSelectValueRef.current = adminSelectValue
}, [adminSelectValue])
useEffect(() => {
return () => {
searchAbortRef.current?.abort()
if (searchTimerRef.current) clearTimeout(searchTimerRef.current)
}
}, [])
const mergeUserOptions = useCallback(
(
searchResults: { user_id: number; user_name: string }[],
currentAdmins: AdminOption[]
): AdminOption[] => {
const byVal = new Map<string, AdminOption>()
for (const a of currentAdmins) byVal.set(a.value, a)
for (const u of searchResults) {
const v = String(u.user_id)
if (!byVal.has(v)) {
byVal.set(v, { value: v, label: u.user_name })
}
}
return Array.from(byVal.values())
},
[]
)
const runUserSearch = useCallback(
async (q: string, currentAdmins: AdminOption[]) => {
searchAbortRef.current?.abort()
const ac = new AbortController()
searchAbortRef.current = ac
try {
const res = await getUsersApi(
{ name: q, page: 1, pageSize: 120 },
{ signal: ac.signal }
)
if (ac.signal.aborted) return
setUserSearchOptions(mergeUserOptions(res.data || [], currentAdmins))
} catch {
/* aborted or network */
}
},
[mergeUserOptions]
)
const scheduleUserSearch = useCallback(
(q: string) => {
if (searchTimerRef.current) clearTimeout(searchTimerRef.current)
searchTimerRef.current = setTimeout(() => {
void runUserSearch(q, adminSelectValueRef.current)
}, 300)
},
[runUserSearch]
)
// Flatten tree for parent selector (exclude archived departments)
const flatList: { id: number; name: string; depth: number }[] = []
const flatten = (nodes: DepartmentTreeNode[], depth: number) => {
@@ -114,11 +50,19 @@ export function CreateDepartmentDialog({
const handleSubmit = useCallback(() => {
if (!name || name.length < 2 || name.length > 50) {
toast({ title: t("bs:department.nameLength"), variant: "error" })
toast({
title: t("prompt"),
description: t("bs:department.nameLength"),
variant: "error",
})
return
}
if (parentId === null) {
toast({ title: t("bs:department.selectParent"), variant: "error" })
toast({
title: t("prompt"),
description: t("bs:department.selectParent"),
variant: "error",
})
return
}
setLoading(true)
@@ -127,14 +71,18 @@ export function CreateDepartmentDialog({
name,
parent_id: parentId,
admin_user_ids: adminSelectValue.length
? adminSelectValue.map((o) => Number(o.value))
? adminSelectValue.map((o) => o.value)
: undefined,
})
).then((res) => {
setLoading(false)
// captureAndAlertRequestErrorHoc 在接口失败时返回 false(不是 null)
if (res === false) return
toast({ title: t("bs:department.create"), variant: "success" })
toast({
title: t("prompt"),
description: t("bs:department.create"),
variant: "success",
})
onCreated()
})
}, [name, parentId, adminSelectValue, onCreated, t])
@@ -180,25 +128,16 @@ export function CreateDepartmentDialog({
<div className="space-y-2">
<Label>{t("bs:department.admins")}</Label>
<p className="text-xs text-muted-foreground">{t("bs:department.adminsHint")}</p>
<MultiSelect
<DepartmentUsersSelect
multiple
scroll
onScrollLoad={() => {}}
value={adminSelectValue}
options={userSearchOptions}
onChange={(vals) => {
const v = (vals as DepartmentUserOption[]) || []
setAdminSelectValue(v)
}}
placeholder={t("bs:department.adminSelectPlaceholder")}
searchPlaceholder={t("bs:department.searchUsersPlaceholder")}
onSearch={(q) => scheduleUserSearch(q)}
onLoad={() => {
void runUserSearch("", adminSelectValueRef.current)
}}
onChange={(vals) => {
const v = (vals as AdminOption[]) || []
setAdminSelectValue(v)
adminSelectValueRef.current = v
}}
className="max-w-xl w-full"
contentClassName="min-w-[var(--radix-select-trigger-width)]"
/>
</div>
</div>
@@ -5,32 +5,34 @@ import { Label } from "@/components/bs-ui/label"
import MultiSelect from "@/components/bs-ui/select/multi"
import { Separator } from "@/components/bs-ui/separator"
import { toast } from "@/components/bs-ui/toast/use-toast"
import { TreeDepartmentSelect } from "@/components/bs-comp/department/TreeDepartmentSelect"
import DepartmentUsersSelect, {
DepartmentUserOption,
} from "@/components/bs-comp/selectComponent/DepartmentUsersSelect"
import {
deleteDepartmentApi,
getDepartmentAdminsApi,
getDepartmentApi,
getDepartmentAssignableRolesApi,
moveDepartmentApi,
purgeDepartmentApi,
restoreDepartmentApi,
updateDepartmentApi,
} from "@/controllers/API/department"
import { getUsersApi } from "@/controllers/API/user"
import { isSyncedSource } from "@/pages/DepartmentPage/constants/syncReadonly"
import { captureAndAlertRequestErrorHoc } from "@/controllers/request"
import type { DepartmentAdmin, DepartmentTreeNode } from "@/types/api/department"
import { useCallback, useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
type AdminOption = { value: string; label: string }
interface DepartmentSettingsProps {
dept: DepartmentTreeNode
tree: DepartmentTreeNode[]
onChanged: () => void
}
function adminsToOptions(admins: DepartmentAdmin[]): AdminOption[] {
return admins.map((a) => ({ value: String(a.user_id), label: a.user_name }))
function adminsToOptions(admins: DepartmentAdmin[]): DepartmentUserOption[] {
return admins.map((a) => ({ value: Number(a.user_id), label: a.user_name }))
}
/** 企业级表单:统一控件最大宽度,右侧对齐 */
@@ -39,79 +41,82 @@ const FORM_CONTROL_WIDTH = "w-full max-w-md"
export function DepartmentSettings({ dept, tree, onChanged }: DepartmentSettingsProps) {
const { t } = useTranslation()
const [name, setName] = useState(dept.name)
const [adminSelectValue, setAdminSelectValue] = useState<AdminOption[]>([])
const [userSearchOptions, setUserSearchOptions] = useState<AdminOption[]>([])
const [adminSelectValue, setAdminSelectValue] = useState<DepartmentUserOption[]>([])
const [defaultRoleIds, setDefaultRoleIds] = useState<string[]>([])
const [assignableRoles, setAssignableRoles] = useState<{ value: string; label: string }[]>([])
const [saving, setSaving] = useState(false)
const [loading, setLoading] = useState(true)
const [parentIdValue, setParentIdValue] = useState<number | null>(dept.parent_id ?? null)
const [parentTreeNodes, setParentTreeNodes] = useState<DepartmentTreeNode[]>([])
const adminSelectValueRef = useRef<AdminOption[]>([])
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const searchAbortRef = useRef<AbortController | null>(null)
const adminSelectValueRef = useRef<DepartmentUserOption[]>([])
const isSynced = isSyncedSource(dept.source)
const isArchived = dept.status === "archived"
const isAbsoluteRootDept = dept.parent_id === null || Number(dept.parent_id) === 0
// 对部门管理员场景:当前可见树的顶层节点也视为“根节点”(即便全局树里它还有父节点)
const isVisibleRootDept = tree.some((n) => n.id === dept.id)
const isRootDept = isAbsoluteRootDept || isVisibleRootDept
/** 仅部门名称对第三方同步部门只读;管理员与默认角色仍可保存 */
const canEditName = !isArchived && !isSynced
const canEditPermissions = !isArchived
const canEditParent = !isArchived && !isSynced && !isRootDept
/** 最近一次从服务端加载成功的快照,用于「取消」还原 */
const baselineRef = useRef<{
name: string
admins: AdminOption[]
admins: DepartmentUserOption[]
defaultRoleIds: string[]
parentId: number | null
} | null>(null)
useEffect(() => {
adminSelectValueRef.current = adminSelectValue
}, [adminSelectValue])
const mergeUserOptions = useCallback(
(
searchResults: { user_id: number; user_name: string }[],
currentAdmins: AdminOption[]
): AdminOption[] => {
const byVal = new Map<string, AdminOption>()
for (const a of currentAdmins) byVal.set(a.value, a)
for (const u of searchResults) {
const v = String(u.user_id)
if (!byVal.has(v)) {
byVal.set(v, { value: v, label: u.user_name })
}
const gatherSubtreeIds = useCallback((node: DepartmentTreeNode | null): Set<number> => {
const ids = new Set<number>()
if (!node) return ids
const walk = (n: DepartmentTreeNode) => {
ids.add(n.id)
for (const c of n.children || []) walk(c)
}
walk(node)
return ids
}, [])
const findNodeByDeptId = useCallback(
(nodes: DepartmentTreeNode[], deptId: string): DepartmentTreeNode | null => {
for (const n of nodes) {
if (n.dept_id === deptId) return n
const found = findNodeByDeptId(n.children || [], deptId)
if (found) return found
}
return Array.from(byVal.values())
return null
},
[]
)
const runUserSearch = useCallback(
async (q: string, currentAdmins: AdminOption[]) => {
searchAbortRef.current?.abort()
const ac = new AbortController()
searchAbortRef.current = ac
try {
const res = await getUsersApi(
{ name: q, page: 1, pageSize: 120 },
{ signal: ac.signal }
)
if (ac.signal.aborted) return
setUserSearchOptions(mergeUserOptions(res.data || [], currentAdmins))
} catch {
/* aborted or network */
const buildParentTreeNodes = useCallback(
(nodes: DepartmentTreeNode[], selectedDeptId: string): DepartmentTreeNode[] => {
const selectedNode = findNodeByDeptId(nodes, selectedDeptId)
const excluded = gatherSubtreeIds(selectedNode)
const walk = (n: DepartmentTreeNode): DepartmentTreeNode | null => {
// 仅可挂到当前可见树(入参 nodes)中的 active 节点;且不能选自身/子树
if (excluded.has(n.id) || n.status !== "active") return null
const nextChildren = (n.children || [])
.map((c) => walk(c))
.filter((x): x is DepartmentTreeNode => Boolean(x))
return {
...n,
children: nextChildren,
}
}
return nodes
.map((root) => walk(root))
.filter((x): x is DepartmentTreeNode => Boolean(x))
},
[mergeUserOptions]
)
const scheduleUserSearch = useCallback(
(q: string) => {
if (searchTimerRef.current) clearTimeout(searchTimerRef.current)
searchTimerRef.current = setTimeout(() => {
void runUserSearch(q, adminSelectValueRef.current)
}, 300)
},
[runUserSearch]
[findNodeByDeptId, gatherSubtreeIds]
)
useEffect(() => {
@@ -131,14 +136,18 @@ export function DepartmentSettings({ dept, tree, onChanged }: DepartmentSettings
setName(detailRes?.name ?? dept.name)
const dr = (detailRes?.default_role_ids ?? []).map(String)
setDefaultRoleIds(dr)
const pTreeNodes = buildParentTreeNodes(tree, dept.dept_id)
setParentTreeNodes(pTreeNodes)
const pid = detailRes?.parent_id ?? dept.parent_id ?? null
setParentIdValue(pid)
setAssignableRoles(
(rolesRes || []).map((r) => ({ value: String(r.id), label: r.role_name }))
)
setUserSearchOptions(mergeUserOptions([], adminOpts))
baselineRef.current = {
name: detailRes?.name ?? dept.name,
admins: adminOpts,
defaultRoleIds: dr,
parentId: pid,
}
})
.catch(() => {
@@ -155,10 +164,8 @@ export function DepartmentSettings({ dept, tree, onChanged }: DepartmentSettings
})
return () => {
cancelled = true
searchAbortRef.current?.abort()
if (searchTimerRef.current) clearTimeout(searchTimerRef.current)
}
}, [dept.dept_id, dept.name, mergeUserOptions, t])
}, [buildParentTreeNodes, dept.dept_id, dept.name, dept.parent_id, t, tree])
const handleCancel = useCallback(() => {
const b = baselineRef.current
@@ -167,13 +174,17 @@ export function DepartmentSettings({ dept, tree, onChanged }: DepartmentSettings
setAdminSelectValue(b.admins)
adminSelectValueRef.current = b.admins
setDefaultRoleIds(b.defaultRoleIds)
setUserSearchOptions(mergeUserOptions([], b.admins))
}, [mergeUserOptions])
setParentIdValue(b.parentId)
}, [])
const handleGlobalSave = useCallback(async () => {
if (!canEditPermissions) return
if (canEditName && (!name || name.length < 2 || name.length > 50)) {
toast({ title: t("bs:department.nameLength"), variant: "error" })
toast({
title: t("prompt"),
description: t("bs:department.nameLength"),
variant: "error",
})
return
}
setSaving(true)
@@ -184,9 +195,22 @@ export function DepartmentSettings({ dept, tree, onChanged }: DepartmentSettings
admin_user_ids: number[]
} = {
default_role_ids: defaultRoleIds.map(Number),
admin_user_ids: adminSelectValue.map((o) => Number(o.value)),
admin_user_ids: adminSelectValue.map((o) => o.value),
}
if (canEditName) body.name = name.trim()
const nextParentId = parentIdValue
const parentChanged =
canEditParent &&
baselineRef.current &&
nextParentId !== null &&
nextParentId !== baselineRef.current.parentId
if (parentChanged) {
const moveRes = await captureAndAlertRequestErrorHoc(
moveDepartmentApi(dept.dept_id, nextParentId)
)
if (moveRes === null || moveRes === false) return
}
const res = await captureAndAlertRequestErrorHoc(
updateDepartmentApi(dept.dept_id, body)
)
@@ -206,8 +230,8 @@ export function DepartmentSettings({ dept, tree, onChanged }: DepartmentSettings
name: name.trim(),
admins: adminOpts,
defaultRoleIds: [...defaultRoleIds],
parentId: nextParentId ?? baselineRef.current?.parentId ?? dept.parent_id ?? null,
}
setUserSearchOptions(mergeUserOptions([], adminOpts))
onChanged()
} finally {
setSaving(false)
@@ -215,12 +239,14 @@ export function DepartmentSettings({ dept, tree, onChanged }: DepartmentSettings
}, [
adminSelectValue,
canEditName,
canEditParent,
canEditPermissions,
defaultRoleIds,
dept.dept_id,
mergeUserOptions,
dept.parent_id,
name,
onChanged,
parentIdValue,
t,
])
@@ -284,14 +310,14 @@ export function DepartmentSettings({ dept, tree, onChanged }: DepartmentSettings
})
}, [dept.dept_id, onChanged, t])
const findParentName = (
const findParentDisplay = (
nodes: DepartmentTreeNode[],
parentId: number | null
): string => {
if (parentId === null) return "-"
for (const n of nodes) {
if (n.id === parentId) return n.name
const found = findParentName(n.children || [], parentId)
const found = findParentDisplay(n.children || [], parentId)
if (found !== "-") return found
}
return "-"
@@ -335,14 +361,28 @@ export function DepartmentSettings({ dept, tree, onChanged }: DepartmentSettings
/>
)}
</div>
<div className="space-y-1.5">
<Label>{t("bs:department.parentDept")}</Label>
<Input
value={findParentName(tree, dept.parent_id)}
disabled
className={FORM_CONTROL_WIDTH}
/>
</div>
{!isRootDept && (
<div className="space-y-1.5">
<Label>{t("bs:department.parentDept")}</Label>
{canEditParent ? (
<TreeDepartmentSelect
nodes={parentTreeNodes}
value={parentIdValue}
onChange={(id) => setParentIdValue(id)}
className={FORM_CONTROL_WIDTH}
placeholder={t("bs:department.selectDept")}
searchPlaceholder={t("bs:department.parentDept")}
modal={false}
/>
) : (
<Input
value={findParentDisplay(tree, dept.parent_id)}
disabled
className={FORM_CONTROL_WIDTH}
/>
)}
</div>
)}
</section>
{/* 区块二:权限与角色 */}
@@ -356,26 +396,18 @@ export function DepartmentSettings({ dept, tree, onChanged }: DepartmentSettings
</div>
<div className="space-y-1.5">
<Label>{t("bs:department.admins")}</Label>
<MultiSelect
<DepartmentUsersSelect
multiple
scroll
disabled={!canEditPermissions}
onScrollLoad={() => {}}
value={adminSelectValue}
options={userSearchOptions}
placeholder={t("bs:department.adminSelectPlaceholder")}
searchPlaceholder={t("bs:department.searchUsersPlaceholder")}
onSearch={(q) => scheduleUserSearch(q)}
onLoad={() => {
void runUserSearch("", adminSelectValueRef.current)
}}
onChange={(vals) => {
const v = (vals as AdminOption[]) || []
const v = (vals as DepartmentUserOption[]) || []
setAdminSelectValue(v)
adminSelectValueRef.current = v
}}
placeholder={t("bs:department.adminSelectPlaceholder")}
searchPlaceholder={t("bs:department.searchUsersPlaceholder")}
className={FORM_CONTROL_WIDTH}
contentClassName="min-w-[var(--radix-select-trigger-width)]"
/>
<p className="mt-1 max-w-md text-xs leading-snug text-gray-500 dark:text-gray-400">
{t("bs:department.adminsHint")}
@@ -13,7 +13,6 @@ import {
import { BookIcon } from "@/components/bs-icons/knowledge";
import { LoadIcon, LoadingIcon } from "@/components/bs-icons/loading";
import { bsConfirm } from "@/components/bs-ui/alertDialog/useConfirm";
import { PermissionBadge } from "@/components/bs-comp/permission/PermissionBadge";
import { PermissionDialog } from "@/components/bs-comp/permission/PermissionDialog";
import { canManageResource, usePermissionLevels } from "@/components/bs-comp/permission/usePermissionLevels";
import { RelationLevel } from "@/components/bs-comp/permission/types";
@@ -537,9 +536,8 @@ export default function KnowledgeFile() {
<BookIcon className="text-primary size-10" />
</div>
<div>
<div className="truncate max-w-[500px] w-[264px] text-[14px] font-medium pt-2 flex items-center gap-2">
<div className="truncate max-w-[500px] w-[264px] text-[14px] font-medium pt-2">
{el.name}
<PermissionBadge level={permLevels[String(el.id)]} />
</div>
<Tip
side="top"
@@ -2,10 +2,11 @@ import { QaIcon } from "@/components/bs-icons/knowledge";
import { LoadIcon, LoadingIcon } from "@/components/bs-icons/loading";
import { bsConfirm } from "@/components/bs-ui/alertDialog/useConfirm";
import { Button } from "@/components/bs-ui/button";
import { PermissionDialog } from "@/components/bs-comp/permission/PermissionDialog";
import { canManageResource, usePermissionLevels } from "@/components/bs-comp/permission/usePermissionLevels";
import { RelationLevel } from "@/components/bs-comp/permission/types";
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/bs-ui/dialog";
import { Input, SearchInput, Textarea } from "@/components/bs-ui/input";
import { RelationLevel } from "@/components/bs-comp/permission/types";
import { usePermissionLevels } from "@/components/bs-comp/permission/usePermissionLevels";
import AutoPagination from "@/components/bs-ui/pagination/autoPagination";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/bs-ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/bs-ui/table";
@@ -18,7 +19,7 @@ import { getKnowledgeModelConfig } from "@/controllers/API/finetune";
import { captureAndAlertRequestErrorHoc } from "@/controllers/request";
import { ModelSelect } from "@/pages/ModelPage/manage/tabs/WorkbenchModel";
import { useTable } from "@/util/hook";
import { CircleAlert, Copy, Ellipsis, LoaderCircle, Settings, Trash2 } from "lucide-react";
import { CircleAlert, Copy, Ellipsis, LoaderCircle, Settings, Shield, Trash2 } from "lucide-react";
import { useContext, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
@@ -280,6 +281,8 @@ export default function KnowledgeQa(params) {
const [copyLoadingId, setCopyLoadingId] = useState<string | null>(null);
const [selectOpenId, setSelectOpenId] = useState<string | null>(null);
const [modalKey, setModalKey] = useState(0);
const [permDialogOpen, setPermDialogOpen] = useState(false);
const [permTarget, setPermTarget] = useState<{ id: string; name: string } | null>(null);
const { page, pageSize, data: datalist, total, loading, setPage, search, reload } = useTable(
{ cancelLoadingWhenReload: true },
@@ -459,6 +462,10 @@ export default function KnowledgeQa(params) {
onValueChange={(selectedValue) => {
setSelectOpenId(null);
switch (selectedValue) {
case 'permission':
setPermTarget({ id: String(el.id), name: el.name });
setPermDialogOpen(true);
break;
case 'copy':
canUseCopy(el) &&
el.state === KnowledgeBaseStatus.Published &&
@@ -494,6 +501,14 @@ export default function KnowledgeQa(params) {
onClick={(e) => e.stopPropagation()}
className="z-50 overflow-visible"
>
{canManageResource(permLevels, el.id) && (
<SelectItem showIcon={false} value="permission">
<div className="flex gap-2 items-center">
<Shield className="w-4 h-4" />
{t('managePermission', { ns: 'permission' })}
</div>
</SelectItem>
)}
<Tip content={!canUseCopy(el) && t('noPermission')} side='top'>
<SelectItem
showIcon={false}
@@ -579,6 +594,16 @@ export default function KnowledgeQa(params) {
currentLib={currentSettingLib}
/>
)}
{permTarget && (
<PermissionDialog
open={permDialogOpen}
onOpenChange={setPermDialogOpen}
resourceType="knowledge_space"
resourceId={permTarget.id}
resourceName={permTarget.name}
/>
)}
</div>
);
}
@@ -82,14 +82,32 @@ export const LoginPage = () => {
}, []);
const fetchCaptchaData = () => {
getCaptchaApi().then(setCaptchaData)
getCaptchaApi()
.then((raw: any) => {
const rawFlag = raw?.user_capthca ?? raw?.user_captcha;
const enabled =
rawFlag === true ||
rawFlag === 1 ||
(typeof rawFlag === 'string'
&& ['true', '1', 'yes', 'on'].includes(String(rawFlag).trim().toLowerCase()));
setCaptchaData({
captcha_key: raw?.captcha_key ?? '',
captcha: raw?.captcha ?? '',
user_capthca: enabled,
});
})
.catch(() => {
setCaptchaData({ captcha_key: '', captcha: '', user_capthca: false });
});
};
const [hasLdap, setHasLdap] = useState(false)
const [ldapCheckboxLabel, setLdapCheckboxLabel] = useState('')
const [isLdapLogin, setIsLdapLogin] = useState(true)
const enableDualLogin = hasLdap && !!ldapCheckboxLabel
const shouldUseLdap = hasLdap && (!enableDualLogin || isLdapLogin)
// 仅当网关下发了「LDAP / 本地」切换文案时,才走 LDAP 口令登录;否则有 LDAP 配置也走 Bisheng
// 本地登录(含验证码),避免「界面不显示验证码却走 LDAP/空 captcha_key」导致后端报验证码错误。
const shouldUseLdap = hasLdap && !!ldapCheckboxLabel && isLdapLogin
const showCaptcha = captchaData.user_capthca && (!showLogin || !shouldUseLdap)
const handleLogin = async () => {
@@ -10,10 +10,15 @@ import { DepartmentSettings } from "@/pages/DepartmentPage/components/Department
import { DepartmentTrafficControl } from "@/pages/DepartmentPage/components/DepartmentTrafficControl"
import { CreateDepartmentDialog } from "@/pages/DepartmentPage/components/CreateDepartmentDialog"
import { locationContext } from "@/contexts/locationContext"
import { userContext } from "@/contexts/userContext"
export default function Departments() {
const { t } = useTranslation()
const { appConfig } = useContext(locationContext)
const { user } = useContext(userContext)
/** 与系统页「组织同步」一致:仅平台超级管理员(role=admin */
const isSuperAdmin = user?.role === "admin"
const showTrafficControlTab = isSuperAdmin && appConfig.isPro
const [tree, setTree] = useState<DepartmentTreeNode[]>([])
const [selectedDeptId, setSelectedDeptId] = useState<string | null>(null)
const [selectedDept, setSelectedDept] = useState<DepartmentTreeNode | null>(null)
@@ -153,7 +158,7 @@ export default function Departments() {
<TabsList>
<TabsTrigger value="members">{t("bs:department.members")}</TabsTrigger>
<TabsTrigger value="settings">{t("bs:department.settings")}</TabsTrigger>
{appConfig.isPro && (
{showTrafficControlTab && (
<TabsTrigger value="traffic-control">{t("bs:department.trafficControl")}</TabsTrigger>
)}
</TabsList>
@@ -169,7 +174,7 @@ export default function Departments() {
<TabsContent value="settings">
<DepartmentSettings dept={selectedDept} tree={tree} onChanged={handleTreeChange} />
</TabsContent>
{appConfig.isPro && (
{showTrafficControlTab && (
<TabsContent value="traffic-control">
<DepartmentTrafficControl dept={selectedDept} />
</TabsContent>
@@ -45,13 +45,12 @@ export default function OrgSync() {
const gatewayTotal = useOrgSyncStore((s) => s.gatewayTotal)
const loading = useOrgSyncStore((s) => s.loading)
const page = useOrgSyncStore((s) => s.page)
const fetchGatewayLogs = useOrgSyncStore((s) => s.fetchGatewayLogs)
const [detailLog, setDetailLog] = useState<OrgSyncLog | null>(null)
useEffect(() => {
captureAndAlertRequestErrorHoc(fetchGatewayLogs(1))
}, [fetchGatewayLogs])
captureAndAlertRequestErrorHoc(useOrgSyncStore.getState().fetchGatewayLogs(1))
}, [])
const maxPage = useMemo(
() => Math.max(1, Math.ceil(gatewayTotal / PAGE_SIZE)),
@@ -61,13 +60,13 @@ export default function OrgSync() {
const goPrev = () => {
if (page <= 1) return
const next = page - 1
captureAndAlertRequestErrorHoc(fetchGatewayLogs(next))
captureAndAlertRequestErrorHoc(useOrgSyncStore.getState().fetchGatewayLogs(next))
}
const goNext = () => {
if (page >= maxPage) return
const next = page + 1
captureAndAlertRequestErrorHoc(fetchGatewayLogs(next))
captureAndAlertRequestErrorHoc(useOrgSyncStore.getState().fetchGatewayLogs(next))
}
return (
+4 -1
View File
@@ -3,7 +3,10 @@ export type User = {
external_id?: string | null;
email: string | null;
phone_number: string | null;
dept_id: number | null;
/** 历史/业务侧部门标识,字符串居多;与组织树节点 ``id`` 不一定一致 */
dept_id?: number | string | null;
/** 主部门在 ``department`` 表中的内部主键,与 ``/departments/tree`` 的 ``id`` 对齐(/user/list 补充) */
department_id?: number | null;
remark: string | null;
delete: number;
create_time: string;