feat: stabilize document type and upload flows
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useSearchParams, useLoaderData } from "@remix-run/react";
|
||||
import { useNavigate, useLoaderData } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
|
||||
import { Card } from "~/components/ui/Card";
|
||||
import { Button } from "~/components/ui/Button";
|
||||
@@ -64,9 +64,28 @@ export default function DocumentTypeNew() {
|
||||
const [description, setDescription] = useState("");
|
||||
const [entryModuleId, setEntryModuleId] = useState<number | null>(null);
|
||||
const [selectedRuleSetIds, setSelectedRuleSetIds] = useState<number[]>([]);
|
||||
const [ruleSetKeyword, setRuleSetKeyword] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const selectedModule = loaderData.entryModules.find((m) => m.id === entryModuleId);
|
||||
const selectedRuleSets = loaderData.ruleSets.filter((rs) => selectedRuleSetIds.includes(rs.id));
|
||||
const selectedUnavailableRuleSets = selectedRuleSets.filter((rs) => !rs.hasUsableVersion);
|
||||
const normalizedRuleSetKeyword = ruleSetKeyword.trim().toLowerCase();
|
||||
const filteredRuleSets = loaderData.ruleSets.filter((rs) => {
|
||||
if (!normalizedRuleSetKeyword) return true;
|
||||
return [
|
||||
rs.ruleName,
|
||||
rs.ruleType,
|
||||
rs.status,
|
||||
String(rs.id),
|
||||
rs.currentVersionId ? String(rs.currentVersionId) : "",
|
||||
rs.fallbackVersionId ? String(rs.fallbackVersionId) : "",
|
||||
].some((value) => value.toLowerCase().includes(normalizedRuleSetKeyword));
|
||||
});
|
||||
const completionCount = [!!code.trim(), !!name.trim(), entryModuleId !== null, selectedRuleSetIds.length > 0]
|
||||
.filter(Boolean).length;
|
||||
|
||||
useEffect(() => {
|
||||
if (editType) {
|
||||
setCode(editType.code || "");
|
||||
@@ -82,6 +101,9 @@ export default function DocumentTypeNew() {
|
||||
if (!code.trim()) errs.code = "编码不能为空";
|
||||
else if (!/^[a-zA-Z][a-zA-Z0-9_.]*$/.test(code.trim())) errs.code = "编码格式:字母开头,可含字母数字._";
|
||||
if (!name.trim()) errs.name = "名称不能为空";
|
||||
if (selectedUnavailableRuleSets.length > 0) {
|
||||
errs.ruleSetIds = "已选择的规则集中包含不可用于上传评查的项,请先发布/回滚可用版本";
|
||||
}
|
||||
setErrors(errs);
|
||||
return Object.keys(errs).length === 0;
|
||||
};
|
||||
@@ -131,99 +153,354 @@ export default function DocumentTypeNew() {
|
||||
return (
|
||||
<div className="document-type-new-page">
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">
|
||||
<i className="ri-file-list-3-line"></i>
|
||||
{isEdit ? `编辑文档类型 — ${editType?.code}` : "新建文档类型"}
|
||||
</h2>
|
||||
<div className="page-heading">
|
||||
<span className="page-kicker">{isEdit ? "文档类型编辑" : "文档类型配置"}</span>
|
||||
<h2 className="page-title">
|
||||
<i className="ri-file-list-3-line"></i>
|
||||
{isEdit ? `编辑文档类型 — ${editType?.code}` : "新建文档类型"}
|
||||
</h2>
|
||||
<p className="page-subtitle">
|
||||
为上传入口绑定清晰的文档语义、规则集和处理流向,减少后续抽取与评查配置分散的问题。
|
||||
</p>
|
||||
</div>
|
||||
<div className="header-overview">
|
||||
<div className="header-badges">
|
||||
<span className={`status-pill ${isEdit ? "edit" : "create"}`}>
|
||||
<i className={isEdit ? "ri-edit-2-line" : "ri-magic-line"}></i>
|
||||
{isEdit ? "编辑模式" : "创建模式"}
|
||||
</span>
|
||||
{entryModuleId ? (
|
||||
<span className="status-pill linked">
|
||||
<i className="ri-links-line"></i>
|
||||
已绑定入口模块
|
||||
</span>
|
||||
) : (
|
||||
<span className="status-pill muted">
|
||||
<i className="ri-focus-3-line"></i>
|
||||
尚未绑定入口模块
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="hero-metrics">
|
||||
<div className="hero-metric-card">
|
||||
<span className="hero-metric-label">配置完成度</span>
|
||||
<strong>{completionCount}/4</strong>
|
||||
<small>基础信息、入口、规则集逐步补齐</small>
|
||||
</div>
|
||||
<div className="hero-metric-card">
|
||||
<span className="hero-metric-label">已关联规则集</span>
|
||||
<strong>{selectedRuleSetIds.length} 个</strong>
|
||||
<small>{selectedRuleSetIds.length > 0 ? "已进入评查链路" : "建议按业务场景精确选择"}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<form className="doc-type-form" onSubmit={handleSubmit}>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="required">类型编码</label>
|
||||
<input
|
||||
type="text"
|
||||
className={`form-input ${errors.code ? "error" : ""}`}
|
||||
placeholder="如: contract.sale"
|
||||
value={code}
|
||||
onChange={(e) => { setCode(e.target.value); setErrors({ ...errors, code: "" }); }}
|
||||
disabled={isEdit}
|
||||
/>
|
||||
{errors.code && <span className="form-error">{errors.code}</span>}
|
||||
{isEdit && <span className="form-hint">编码创建后不可修改</span>}
|
||||
<div className="editor-shell">
|
||||
<Card className="editor-main-card">
|
||||
<form className="doc-type-form" onSubmit={handleSubmit}>
|
||||
<section className="form-section">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<span className="section-kicker">Step 01</span>
|
||||
<h3>基础标识</h3>
|
||||
</div>
|
||||
<p>先定义业务识别码与名称,确保后续抽取、归档、评查引用一致。</p>
|
||||
</div>
|
||||
<div className="section-intro-card">
|
||||
<div className="section-intro-item">
|
||||
<i className="ri-fingerprint-line"></i>
|
||||
<div>
|
||||
<strong>编码保持稳定</strong>
|
||||
<span>编码创建后建议长期复用,避免和展示文案耦合。</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="section-intro-item">
|
||||
<i className="ri-text"></i>
|
||||
<div>
|
||||
<strong>名称面向使用者</strong>
|
||||
<span>名称会直接出现在上传入口与评查流程,尽量让业务人员一眼看懂。</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row two-column">
|
||||
<div className="form-group">
|
||||
<label className="required">类型编码</label>
|
||||
<input
|
||||
type="text"
|
||||
className={`form-input ${errors.code ? "error" : ""}`}
|
||||
placeholder="如: contract.sale"
|
||||
value={code}
|
||||
onChange={(e) => { setCode(e.target.value); setErrors({ ...errors, code: "" }); }}
|
||||
disabled={isEdit}
|
||||
/>
|
||||
{errors.code && <span className="form-error">{errors.code}</span>}
|
||||
{!errors.code && (
|
||||
<span className="form-hint">{isEdit ? "编码创建后不可修改" : "建议使用业务域.场景名,便于接口与导航复用"}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="required">类型名称</label>
|
||||
<input
|
||||
type="text"
|
||||
className={`form-input ${errors.name ? "error" : ""}`}
|
||||
placeholder="如: 通用买卖合同"
|
||||
value={name}
|
||||
onChange={(e) => { setName(e.target.value); setErrors({ ...errors, name: "" }); }}
|
||||
/>
|
||||
{errors.name && <span className="form-error">{errors.name}</span>}
|
||||
{!errors.name && <span className="form-hint">名称会直接出现在上传入口、文档详情和评查流程里</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>描述</label>
|
||||
<textarea
|
||||
className="form-textarea"
|
||||
placeholder="补充这个文档类型的适用业务场景、上传说明、抽取注意事项"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
<span className="form-hint">建议写清这个类型的用途,方便后续运营和规则维护人员理解。</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="form-section">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<span className="section-kicker">Step 02</span>
|
||||
<h3>入口与流程绑定</h3>
|
||||
</div>
|
||||
<p>把文档类型挂到正确入口,让前端导航、上传动作和后端处理链条保持一致。</p>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>入口模块</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={entryModuleId ?? ""}
|
||||
onChange={(e) => setEntryModuleId(e.target.value ? parseInt(e.target.value) : null)}
|
||||
>
|
||||
<option value="">不关联</option>
|
||||
{loaderData.entryModules.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="form-hint">如果绑定入口模块,用户会在对应入口中更自然地发现该文档类型。</span>
|
||||
</div>
|
||||
|
||||
<div className="binding-preview">
|
||||
<div className="binding-preview-label">当前路由预览</div>
|
||||
<div className="binding-preview-value">
|
||||
<i className="ri-route-line"></i>
|
||||
<span>{selectedModule?.name || "未绑定入口模块,上传入口不会主动露出此类型"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="form-section">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<span className="section-kicker">Step 03</span>
|
||||
<h3>关联规则集</h3>
|
||||
</div>
|
||||
<p>规则集会直接影响上传后的评查范围,建议按业务场景精确绑定,不要泛滥勾选。</p>
|
||||
</div>
|
||||
|
||||
<div className="rule-set-toolbar">
|
||||
<div className="rule-set-toolbar-main">
|
||||
<span className="rule-set-counter">
|
||||
<i className="ri-checkbox-circle-line"></i>
|
||||
已选择 {selectedRuleSetIds.length} / {loaderData.ruleSets.length}
|
||||
</span>
|
||||
<span className="form-hint">勾选后,该文档类型上传时会自动加载对应规则集执行评查</span>
|
||||
</div>
|
||||
<div className="rule-set-search">
|
||||
<i className="ri-search-line"></i>
|
||||
<input
|
||||
type="text"
|
||||
value={ruleSetKeyword}
|
||||
onChange={(e) => setRuleSetKeyword(e.target.value)}
|
||||
placeholder="搜索规则集名称、类型、ID、版本"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{selectedUnavailableRuleSets.length > 0 && (
|
||||
<div className="rule-set-warning">
|
||||
<i className="ri-error-warning-line"></i>
|
||||
<div>
|
||||
<strong>当前选择里存在不可执行的规则集</strong>
|
||||
<span>
|
||||
{selectedUnavailableRuleSets.map((item) => item.ruleName).join("、")}
|
||||
{" "}尚未绑定可用版本;若直接上传,后端会拒绝发起评查。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rule-set-checklist">
|
||||
{loaderData.ruleSets.length === 0 ? (
|
||||
<div className="empty-rule-set">
|
||||
<i className="ri-inbox-archive-line"></i>
|
||||
<p>暂无可选规则集</p>
|
||||
<span>请先在规则管理中创建可用于文档评查的规则集。</span>
|
||||
</div>
|
||||
) : filteredRuleSets.length === 0 ? (
|
||||
<div className="empty-rule-set compact">
|
||||
<i className="ri-search-eye-line"></i>
|
||||
<p>没有匹配的规则集</p>
|
||||
<span>请尝试更换关键词,或清空搜索后重新选择。</span>
|
||||
</div>
|
||||
) : (
|
||||
filteredRuleSets.map((rs) => (
|
||||
<label
|
||||
key={rs.id}
|
||||
className={`rule-set-item ${selectedRuleSetIds.includes(rs.id) ? "checked" : ""} ${rs.hasUsableVersion ? "" : "unavailable"}`}
|
||||
>
|
||||
<div className="rule-set-checkbox-wrap">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRuleSetIds.includes(rs.id)}
|
||||
onChange={() => toggleRuleSet(rs.id)}
|
||||
/>
|
||||
</div>
|
||||
<div className="rule-set-content">
|
||||
<div className="rule-set-topline">
|
||||
<span className="rule-set-name">{rs.ruleName}</span>
|
||||
<span className={`rule-set-status ${rs.status}`}>{rs.status}</span>
|
||||
</div>
|
||||
<div className="rule-set-meta">
|
||||
<span className="rule-set-type">{rs.ruleType}</span>
|
||||
<span className="rule-set-id">规则集 ID #{rs.id}</span>
|
||||
<span className={`rule-set-version-badge ${rs.hasUsableVersion ? "ok" : "missing"}`}>
|
||||
{rs.hasUsableVersion
|
||||
? `可用版本 ${rs.currentVersionId || rs.fallbackVersionId}`
|
||||
: "无可用版本"}
|
||||
</span>
|
||||
</div>
|
||||
{!rs.hasUsableVersion && (
|
||||
<div className="rule-set-inline-warning">
|
||||
<i className="ri-alarm-warning-line"></i>
|
||||
<span>该规则集当前没有已发布/可回退版本,绑定后仍无法执行上传评查。</span>
|
||||
</div>
|
||||
)}
|
||||
{rs.hasUsableVersion && !rs.currentVersionId && rs.fallbackVersionId && (
|
||||
<div className="rule-set-inline-warning soft">
|
||||
<i className="ri-information-line"></i>
|
||||
<span>当前会回退使用最近可用版本 #{rs.fallbackVersionId},建议在规则管理中补齐当前版本指针。</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="form-actions">
|
||||
<Button type="default" onClick={() => navigate("/document-types")} disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" onClick={handleSubmit} disabled={saving}>
|
||||
{saving ? "保存中..." : isEdit ? "保存修改" : "创建"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="required">类型名称</label>
|
||||
<input
|
||||
type="text"
|
||||
className={`form-input ${errors.name ? "error" : ""}`}
|
||||
placeholder="如: 通用买卖合同"
|
||||
value={name}
|
||||
onChange={(e) => { setName(e.target.value); setErrors({ ...errors, name: "" }); }}
|
||||
/>
|
||||
{errors.name && <span className="form-error">{errors.name}</span>}
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<aside className="editor-sidebar">
|
||||
<Card className="summary-card">
|
||||
<div className="summary-card-header">
|
||||
<span className="summary-kicker">实时概览</span>
|
||||
<h3>配置概览</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="summary-grid">
|
||||
<div className="summary-item">
|
||||
<span className="summary-label">当前模式</span>
|
||||
<strong>{isEdit ? "编辑既有类型" : "新建类型"}</strong>
|
||||
</div>
|
||||
<div className="summary-item">
|
||||
<span className="summary-label">类型编码</span>
|
||||
<strong>{code.trim() || "待填写"}</strong>
|
||||
</div>
|
||||
<div className="summary-item">
|
||||
<span className="summary-label">类型名称</span>
|
||||
<strong>{name.trim() || "待填写"}</strong>
|
||||
</div>
|
||||
<div className="summary-item">
|
||||
<span className="summary-label">入口模块</span>
|
||||
<strong>{selectedModule?.name || "未绑定"}</strong>
|
||||
</div>
|
||||
<div className="summary-item full">
|
||||
<span className="summary-label">已关联规则集</span>
|
||||
<strong>{selectedRuleSetIds.length} 个</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="summary-progress">
|
||||
<div className="summary-progress-topline">
|
||||
<span>表单完成度</span>
|
||||
<strong>{completionCount * 25}%</strong>
|
||||
</div>
|
||||
<div className="summary-progress-bar">
|
||||
<span style={{ width: `${completionCount * 25}%` }}></span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="form-group">
|
||||
<label>描述</label>
|
||||
<textarea
|
||||
className="form-textarea"
|
||||
placeholder="文档类型描述"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>入口模块</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={entryModuleId ?? ""}
|
||||
onChange={(e) => setEntryModuleId(e.target.value ? parseInt(e.target.value) : null)}
|
||||
>
|
||||
<option value="">不关联</option>
|
||||
{loaderData.entryModules.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>关联规则集</label>
|
||||
<span className="form-hint">勾选后,该文档类型上传时会自动加载对应规则集执行评查</span>
|
||||
<div className="rule-set-checklist">
|
||||
{loaderData.ruleSets.length === 0 ? (
|
||||
<p className="form-hint">暂无可选规则集</p>
|
||||
) : (
|
||||
loaderData.ruleSets.map((rs) => (
|
||||
<label key={rs.id} className={`rule-set-item ${selectedRuleSetIds.includes(rs.id) ? "checked" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRuleSetIds.includes(rs.id)}
|
||||
onChange={() => toggleRuleSet(rs.id)}
|
||||
/>
|
||||
<span className="rule-set-name">{rs.ruleName}</span>
|
||||
<span className="rule-set-type">{rs.ruleType}</span>
|
||||
<span className={`rule-set-status ${rs.status}`}>{rs.status}</span>
|
||||
</label>
|
||||
))
|
||||
<Card className="selection-card">
|
||||
<div className="summary-card-header">
|
||||
<span className="summary-kicker">即时结果</span>
|
||||
<h3>绑定结果预览</h3>
|
||||
</div>
|
||||
<div className="selection-stack">
|
||||
<div className="selection-item">
|
||||
<span className="selection-label">入口归属</span>
|
||||
<strong>{selectedModule?.name || "暂未指定"}</strong>
|
||||
</div>
|
||||
<div className="selection-item">
|
||||
<span className="selection-label">规则集清单</span>
|
||||
{selectedRuleSets.length > 0 ? (
|
||||
<div className="selection-tags">
|
||||
{selectedRuleSets.slice(0, 4).map((ruleSet) => (
|
||||
<span
|
||||
key={ruleSet.id}
|
||||
className={`selection-tag ${ruleSet.hasUsableVersion ? "" : "warning"}`}
|
||||
>
|
||||
{ruleSet.ruleName}
|
||||
</span>
|
||||
))}
|
||||
{selectedRuleSets.length > 4 && (
|
||||
<span className="selection-tag muted">+{selectedRuleSets.length - 4}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<strong>暂未选择</strong>
|
||||
)}
|
||||
</div>
|
||||
{selectedUnavailableRuleSets.length > 0 && (
|
||||
<div className="selection-item danger">
|
||||
<span className="selection-label">上传风险</span>
|
||||
<strong>当前绑定无法直接触发评查</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="form-actions">
|
||||
<Button type="default" onClick={() => navigate("/document-types")} disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" onClick={handleSubmit} disabled={saving}>
|
||||
{saving ? "保存中..." : isEdit ? "保存修改" : "创建"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
<Card className="guide-card">
|
||||
<div className="summary-card-header">
|
||||
<span className="summary-kicker">配置建议</span>
|
||||
<h3>建议做法</h3>
|
||||
</div>
|
||||
<ul className="guide-list">
|
||||
<li>编码保持稳定,不要混入显示文案,便于后端接口与权限配置长期复用。</li>
|
||||
<li>描述尽量写业务边界,例如“主合同”“补充协议”“付款附件”等,避免上传误选。</li>
|
||||
<li>规则集宁可精简,也不要把无关规则打包给所有文档类型,避免误报过多。</li>
|
||||
</ul>
|
||||
</Card>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+169
-38
@@ -11,6 +11,7 @@ import uploadStyles from "~/styles/pages/files_upload.css?url";
|
||||
import { messageService } from "~/components/ui/MessageModal";
|
||||
import { toastService } from "~/components/ui/Toast";
|
||||
import {
|
||||
buildUploadErrorDetails,
|
||||
getTodayDocuments,
|
||||
getDocumentTypes,
|
||||
getDocumentsStatus,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
checkDocumentDuplicate,
|
||||
type Document,
|
||||
type DocumentType,
|
||||
type UploadErrorDetails,
|
||||
type UploadResult,
|
||||
DocumentStatus
|
||||
} from "~/api/files/files-upload";
|
||||
@@ -119,6 +121,11 @@ export interface UploadedFile {
|
||||
};
|
||||
}
|
||||
|
||||
type UploadRequestError = Error & {
|
||||
status?: number;
|
||||
payload?: unknown;
|
||||
};
|
||||
|
||||
// 修改文件上传函数部分,解决类型问题
|
||||
async function handleFileUpload(
|
||||
binaryData: ArrayBuffer,
|
||||
@@ -147,7 +154,14 @@ async function handleFileUpload(
|
||||
if ("error" in response || !response.data) {
|
||||
const errMsg = "error" in response ? response.error : "上传响应为空";
|
||||
console.error("上传失败:", errMsg);
|
||||
throw new Error(errMsg || "上传失败");
|
||||
const uploadError = new Error(errMsg || "上传失败") as UploadRequestError;
|
||||
if ("status" in response) {
|
||||
uploadError.status = response.status;
|
||||
}
|
||||
if ("payload" in response) {
|
||||
uploadError.payload = response.payload;
|
||||
}
|
||||
throw uploadError;
|
||||
}
|
||||
|
||||
return response.data;
|
||||
@@ -369,41 +383,123 @@ export default function FilesUpload() {
|
||||
// 队列文件状态
|
||||
const [queueFiles, setQueueFiles] = useState<Document[]>([]);
|
||||
const [documentTypesState, setDocumentTypesState] = useState<DocumentType[]>([]);
|
||||
const [uploadErrorDetails, setUploadErrorDetails] = useState<UploadErrorDetails | null>(null);
|
||||
|
||||
// 全局队列状态(用于显示排队统计)
|
||||
const [globalQueueStatus, setGlobalQueueStatus] = useState<QueueStatus | null>(null);
|
||||
|
||||
const getSelectedDocumentType = () =>
|
||||
documentTypesState.find(type => type.id.toString() === fileType) ||
|
||||
loaderData.documentTypes.find(type => type.id.toString() === fileType) ||
|
||||
null;
|
||||
|
||||
const clearUploadErrorDetails = () => {
|
||||
setUploadErrorDetails(null);
|
||||
};
|
||||
|
||||
const showFriendlyUploadError = (
|
||||
error: unknown,
|
||||
options?: {
|
||||
titlePrefix?: string;
|
||||
resetAfterClose?: boolean;
|
||||
}
|
||||
) => {
|
||||
const uploadError = error instanceof Error ? (error as UploadRequestError) : null;
|
||||
const rawMessage = uploadError?.message || (typeof error === 'string' ? error : '未知错误');
|
||||
const details = buildUploadErrorDetails(rawMessage, {
|
||||
documentType: getSelectedDocumentType(),
|
||||
status: uploadError?.status,
|
||||
payload: (uploadError?.payload as Record<string, unknown> | null | undefined),
|
||||
});
|
||||
|
||||
setUploadErrorDetails(details);
|
||||
|
||||
const detailPreview = details.detailLines.slice(0, 2).join('\n');
|
||||
const actionPreview = details.actionLines[0] ? `\n\n建议:${details.actionLines[0]}` : '';
|
||||
const modalMessage = `${details.summary}${detailPreview ? `\n\n${detailPreview}` : ''}${actionPreview}`;
|
||||
|
||||
messageService.error(modalMessage, {
|
||||
title: options?.titlePrefix ? `${options.titlePrefix} - ${details.title}` : details.title,
|
||||
confirmText: '确定',
|
||||
cancelText: '',
|
||||
onConfirm: options?.resetAfterClose ? () => resetUpload() : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
// 在组件挂载时从 sessionStorage 获取 documentTypeIds
|
||||
useEffect(() => {
|
||||
try {
|
||||
// 在客户端环境中执行
|
||||
if (typeof window !== 'undefined') {
|
||||
const typeIdsStr = sessionStorage.getItem('documentTypeIds');
|
||||
const typeIds = typeIdsStr ? JSON.parse(typeIdsStr) : null;
|
||||
setDocumentTypeIds(typeIds);
|
||||
// 根据 documentTypeIds 过滤文档类型和文档列表
|
||||
filterDocumentTypes(typeIds, loaderData.documentTypes);
|
||||
filterDocuments(typeIds);
|
||||
let cancelled = false;
|
||||
|
||||
// 如果包含合同类型(ID=1),自动选择合同文档类型
|
||||
if (typeIds && typeIds.includes(1)) {
|
||||
setIsContractType(true);
|
||||
// 查找ID为1的合同文档类型
|
||||
const contractType = loaderData.documentTypes.find(type => type.id === 1);
|
||||
if (contractType) {
|
||||
setFileType(contractType.id.toString());
|
||||
// 清除可能存在的文件类型错误
|
||||
setFileTypeError(null);
|
||||
try {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const bootstrapUploadScope = async () => {
|
||||
const typeIdsStr = sessionStorage.getItem('documentTypeIds');
|
||||
const cachedTypeIds = typeIdsStr ? JSON.parse(typeIdsStr) : null;
|
||||
const selectedModuleIdStr = sessionStorage.getItem('selectedModuleId');
|
||||
const nextSelectedModuleId = selectedModuleIdStr ? Number(selectedModuleIdStr) : null;
|
||||
const normalizedModuleId = Number.isFinite(nextSelectedModuleId) && (nextSelectedModuleId || 0) > 0
|
||||
? nextSelectedModuleId
|
||||
: null;
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let scopedTypes = loaderData.documentTypes;
|
||||
let effectiveTypeIds = cachedTypeIds;
|
||||
|
||||
// 客户端可读取 sessionStorage,因此这里重新按入口模块拉一次,避免 SSR 阶段拿到全量类型后继续走旧缓存。
|
||||
if (normalizedModuleId) {
|
||||
const scopedTypesResponse = await getDocumentTypes(loaderData.frontendJWT || undefined);
|
||||
if (!cancelled && !scopedTypesResponse.error && scopedTypesResponse.data) {
|
||||
scopedTypes = scopedTypesResponse.data;
|
||||
effectiveTypeIds = scopedTypes.map(type => type.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDocumentTypeIds(effectiveTypeIds);
|
||||
filterDocumentTypes(effectiveTypeIds, scopedTypes, normalizedModuleId);
|
||||
await filterDocuments(effectiveTypeIds);
|
||||
|
||||
if (effectiveTypeIds && effectiveTypeIds.includes(1)) {
|
||||
setIsContractType(true);
|
||||
const contractType = scopedTypes.find(type => type.id === 1);
|
||||
if (contractType) {
|
||||
setFileType(contractType.id.toString());
|
||||
setFileTypeError(null);
|
||||
}
|
||||
} else {
|
||||
setIsContractType(false);
|
||||
}
|
||||
};
|
||||
|
||||
void bootstrapUploadScope().catch(error => {
|
||||
console.error('初始化上传页入口模块作用域失败:', error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取 sessionStorage 中的 documentTypeIds 失败:', error);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [loaderData]);
|
||||
|
||||
// 过滤文档类型列表
|
||||
const filterDocumentTypes = (documentTypeIds: number[] | null, types: DocumentType[]) => {
|
||||
const filterDocumentTypes = (documentTypeIds: number[] | null, types: DocumentType[], selectedModuleId?: number | null) => {
|
||||
if (selectedModuleId) {
|
||||
// 已通过 entry_module_id 从后端取过当前入口模块的文档类型,前端不再二次用旧缓存裁剪
|
||||
setDocumentTypesState(types);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!documentTypeIds || documentTypeIds.length === 0) {
|
||||
// 如果没有特定的 documentTypeIds,使用原始数据
|
||||
setDocumentTypesState(types);
|
||||
@@ -439,7 +535,7 @@ export default function FilesUpload() {
|
||||
setQueueFiles(loaderData.documents);
|
||||
return;
|
||||
}
|
||||
const documents = response.data || [];
|
||||
const documents = (response.data || []).filter(doc => documentTypeIds.includes(doc.type_id));
|
||||
console.log('过滤文档列表成功:', documents);
|
||||
setQueueFiles(documents);
|
||||
|
||||
@@ -591,11 +687,6 @@ export default function FilesUpload() {
|
||||
try {
|
||||
// console.log('开始检查队列状态,当前队列文件:', files);
|
||||
|
||||
// 直接从sessionStorage读取documentTypeIds,避免异步状态更新问题
|
||||
const typeIdsStr = typeof window !== 'undefined' ? sessionStorage.getItem('documentTypeIds') : null;
|
||||
const currentDocumentTypeIds = typeIdsStr ? JSON.parse(typeIdsStr) : null;
|
||||
// console.log('从sessionStorage读取的documentTypeIds:', currentDocumentTypeIds);
|
||||
|
||||
// 获取所有未完成的文档
|
||||
const incompleteFiles = files.filter(file =>
|
||||
file.status !== DocumentStatus.PROCESSED && file.id
|
||||
@@ -609,7 +700,6 @@ export default function FilesUpload() {
|
||||
let statusResponse;
|
||||
|
||||
// 如果是合同类型(ID=1),需要分类处理
|
||||
// console.log('当前documentTypeIds:', currentDocumentTypeIds);
|
||||
// if (currentDocumentTypeIds && currentDocumentTypeIds.includes(1)) {
|
||||
// // 分类文档ID
|
||||
// const mainDocumentIds: number[] = [];
|
||||
@@ -666,6 +756,7 @@ export default function FilesUpload() {
|
||||
// 处理文件选择
|
||||
const handleFilesSelected = (files: FileList) => {
|
||||
if (files.length > 0) {
|
||||
clearUploadErrorDetails();
|
||||
// 验证文件类型,支持PDF和Word文件
|
||||
const validFiles: File[] = [];
|
||||
let hasInvalidFiles = false;
|
||||
@@ -708,6 +799,7 @@ export default function FilesUpload() {
|
||||
// 处理文件类型变化
|
||||
const handleFileTypeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = e.target.value;
|
||||
clearUploadErrorDetails();
|
||||
// 确保只有选择了有效的文件类型才进行设置
|
||||
if (value) {
|
||||
// console.log('【调试-handleFileTypeChange】文件类型变更为:', value);
|
||||
@@ -1102,6 +1194,7 @@ export default function FilesUpload() {
|
||||
// 合同专用:首传即合并的上传链路
|
||||
const startContractUpload = async (mainFiles: File[], attachmentFiles: File[], templateFiles: File[] = []) => {
|
||||
try {
|
||||
clearUploadErrorDetails();
|
||||
// 只允许一个主文件
|
||||
const mainFile = mainFiles[0];
|
||||
|
||||
@@ -1220,7 +1313,6 @@ export default function FilesUpload() {
|
||||
await filterDocuments(documentTypeIds);
|
||||
} catch (error) {
|
||||
console.error('合同首传上传失败:', error);
|
||||
messageService.error(`合同上传失败:${error instanceof Error ? error.message : '未知错误'}`);
|
||||
if (uploadProgressIntervalRef.current) {
|
||||
clearInterval(uploadProgressIntervalRef.current);
|
||||
uploadProgressIntervalRef.current = null;
|
||||
@@ -1230,6 +1322,7 @@ export default function FilesUpload() {
|
||||
setContractTemplateFiles([]);
|
||||
console.log('【合同上传失败】已清空合同模板文件缓存');
|
||||
|
||||
showFriendlyUploadError(error, { titlePrefix: '合同上传失败' });
|
||||
resetUpload();
|
||||
}
|
||||
};
|
||||
@@ -1237,6 +1330,7 @@ export default function FilesUpload() {
|
||||
// 检查并准备上传
|
||||
const checkAndPrepareUpload = async (mainFiles: File[], attachmentFiles: File[], templateFiles: File[] = []) => {
|
||||
try {
|
||||
clearUploadErrorDetails();
|
||||
// console.log('【调试-checkAndPrepareUpload】开始检查并准备上传文件', {
|
||||
// mainFilesCount: mainFiles.length,
|
||||
// attachmentFilesCount: attachmentFiles.length,
|
||||
@@ -1362,6 +1456,7 @@ export default function FilesUpload() {
|
||||
// 开始上传文件
|
||||
const startUpload = async (files: File[]) => {
|
||||
try {
|
||||
clearUploadErrorDetails();
|
||||
console.log('【调试-startUpload】开始上传过程,文件数量:', files.length);
|
||||
|
||||
// 检查组件是否已卸载
|
||||
@@ -1630,15 +1725,7 @@ export default function FilesUpload() {
|
||||
clearInterval(uploadProgressIntervalRef.current);
|
||||
}
|
||||
|
||||
// 显示错误提示
|
||||
messageService.error(`文件上传失败:${error instanceof Error ? error.message : '未知错误'}`, {
|
||||
title: '文件上传失败',
|
||||
confirmText: '确定',
|
||||
cancelText: '',
|
||||
onConfirm: () => {
|
||||
resetUpload();
|
||||
}
|
||||
});
|
||||
showFriendlyUploadError(error, { titlePrefix: '文件上传失败' });
|
||||
resetUpload();
|
||||
|
||||
// 抛出错误,让React错误边界捕获并显示
|
||||
@@ -2330,6 +2417,50 @@ export default function FilesUpload() {
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{uploadErrorDetails && (
|
||||
<Card className="mb-4 upload-error-card">
|
||||
<div className="upload-error-banner">
|
||||
<div className="upload-error-banner__header">
|
||||
<div className="upload-error-banner__title-wrap">
|
||||
<i className="ri-error-warning-line upload-error-banner__icon"></i>
|
||||
<div>
|
||||
<div className="upload-error-banner__title">{uploadErrorDetails.title}</div>
|
||||
<p className="upload-error-banner__summary">{uploadErrorDetails.summary}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="upload-error-banner__dismiss"
|
||||
onClick={clearUploadErrorDetails}
|
||||
aria-label="关闭上传失败提示"
|
||||
>
|
||||
<i className="ri-close-line"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="upload-error-banner__content">
|
||||
<div>
|
||||
<div className="upload-error-banner__label">定位信息</div>
|
||||
<ul className="upload-error-banner__list">
|
||||
{uploadErrorDetails.detailLines.map((line, index) => (
|
||||
<li key={`detail-${index}`}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="upload-error-banner__label">建议处理</div>
|
||||
<ol className="upload-error-banner__list upload-error-banner__list--ordered">
|
||||
{uploadErrorDetails.actionLines.map((line, index) => (
|
||||
<li key={`action-${index}`}>{line}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 文件上传区域 */}
|
||||
<Card className="mb-4">
|
||||
|
||||
@@ -4,13 +4,13 @@ import { Card } from "~/components/ui/Card";
|
||||
import { Button } from "~/components/ui/Button";
|
||||
import { Modal } from "~/components/ui/Modal";
|
||||
import { toastService } from "~/components/ui/Toast";
|
||||
import { usePermission } from "~/hooks/usePermission";
|
||||
import {
|
||||
getRoles,
|
||||
getAllRoutes,
|
||||
getRoleRoutePermissions,
|
||||
updateRoleRoutePermissions,
|
||||
getRoleRoutesWithPermissions,
|
||||
saveRoleApiPermissions,
|
||||
saveRoleAccess,
|
||||
getRolePermissions,
|
||||
getRoleUsers,
|
||||
getUsersWithRoles,
|
||||
@@ -938,6 +938,7 @@ function AssignUserModal({ isOpen, onClose, onSuccess, role, isCityAdmin, curren
|
||||
|
||||
// 主组件
|
||||
export default function RolePermissions() {
|
||||
const { hasPermission, userRole, userArea } = usePermission();
|
||||
const [roles, setRoles] = useState<RoleInfo[]>([]);
|
||||
const [routes, setRoutes] = useState<RouteInfo[]>([]);
|
||||
const [users, setUsers] = useState<UserInfo[]>([]);
|
||||
@@ -946,9 +947,7 @@ export default function RolePermissions() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// v3.3: 检查当前用户角色和地区
|
||||
const [currentUserRole, setCurrentUserRole] = useState('');
|
||||
const [currentUserArea, setCurrentUserArea] = useState('');
|
||||
const [canEdit, setCanEdit] = useState(false);
|
||||
const [isCityAdmin, setIsCityAdmin] = useState(false);
|
||||
|
||||
// 模态框状态
|
||||
@@ -999,10 +998,23 @@ export default function RolePermissions() {
|
||||
// v3.8: 路由ID到路由信息的映射(用于显示通用权限关联的路由名称)
|
||||
const [routeIdToInfoMap, setRouteIdToInfoMap] = useState<Map<number, { title: string; path: string }>>(new Map());
|
||||
|
||||
const canCreateRole = hasPermission('rbac:roles:create');
|
||||
const canUpdateRole = hasPermission('rbac:roles:update');
|
||||
const canDeleteRole = hasPermission('rbac:roles:delete');
|
||||
const canAssignUsers = hasPermission('rbac:user_roles:write');
|
||||
const canSaveRoutePermissions = hasPermission('rbac:role_routes:write');
|
||||
const canSaveApiPermissions = hasPermission('rbac:role_permissions:write');
|
||||
const canSavePermissions = canSaveRoutePermissions && canSaveApiPermissions;
|
||||
|
||||
// 加载初始数据
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
}, [canAssignUsers, canCreateRole, canDeleteRole, canSaveApiPermissions, canSaveRoutePermissions, canUpdateRole, userArea, userRole]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentUserArea(userArea || '');
|
||||
setIsCityAdmin(userRole === 'admin');
|
||||
}, [userArea, userRole]);
|
||||
|
||||
// 删除确认倒计时
|
||||
useEffect(() => {
|
||||
@@ -1018,31 +1030,16 @@ export default function RolePermissions() {
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// v3.3: 检查当前用户角色和地区
|
||||
if (typeof window !== 'undefined') {
|
||||
const userInfoStr = localStorage.getItem('user_info');
|
||||
if (userInfoStr) {
|
||||
try {
|
||||
const userInfo = JSON.parse(userInfoStr);
|
||||
const userRole = userInfo.user_role || '';
|
||||
const userArea = userInfo.area || ''; // v3.3: 使用 area 字段进行地区隔离
|
||||
|
||||
setCurrentUserRole(userRole);
|
||||
setCurrentUserArea(userArea);
|
||||
setCanEdit((userRole === 'provincial_admin' || userRole === 'admin'));
|
||||
setIsCityAdmin(userRole === 'admin');
|
||||
|
||||
console.log('🔑 [RolePermissions v3.3] 当前用户信息:', {
|
||||
role: userRole,
|
||||
area: userArea,
|
||||
canEdit: (userRole === 'provincial_admin' || userRole === 'admin'),
|
||||
isCityAdmin: userRole === 'admin'
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('❌ [RolePermissions] 解析用户信息失败:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('🔑 [RolePermissions] 当前用户权限:', {
|
||||
role: userRole,
|
||||
area: userArea,
|
||||
canCreateRole,
|
||||
canUpdateRole,
|
||||
canDeleteRole,
|
||||
canAssignUsers,
|
||||
canSaveRoutePermissions,
|
||||
canSaveApiPermissions
|
||||
});
|
||||
|
||||
const [rolesData, routesData, usersData] = await Promise.all([
|
||||
getRoles(),
|
||||
@@ -1472,12 +1469,21 @@ export default function RolePermissions() {
|
||||
|
||||
// 编辑角色
|
||||
const handleEditRole = (role: RoleInfo) => {
|
||||
if (!canUpdateRole) {
|
||||
toastService.error('权限不足:当前账号不能编辑角色');
|
||||
return;
|
||||
}
|
||||
setRoleToEdit(role);
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
// 删除角色 - 显示确认Modal
|
||||
const handleDeleteRole = async (role: RoleInfo) => {
|
||||
if (!canDeleteRole) {
|
||||
toastService.error('权限不足:当前账号不能删除角色');
|
||||
return;
|
||||
}
|
||||
|
||||
// 系统角色禁止删除
|
||||
if (role.is_system_role) {
|
||||
toastService.error('系统角色不能删除');
|
||||
@@ -1538,6 +1544,10 @@ export default function RolePermissions() {
|
||||
// 移除用户角色 - 显示确认Modal
|
||||
const handleRemoveUserRole = (user: UserInfo) => {
|
||||
if (!selectedRole) return;
|
||||
if (!canAssignUsers) {
|
||||
toastService.error('权限不足:当前账号不能移除用户角色');
|
||||
return;
|
||||
}
|
||||
|
||||
// 打开确认删除Modal
|
||||
setDeleteTarget({ type: 'userRole', role: selectedRole, user });
|
||||
@@ -1570,66 +1580,41 @@ export default function RolePermissions() {
|
||||
}
|
||||
};
|
||||
|
||||
// 保存权限 - 省级管理员和地区管理员可操作
|
||||
// 保存权限:路由与 API 权限联合提交
|
||||
// v3.5: 增加事务性操作和回滚机制
|
||||
const handleSavePermissions = async () => {
|
||||
if (!selectedRole) return;
|
||||
|
||||
// 前置权限检查(省级管理员和地区管理员)
|
||||
if (!canEdit) {
|
||||
toastService.error('权限不足:仅省级管理员和地区管理员可以修改角色路由权限');
|
||||
// 菜单和 API 权限会联合保存,必须同时具备两个写权限
|
||||
if (!canSavePermissions) {
|
||||
toastService.error('权限不足:当前账号缺少菜单保存或接口权限保存能力');
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingPermissions(true);
|
||||
|
||||
// v3.5: 开始事务性操作,保存原始状态以便回滚
|
||||
const originalRouteIds = [...selectedRouteIds];
|
||||
const originalPermissionIds = [...selectedPermissionIds];
|
||||
|
||||
try {
|
||||
// 1. 保存路由权限
|
||||
const routeResult = await updateRoleRoutePermissions(selectedRole.id, selectedRouteIds);
|
||||
const scopedPermissionIds = originalAllPermissions.map(permission => permission.id);
|
||||
const result = await saveRoleAccess(
|
||||
selectedRole.id,
|
||||
selectedRouteIds,
|
||||
selectedPermissionIds,
|
||||
scopedPermissionIds
|
||||
);
|
||||
|
||||
// v3.3: 处理权限不足错误
|
||||
if (!routeResult.success) {
|
||||
if (routeResult.code === 4003) {
|
||||
toastService.error('权限不足:仅省级管理员和地区管理员可以修改角色路由权限');
|
||||
if (!result.success) {
|
||||
if (result.code === 4003) {
|
||||
toastService.error('权限不足:当前账号缺少菜单保存或接口权限保存能力');
|
||||
} else {
|
||||
toastService.error(routeResult.message);
|
||||
toastService.error(result.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// v3.5: 只有在路由权限保存成功后才保存API权限
|
||||
// 2. 保存API权限(如果有选中的权限)
|
||||
let permResult;
|
||||
if (selectedPermissionIds.length > 0) {
|
||||
permResult = await saveRoleApiPermissions(selectedRole.id, selectedPermissionIds);
|
||||
} else {
|
||||
// 没有选中API权限时,清空该角色的所有API权限
|
||||
permResult = await saveRoleApiPermissions(selectedRole.id, []);
|
||||
}
|
||||
|
||||
// v3.5: 处理API权限保存失败的情况
|
||||
if (!permResult.success) {
|
||||
console.error('API权限保存失败,正在回滚路由权限...');
|
||||
// 回滚路由权限到原始状态
|
||||
await updateRoleRoutePermissions(selectedRole.id, originalRouteIds);
|
||||
toastService.error('权限保存失败,已自动回滚到原始状态');
|
||||
// 恢复前端状态
|
||||
setSelectedRouteIds(originalRouteIds);
|
||||
setSelectedPermissionIds(originalPermissionIds);
|
||||
return;
|
||||
}
|
||||
|
||||
toastService.success(`路由权限:${routeResult.message} | API权限:${permResult.message}`);
|
||||
toastService.success(result.message);
|
||||
} catch (error) {
|
||||
console.error("保存权限失败:", error);
|
||||
toastService.error("保存权限失败,已自动回滚到原始状态");
|
||||
// 发生异常时回滚到原始状态
|
||||
setSelectedRouteIds(originalRouteIds);
|
||||
setSelectedPermissionIds(originalPermissionIds);
|
||||
toastService.error("保存权限失败");
|
||||
} finally {
|
||||
setSavingPermissions(false);
|
||||
}
|
||||
@@ -1742,7 +1727,7 @@ export default function RolePermissions() {
|
||||
checked={selectedPermissionIds.includes(permission.id)}
|
||||
onChange={(e) => handleTogglePermission(permission, e.target.checked)}
|
||||
style={{ margin: '3px 0 0 0', flexShrink: 0 }}
|
||||
disabled={!canEdit}
|
||||
disabled={!canSavePermissions}
|
||||
/>
|
||||
{isShared && (
|
||||
<span
|
||||
@@ -1832,7 +1817,7 @@ export default function RolePermissions() {
|
||||
}
|
||||
}}
|
||||
className="route-checkbox"
|
||||
disabled={!canEdit}
|
||||
disabled={!canSavePermissions}
|
||||
/>
|
||||
<label htmlFor={`route-${route.id}`} className="route-label">
|
||||
{route.icon && <i className={`${route.icon} route-icon`}></i>}
|
||||
@@ -1878,7 +1863,7 @@ export default function RolePermissions() {
|
||||
}
|
||||
}}
|
||||
className="route-checkbox"
|
||||
disabled={!canEdit}
|
||||
disabled={!canSavePermissions}
|
||||
/>
|
||||
<label htmlFor={`route-${route.id}`} className="route-label">
|
||||
{route.icon && <i className={`${route.icon} route-icon`}></i>}
|
||||
@@ -1964,7 +1949,7 @@ export default function RolePermissions() {
|
||||
type="primary"
|
||||
icon="ri-add-line"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
disabled={!canEdit}
|
||||
disabled={!canCreateRole}
|
||||
>
|
||||
新建角色
|
||||
</Button>
|
||||
@@ -2010,6 +1995,7 @@ export default function RolePermissions() {
|
||||
handleEditRole(role);
|
||||
}}
|
||||
title="编辑"
|
||||
disabled={!canUpdateRole}
|
||||
>
|
||||
<i className="ri-edit-line"></i>
|
||||
</button>
|
||||
@@ -2020,6 +2006,7 @@ export default function RolePermissions() {
|
||||
handleDeleteRole(role);
|
||||
}}
|
||||
title="删除"
|
||||
disabled={!canDeleteRole}
|
||||
>
|
||||
<i className="ri-delete-bin-line"></i>
|
||||
</button>
|
||||
@@ -2059,11 +2046,11 @@ export default function RolePermissions() {
|
||||
<div className="permissions-tab">
|
||||
{/* v3.8: 固定头部区域 */}
|
||||
<div className="permissions-tab-header">
|
||||
{/* 权限提示(省级管理员和地区管理员可修改) */}
|
||||
{!canEdit && (
|
||||
{/* 权限提示:当前账号缺少联合保存所需写权限时仅可查看 */}
|
||||
{!canSavePermissions && (
|
||||
<div className="form-notice warning" style={{ marginBottom: '12px' }}>
|
||||
<i className="ri-information-line"></i>
|
||||
<span>您当前为只读模式,仅省级管理员和地区管理员可以修改角色路由权限</span>
|
||||
<span>您当前为只读模式,缺少菜单保存或接口权限保存能力,无法提交本页改动</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="permissions-header">
|
||||
@@ -2098,7 +2085,7 @@ export default function RolePermissions() {
|
||||
type="primary"
|
||||
icon={savingPermissions ? "ri-loader-4-line spin" : "ri-save-line"}
|
||||
onClick={handleSavePermissions}
|
||||
disabled={!canEdit || savingPermissions}
|
||||
disabled={!canSavePermissions || savingPermissions}
|
||||
>
|
||||
{savingPermissions ? '保存中...' : '保存菜单与接口权限'}
|
||||
</Button>
|
||||
@@ -2162,6 +2149,7 @@ export default function RolePermissions() {
|
||||
type="primary"
|
||||
icon="ri-user-add-line"
|
||||
onClick={() => setShowAssignUserModal(true)}
|
||||
disabled={!canAssignUsers}
|
||||
>
|
||||
分配用户
|
||||
</Button>
|
||||
@@ -2214,6 +2202,7 @@ export default function RolePermissions() {
|
||||
className="btn-icon text-error"
|
||||
onClick={() => handleRemoveUserRole(user)}
|
||||
title="移除角色"
|
||||
disabled={!canAssignUsers}
|
||||
>
|
||||
<i className="ri-user-unfollow-line"></i>
|
||||
</button>
|
||||
@@ -2330,7 +2319,7 @@ export default function RolePermissions() {
|
||||
marginTop: '24px'
|
||||
}}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="default"
|
||||
onClick={() => {
|
||||
setShowDeleteConfirm(false);
|
||||
setDeleteTarget(null);
|
||||
@@ -2339,7 +2328,7 @@ export default function RolePermissions() {
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
type="danger"
|
||||
onClick={() => {
|
||||
if (deleteTarget?.type === 'role') {
|
||||
confirmDeleteRole();
|
||||
@@ -2378,7 +2367,7 @@ export default function RolePermissions() {
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '24px' }}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="default"
|
||||
onClick={() => {
|
||||
setShowPermissionWarning(false);
|
||||
setPendingRouteChange(null);
|
||||
@@ -2387,7 +2376,7 @@ export default function RolePermissions() {
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
type="danger"
|
||||
onClick={confirmRemovePermissionRoute}
|
||||
>
|
||||
确认取消勾选
|
||||
|
||||
Reference in New Issue
Block a user