mirror of
https://github.com/dataelement/bisheng.git
synced 2026-08-29 01:22:31 +08:00
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:
@@ -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 (端口 7860;config 须为相对 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/sync;Gateway 通过 F014 HMAC 调 bisheng
|
||||
# POST /api/v1/departments/sync。联调入口见 docker/local-dev/README.md §6(含一键脚本与冒烟命令)。
|
||||
|
||||
# 启用商业版功能需设置后端环境变量
|
||||
export BISHENG_PRO=true # 在启动后端前设置,开启 /api/v1/user/sso 端点
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 官方镜像无 HEALTHCHECK,Compose 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:
|
||||
|
||||
@@ -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 = 1(AdminRole)」中 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;
|
||||
@@ -0,0 +1,8 @@
|
||||
# 在 bisheng 仓库根目录执行:本机 bisheng API + 开启 SSO(BISHENG_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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
**/node_modules
|
||||
**/build
|
||||
**/dist
|
||||
**/.git
|
||||
**/.cache
|
||||
**/.turbo
|
||||
**/*.log
|
||||
.DS_Store
|
||||
@@ -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 */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
+373
@@ -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 作为管理端父级;旧数据可能仍为 backend(WebMenuResource 遗留)
|
||||
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)]} />}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
+26
-87
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user