feat(admin,rvw,asl,frontend): Batch import redesign + RVW parallel skills + UI improvements

Backend:
- Redesign batch user import: add autoInheritModules param, users auto-inherit tenant modules when true
- Add module validation: reject modules not subscribed by the tenant
- Soften department validation: skip instead of fail when department name not found
- Fix RVW skill status semantics: review findings (ERROR issues) no longer mark skill as error
- Add parallel execution support to SkillExecutor via parallelGroup
- Configure Editorial + Methodology skills to run in parallel (~240s -> ~130s)
- Update legacy bridge error message to user-friendly text

Frontend:
- Redesign ImportUserModal: 4-step flow (select tenant -> upload -> preview -> result)
- Simplify import template: remove tenant code and module columns
- Show tenant subscribed modules before import with auto-inherit option
- Fix isLegacyEmbed modules bypassing RouteGuard and TopNavigation permission checks
- Hide ASL fulltext screening (step 3), renumber subsequent nav items
- Add ExtractionWorkbenchGuide page when no taskId provided
- Update legacy system error message to network-friendly text

Docs:
- Update deployment changelog with BE-9, FE-11 entries

Made-with: Cursor
This commit is contained in:
2026-03-05 22:04:36 +08:00
parent 0677d42345
commit 91ae80888e
19 changed files with 576 additions and 274 deletions

View File

@@ -82,22 +82,18 @@ function App() {
{/* 首页重定向到 AI 问答 */}
<Route index element={<Navigate to="/ai-qa" replace />} />
{/* 动态加载模块路由 - 基于模块权限系统 ⭐ 2026-01-16 */}
{/* 动态加载模块路由 - 基于模块权限系统 */}
{MODULES.filter(m => !m.isExternal).map(module => (
<Route
key={module.id}
path={`${module.path}/*`}
element={
module.isLegacyEmbed ? (
<RouteGuard
requiredModule={module.moduleCode}
moduleName={module.name}
>
<module.component />
) : (
<RouteGuard
requiredModule={module.moduleCode}
moduleName={module.name}
>
<module.component />
</RouteGuard>
)
</RouteGuard>
}
/>
))}

View File

@@ -30,7 +30,6 @@ const TopNavigation = () => {
// 根据用户模块权限过滤可显示的模块
const availableModules = MODULES.filter(module => {
if (!module.moduleCode) return false;
if (module.isExternal || module.isLegacyEmbed) return true;
return hasModule(module.moduleCode);
});

View File

@@ -91,10 +91,15 @@ export async function updateUserModules(userId: string, data: UpdateUserModulesR
/**
* 批量导入用户
*/
export async function importUsers(users: ImportUserRow[], defaultTenantId?: string): Promise<ImportResult> {
export async function importUsers(
users: ImportUserRow[],
defaultTenantId: string,
autoInheritModules: boolean = true
): Promise<ImportResult> {
const response = await apiClient.post<{ code: number; data: ImportResult }>(`${BASE_URL}/import`, {
users,
defaultTenantId,
autoInheritModules,
});
return response.data.data;
}

View File

@@ -1,8 +1,9 @@
/**
* 批量导入用户弹窗
* 4步流程选择租户 → 下载模板/上传 → 预览确认 → 导入结果
*/
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import {
Modal,
Upload,
@@ -14,15 +15,39 @@ import {
Typography,
message,
Divider,
Steps,
Tag,
Checkbox,
Descriptions,
Spin,
} from 'antd';
import { InboxOutlined, DownloadOutlined } from '@ant-design/icons';
import {
InboxOutlined,
DownloadOutlined,
TeamOutlined,
AppstoreOutlined,
} from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload/interface';
import * as XLSX from 'xlsx';
import * as userApi from '../api/userApi';
import type { TenantOption, ImportUserRow, ImportResult } from '../types/user';
import type {
TenantOption,
ImportUserRow,
ImportResult,
ModuleOption,
} from '../types/user';
const { Dragger } = Upload;
const { Text } = Typography;
const { Text, Title } = Typography;
type StepKey = 'tenant' | 'upload' | 'preview' | 'result';
const STEPS: { key: StepKey; title: string }[] = [
{ key: 'tenant', title: '选择租户' },
{ key: 'upload', title: '上传文件' },
{ key: 'preview', title: '预览确认' },
{ key: 'result', title: '导入结果' },
];
interface ImportUserModalProps {
visible: boolean;
@@ -37,29 +62,47 @@ const ImportUserModal: React.FC<ImportUserModalProps> = ({
onSuccess,
tenantOptions,
}) => {
const [step, setStep] = useState<'upload' | 'preview' | 'result'>('upload');
const [currentStep, setCurrentStep] = useState(0);
const [selectedTenantId, setSelectedTenantId] = useState<string>();
const [tenantModules, setTenantModules] = useState<ModuleOption[]>([]);
const [loadingModules, setLoadingModules] = useState(false);
const [autoInherit, setAutoInherit] = useState(true);
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [parsedData, setParsedData] = useState<ImportUserRow[]>([]);
const [defaultTenantId, setDefaultTenantId] = useState<string>();
const [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState<ImportResult | null>(null);
// 重置状态
const currentStepKey = STEPS[currentStep].key;
const selectedTenant = tenantOptions.find((t) => t.id === selectedTenantId);
useEffect(() => {
if (!selectedTenantId) {
setTenantModules([]);
return;
}
setLoadingModules(true);
userApi
.getModuleOptions(selectedTenantId)
.then((modules) => setTenantModules(modules))
.catch(() => message.error('获取租户模块配置失败'))
.finally(() => setLoadingModules(false));
}, [selectedTenantId]);
const resetState = () => {
setStep('upload');
setCurrentStep(0);
setSelectedTenantId(undefined);
setTenantModules([]);
setAutoInherit(true);
setFileList([]);
setParsedData([]);
setDefaultTenantId(undefined);
setImportResult(null);
};
// 关闭弹窗
const handleClose = () => {
resetState();
onClose();
};
// 解析Excel文件
const parseExcel = (file: File): Promise<ImportUserRow[]> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
@@ -69,17 +112,17 @@ const ImportUserModal: React.FC<ImportUserModalProps> = ({
const workbook = XLSX.read(data, { type: 'binary' });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const jsonData = XLSX.utils.sheet_to_json<any>(worksheet);
// 映射列名
const jsonData = XLSX.utils.sheet_to_json<Record<string, unknown>>(worksheet);
const rows: ImportUserRow[] = jsonData.map((row) => ({
phone: String(row['手机号'] || row['phone'] || '').trim(),
name: String(row['姓名'] || row['name'] || '').trim(),
email: row['邮箱'] || row['email'] || undefined,
role: row['角色'] || row['role'] || undefined,
tenantCode: row['租户代码'] || row['tenantCode'] || undefined,
departmentName: row['科室'] || row['departmentName'] || undefined,
modules: row['模块'] || row['modules'] || undefined,
email: (row['邮箱'] || row['email'] || undefined) as string | undefined,
role: (row['角色'] || row['role'] || undefined) as string | undefined,
departmentName: (row['科室'] || row['departmentName'] || undefined) as string | undefined,
modules: autoInherit
? undefined
: ((row['模块'] || row['modules'] || undefined) as string | undefined),
}));
resolve(rows);
@@ -92,7 +135,6 @@ const ImportUserModal: React.FC<ImportUserModalProps> = ({
});
};
// 处理文件上传
const handleUpload = async (file: File) => {
try {
const rows = await parseExcel(file);
@@ -101,91 +143,209 @@ const ImportUserModal: React.FC<ImportUserModalProps> = ({
return false;
}
setParsedData(rows);
setStep('preview');
} catch (error) {
message.error('解析文件失败');
setCurrentStep(2);
} catch {
message.error('解析文件失败,请检查文件格式');
}
return false;
};
// 执行导入
const handleImport = async () => {
if (!defaultTenantId) {
message.warning('请选择默认租户');
return;
}
if (!selectedTenantId) return;
setImporting(true);
try {
const result = await userApi.importUsers(parsedData, defaultTenantId);
const result = await userApi.importUsers(parsedData, selectedTenantId, autoInherit);
setImportResult(result);
setStep('result');
setCurrentStep(3);
if (result.success > 0) {
onSuccess();
}
} catch (error) {
} catch {
message.error('导入失败');
} finally {
setImporting(false);
}
};
// 下载模板
const downloadTemplate = () => {
const template = [
const baseTemplate = [
{
'手机号': '13800138000',
'姓名': '张三',
'邮箱': 'zhangsan@example.com',
'角色': 'USER',
'租户代码': '',
'科室': '',
'模块': 'AIA,PKB',
},
];
const template = autoInherit
? baseTemplate
: baseTemplate.map((row) => ({
...row,
'模块': subscribedModuleCodes,
}));
const ws = XLSX.utils.json_to_sheet(template);
// 设置列宽
ws['!cols'] = [
{ wch: 15 },
{ wch: 10 },
{ wch: 25 },
{ wch: 10 },
{ wch: 15 },
...(autoInherit ? [] : [{ wch: 20 }]),
];
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '用户导入模板');
XLSX.writeFile(wb, '用户导入模板.xlsx');
XLSX.writeFile(wb, `用户导入模板_${selectedTenant?.name || ''}.xlsx`);
};
// 预览表格列
const subscribedModules = tenantModules.filter((m) => m.isSubscribed);
const subscribedModuleCodes = subscribedModules.map((m) => m.code).join(',');
const previewColumns = [
{ title: '行号', key: 'rowNum', width: 60, render: (_: unknown, __: unknown, index: number) => index + 2 },
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 130 },
{ title: '姓名', dataIndex: 'name', key: 'name', width: 100 },
{ title: '邮箱', dataIndex: 'email', key: 'email', width: 180 },
{ title: '角色', dataIndex: 'role', key: 'role', width: 100 },
{ title: '租户代码', dataIndex: 'tenantCode', key: 'tenantCode', width: 100 },
{ title: '角色', dataIndex: 'role', key: 'role', width: 100, render: (v: string) => v || 'USER' },
{ title: '科室', dataIndex: 'departmentName', key: 'departmentName', width: 100 },
{ title: '模块', dataIndex: 'modules', key: 'modules', width: 120 },
...(!autoInherit
? [{ title: '模块', dataIndex: 'modules', key: 'modules', width: 120 }]
: []),
];
// 错误表格列
const errorColumns = [
{ title: '行号', dataIndex: 'row', key: 'row', width: 80 },
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 130 },
{ title: '错误原因', dataIndex: 'error', key: 'error' },
];
const canGoNext = () => {
if (currentStepKey === 'tenant') return !!selectedTenantId;
if (currentStepKey === 'upload') return parsedData.length > 0;
return true;
};
return (
<Modal
title="批量导入用户"
open={visible}
onCancel={handleClose}
width={800}
width={860}
footer={null}
destroyOnClose
>
{step === 'upload' && (
<Steps
current={currentStep}
size="small"
style={{ marginBottom: 24 }}
items={STEPS.map((s) => ({ title: s.title }))}
/>
{/* Step 1: 选择租户 */}
{currentStepKey === 'tenant' && (
<>
<div style={{ marginBottom: 16 }}>
<Text strong></Text>
<Select
placeholder="请选择租户"
value={selectedTenantId}
onChange={setSelectedTenantId}
style={{ width: 360, marginLeft: 12 }}
showSearch
optionFilterProp="label"
options={tenantOptions.map((t) => ({
value: t.id,
label: `${t.name} (${t.code})`,
}))}
/>
</div>
{selectedTenantId && (
<Spin spinning={loadingModules}>
<Descriptions
bordered
size="small"
column={1}
style={{ marginBottom: 16 }}
>
<Descriptions.Item
label={
<Space>
<TeamOutlined />
<span></span>
</Space>
}
>
{selectedTenant?.name}
</Descriptions.Item>
<Descriptions.Item
label={
<Space>
<AppstoreOutlined />
<span></span>
</Space>
}
>
{subscribedModules.length > 0 ? (
<Space wrap>
{subscribedModules.map((m) => (
<Tag color="blue" key={m.code}>
{m.name}
</Tag>
))}
</Space>
) : (
<Text type="secondary"></Text>
)}
</Descriptions.Item>
</Descriptions>
<Alert
message="模块权限说明"
description="导入的用户将自动获得该租户已开通的全部模块权限。如需为每个用户单独配置模块,可在下一步取消「自动继承」选项。"
type="info"
showIcon
/>
</Spin>
)}
<Divider />
<div style={{ textAlign: 'right' }}>
<Space>
<Button onClick={handleClose}></Button>
<Button
type="primary"
disabled={!canGoNext()}
onClick={() => setCurrentStep(1)}
>
</Button>
</Space>
</div>
</>
)}
{/* Step 2: 下载模板与上传 */}
{currentStepKey === 'upload' && (
<>
<Alert
message="导入说明"
description={
<ul style={{ margin: 0, paddingLeft: 20 }}>
<li> .xlsx .xls Excel文件</li>
<li>
<Tag color="blue">{selectedTenant?.name}</Tag>
</li>
<li> .xlsx .xls Excel </li>
<li></li>
<li>SUPER_ADMINHOSPITAL_ADMINPHARMA_ADMINDEPARTMENT_ADMINUSER</li>
<li>使AIA,PKB,RVW</li>
<li>SUPER_ADMINHOSPITAL_ADMINPHARMA_ADMINDEPARTMENT_ADMINUSER USER</li>
{!autoInherit && (
<li>使{subscribedModuleCodes}</li>
)}
</ul>
}
type="info"
@@ -193,6 +353,20 @@ const ImportUserModal: React.FC<ImportUserModalProps> = ({
style={{ marginBottom: 16 }}
/>
<div style={{ marginBottom: 16 }}>
<Checkbox
checked={autoInherit}
onChange={(e) => setAutoInherit(e.target.checked)}
>
</Checkbox>
{!autoInherit && (
<Text type="warning" style={{ display: 'block', marginTop: 4, marginLeft: 24 }}>
Excel
</Text>
)}
</div>
<div style={{ marginBottom: 16 }}>
<Button icon={<DownloadOutlined />} onClick={downloadTemplate}>
@@ -203,7 +377,7 @@ const ImportUserModal: React.FC<ImportUserModalProps> = ({
accept=".xlsx,.xls"
fileList={fileList}
beforeUpload={handleUpload}
onChange={({ fileList }) => setFileList(fileList)}
onChange={({ fileList: fl }) => setFileList(fl)}
maxCount={1}
>
<p className="ant-upload-drag-icon">
@@ -212,46 +386,59 @@ const ImportUserModal: React.FC<ImportUserModalProps> = ({
<p className="ant-upload-text"></p>
<p className="ant-upload-hint"> .xlsx .xls </p>
</Dragger>
<Divider />
<div style={{ textAlign: 'right' }}>
<Space>
<Button onClick={() => { setCurrentStep(0); setFileList([]); setParsedData([]); }}>
</Button>
</Space>
</div>
</>
)}
{step === 'preview' && (
{/* Step 3: 预览确认 */}
{currentStepKey === 'preview' && (
<>
<Alert
message={`共解析 ${parsedData.length} 条数据,请确认后导入`}
message={
<Space>
<span> <Text strong>{parsedData.length}</Text> </span>
<Divider type="vertical" />
<span>
<Tag color="blue">{selectedTenant?.name}</Tag>
</span>
<Divider type="vertical" />
<span>
{autoInherit ? (
<Tag color="green"></Tag>
) : (
<Tag color="orange"> Excel </Tag>
)}
</span>
</Space>
}
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
<div style={{ marginBottom: 16 }}>
<Space>
<Text></Text>
<Select
placeholder="请选择默认租户"
value={defaultTenantId}
onChange={setDefaultTenantId}
style={{ width: 250 }}
options={tenantOptions.map((t) => ({
value: t.id,
label: `${t.name} (${t.code})`,
}))}
/>
</Space>
</div>
<Table
columns={previewColumns}
dataSource={parsedData.map((row, i) => ({ ...row, key: i }))}
size="small"
scroll={{ x: 800, y: 300 }}
scroll={{ x: 700, y: 300 }}
pagination={false}
/>
<Divider />
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={() => setStep('upload')}></Button>
<Button onClick={() => { setCurrentStep(1); setFileList([]); setParsedData([]); }}>
</Button>
<Button type="primary" loading={importing} onClick={handleImport}>
</Button>
@@ -259,7 +446,8 @@ const ImportUserModal: React.FC<ImportUserModalProps> = ({
</>
)}
{step === 'result' && importResult && (
{/* Step 4: 导入结果 */}
{currentStepKey === 'result' && importResult && (
<>
<Alert
message={`导入完成:成功 ${importResult.success} 条,失败 ${importResult.failed}`}
@@ -268,11 +456,18 @@ const ImportUserModal: React.FC<ImportUserModalProps> = ({
style={{ marginBottom: 16 }}
/>
{autoInherit && importResult.success > 0 && (
<Alert
message={`已成功导入的 ${importResult.success} 位用户自动继承租户「${selectedTenant?.name}」的全部模块权限`}
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
)}
{importResult.errors.length > 0 && (
<>
<Text strong style={{ display: 'block', marginBottom: 8 }}>
</Text>
<Title level={5}></Title>
<Table
columns={errorColumns}
dataSource={importResult.errors.map((e, i) => ({ ...e, key: i }))}
@@ -298,4 +493,3 @@ const ImportUserModal: React.FC<ImportUserModalProps> = ({
};
export default ImportUserModal;

View File

@@ -10,7 +10,6 @@ import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import {
SearchOutlined,
FilterOutlined,
FileSearchOutlined,
DatabaseOutlined,
BarChartOutlined,
SettingOutlined,
@@ -57,32 +56,10 @@ const ASLLayout = () => {
},
],
},
{
key: 'fulltext-screening',
icon: <FileSearchOutlined />,
label: '3. 全文复筛',
children: [
{
key: '/literature/screening/fulltext/settings',
icon: <SettingOutlined />,
label: '设置与启动',
},
{
key: '/literature/screening/fulltext/workbench',
icon: <CheckSquareOutlined />,
label: '审核工作台',
},
{
key: '/literature/screening/fulltext/results',
icon: <UnorderedListOutlined />,
label: '复筛结果',
},
],
},
{
key: 'extraction',
icon: <DatabaseOutlined />,
label: '4. 全文智能提取',
label: '3. 全文智能提取',
children: [
{
key: '/literature/extraction/setup',
@@ -99,12 +76,12 @@ const ASLLayout = () => {
{
key: '/literature/charting',
icon: <ApartmentOutlined />,
label: '5. SR 图表生成器',
label: '4. SR 图表生成器',
},
{
key: '/literature/meta-analysis',
icon: <BarChartOutlined />,
label: '6. Meta 分析引擎',
label: '5. Meta 分析引擎',
},
];
@@ -122,7 +99,6 @@ const ASLLayout = () => {
// 根据当前路径确定展开的菜单
const getOpenKeys = () => {
if (currentPath.includes('screening/title')) return ['title-screening'];
if (currentPath.includes('screening/fulltext')) return ['fulltext-screening'];
if (currentPath.includes('/extraction')) return ['extraction'];
if (currentPath.includes('/charting')) return [];
if (currentPath.includes('/meta-analysis')) return [];

View File

@@ -4,8 +4,9 @@
*/
import { Suspense, lazy } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { Spin } from 'antd';
import { Routes, Route, Navigate, useNavigate } from 'react-router-dom';
import { Spin, Result, Button } from 'antd';
import { SettingOutlined } from '@ant-design/icons';
// 懒加载组件
const ASLLayout = lazy(() => import('./components/ASLLayout'));
@@ -36,6 +37,22 @@ const SRChartGenerator = lazy(() => import('./pages/SRChartGenerator'));
// 工具 5Meta 分析引擎
const MetaAnalysisEngine = lazy(() => import('./pages/MetaAnalysisEngine'));
const ExtractionWorkbenchGuide = () => {
const nav = useNavigate();
return (
<Result
icon={<SettingOutlined style={{ color: '#1890ff' }} />}
title="请先配置提取任务"
subTitle="审核工作台需要在「配置与启动」中创建并完成提取任务后才可使用。请先前往配置页面上传文献并启动提取。"
extra={
<Button type="primary" onClick={() => nav('/literature/extraction/setup')}>
</Button>
}
/>
);
};
const ASLModule = () => {
return (
<Suspense
@@ -77,6 +94,7 @@ const ASLModule = () => {
<Route index element={<Navigate to="setup" replace />} />
<Route path="setup" element={<ExtractionSetup />} />
<Route path="progress/:taskId" element={<ExtractionProgress />} />
<Route path="workbench" element={<ExtractionWorkbenchGuide />} />
<Route path="workbench/:taskId" element={<ExtractionWorkbench />} />
</Route>

View File

@@ -51,10 +51,9 @@ const LegacySystemPage: React.FC<LegacySystemPageProps> = ({ targetUrl }) => {
authDoneRef.current = true
setStatus('ready')
} catch (err: any) {
const msg = err?.response?.data?.message || err?.message || '服务连接失败,请稍后重试'
const msg = err?.response?.data?.message || '网络连接问题,请点击下方按钮重试'
setErrorMsg(msg)
setStatus('error')
message.error(msg)
}
}, [targetUrl])