411 lines
12 KiB
TypeScript
411 lines
12 KiB
TypeScript
import { useState, useEffect, useRef } from "react";
|
||
import { useNavigate, useSearchParams, useLoaderData, ClientLoaderFunctionArgs, MetaFunction } from "@remix-run/react";
|
||
import { Card } from "~/components/ui/Card";
|
||
import { Button } from "~/components/ui/Button";
|
||
import { toastService } from "~/components/ui/Toast";
|
||
import { usePermission } from "~/hooks/usePermission";
|
||
import {
|
||
getEntryModuleById,
|
||
createEntryModule,
|
||
updateEntryModule,
|
||
uploadEntryModuleImage,
|
||
type EntryModule,
|
||
type AreaConfig
|
||
} from "~/api/entry-modules/entry-modules";
|
||
import { DOCUMENT_URL } from "~/config/api-config";
|
||
import entryModulesStyles from "~/styles/pages/entry-modules.css?url";
|
||
|
||
// 引入CSS样式
|
||
export function links() {
|
||
return [
|
||
{ rel: "stylesheet", href: entryModulesStyles }
|
||
];
|
||
}
|
||
|
||
// 页面元数据
|
||
export const meta: MetaFunction = () => {
|
||
return [
|
||
{ title: "入口模块编辑 - 中国烟草AI合同及卷宗审核系统" },
|
||
{ name: "description", content: "创建或编辑入口模块" },
|
||
];
|
||
};
|
||
|
||
export const handle = {
|
||
breadcrumb: "新建/编辑入口模块",
|
||
previousRoute: {
|
||
title: "入口模块管理",
|
||
to: "/entry-modules"
|
||
}
|
||
};
|
||
|
||
// 定义加载器返回的数据类型
|
||
interface LoaderData {
|
||
module?: EntryModule;
|
||
error?: string;
|
||
}
|
||
|
||
// 🔑 客户端加载函数 - 在浏览器端执行,axios-client 会自动添加 JWT
|
||
export async function clientLoader({ request }: ClientLoaderFunctionArgs) {
|
||
try {
|
||
const url = new URL(request.url);
|
||
const id = url.searchParams.get('id');
|
||
|
||
if (id) {
|
||
// ✅ 不需要传递 JWT,axios-client 会自动处理
|
||
const moduleResponse = await getEntryModuleById(parseInt(id));
|
||
if (moduleResponse.error) {
|
||
throw new Error(moduleResponse.error);
|
||
}
|
||
return {
|
||
module: moduleResponse.data
|
||
};
|
||
}
|
||
|
||
return {};
|
||
} catch (error) {
|
||
console.error("加载入口模块失败:", error);
|
||
return {
|
||
error: error instanceof Error ? error.message : "加载入口模块失败"
|
||
};
|
||
}
|
||
}
|
||
|
||
// 地区选项
|
||
const AREA_OPTIONS = [
|
||
{ value: "梅州", label: "梅州" },
|
||
{ value: "云浮", label: "云浮" },
|
||
{ value: "揭阳", label: "揭阳" },
|
||
{ value: "潮州", label: "潮州" },
|
||
{ value: "省局", label: "省局" }
|
||
];
|
||
|
||
function resolveModuleLogoUrl(path?: string | null): string | null {
|
||
if (!path) {
|
||
return null;
|
||
}
|
||
|
||
if (path.startsWith("/images/") || path.startsWith("http://") || path.startsWith("https://")) {
|
||
return path;
|
||
}
|
||
|
||
return `${DOCUMENT_URL}${path}`;
|
||
}
|
||
|
||
// 入口模块新建/编辑组件
|
||
export default function EntryModuleNew() {
|
||
const navigate = useNavigate();
|
||
const [searchParams] = useSearchParams();
|
||
const { module, error } = useLoaderData<LoaderData>();
|
||
|
||
const id = searchParams.get('id');
|
||
const isEditMode = !!id;
|
||
|
||
// ✅ 使用权限 Hook
|
||
const { canCreate, canUpdate } = usePermission();
|
||
const canCreateModule = canCreate('entry_module');
|
||
const canUpdateModule = canUpdate('entry_module');
|
||
|
||
// ✅ 根据当前操作类型判断权限
|
||
const hasEditPermission = isEditMode ? canUpdateModule : canCreateModule;
|
||
const isReadOnly = !hasEditPermission;
|
||
|
||
// 表单状态
|
||
const [name, setName] = useState(module?.name || '');
|
||
const [description, setDescription] = useState(module?.description || '');
|
||
// 🔑 从 AreaConfig[] 提取地区名称数组
|
||
const [selectedAreas, setSelectedAreas] = useState<string[]>(
|
||
module?.areas ? module.areas.map(a => a.area) : []
|
||
);
|
||
const [logoFile, setLogoFile] = useState<File | null>(null);
|
||
const [logoPreview, setLogoPreview] = useState<string | null>(
|
||
resolveModuleLogoUrl(module?.path)
|
||
);
|
||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
// ✅ 页面加载时检查权限并提示(仅在只读模式下提示)
|
||
useEffect(() => {
|
||
if (isReadOnly) {
|
||
if (isEditMode) {
|
||
toastService.info('当前为查看模式,您没有编辑权限');
|
||
} else {
|
||
toastService.warning('您没有创建入口模块的权限');
|
||
}
|
||
}
|
||
}, [isReadOnly, isEditMode]);
|
||
|
||
// 处理loader加载数据的时候的错误
|
||
useEffect(() => {
|
||
if (error) {
|
||
toastService.error(error);
|
||
}
|
||
}, [error]);
|
||
|
||
// 处理logo文件选择
|
||
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (file) {
|
||
// 验证文件类型
|
||
if (!file.type.startsWith('image/')) {
|
||
toastService.error('请选择图片文件');
|
||
return;
|
||
}
|
||
|
||
// 验证文件大小(限制5MB)
|
||
if (file.size > 5 * 1024 * 1024) {
|
||
toastService.error('图片大小不能超过5MB');
|
||
return;
|
||
}
|
||
|
||
setLogoFile(file);
|
||
|
||
// 生成预览
|
||
const reader = new FileReader();
|
||
reader.onload = (event) => {
|
||
setLogoPreview(event.target?.result as string);
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
};
|
||
|
||
// 处理地区选择
|
||
const handleAreaToggle = (area: string) => {
|
||
setSelectedAreas(prev => {
|
||
if (prev.includes(area)) {
|
||
return prev.filter(a => a !== area);
|
||
} else {
|
||
return [...prev, area];
|
||
}
|
||
});
|
||
};
|
||
|
||
// 验证表单
|
||
const validateForm = () => {
|
||
if (!name.trim()) {
|
||
toastService.error('请输入模块名称');
|
||
return false;
|
||
}
|
||
|
||
if (selectedAreas.length === 0) {
|
||
toastService.error('请至少选择一个适用地区');
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
};
|
||
|
||
// 处理表单提交
|
||
const handleSubmit = async () => {
|
||
// ✅ Runtime permission check
|
||
if (isEditMode && !canUpdateModule) {
|
||
toastService.warning('您没有修改权限,无法保存更改');
|
||
return;
|
||
}
|
||
|
||
if (!isEditMode && !canCreateModule) {
|
||
toastService.warning('您没有创建权限,无法新增入口模块');
|
||
return;
|
||
}
|
||
|
||
if (!validateForm()) return;
|
||
|
||
setIsSubmitting(true);
|
||
|
||
try {
|
||
// 🔑 步骤1: 先创建或更新模块(不包含图片上传)
|
||
// areas 字段会在 API 层自动转换为 AreaConfig[] 格式
|
||
const moduleData = {
|
||
name: name.trim(),
|
||
description: description.trim() || undefined,
|
||
// path 字段在后端表示 route_path,这里编辑时必须保留原 route_path,不能误传 logo path。
|
||
path: isEditMode ? module?.route_path : null,
|
||
areas: selectedAreas // 字符串数组,API会自动转换
|
||
};
|
||
|
||
// ✅ 不需要传递 JWT,axios-client 会自动处理
|
||
let result;
|
||
let moduleId: number;
|
||
|
||
if (isEditMode) {
|
||
result = await updateEntryModule(parseInt(id!), moduleData);
|
||
moduleId = parseInt(id!);
|
||
} else {
|
||
result = await createEntryModule(moduleData);
|
||
moduleId = result.data!.id!;
|
||
}
|
||
|
||
if (result.error) {
|
||
toastService.error(result.error);
|
||
return;
|
||
}
|
||
|
||
// 🔑 步骤2: 如果有新的logo文件,调用专用的图片上传接口
|
||
// 后端会自动更新数据库中的 path 字段,无需再手动调用 update
|
||
if (logoFile) {
|
||
const uploadResult = await uploadEntryModuleImage(moduleId, logoFile);
|
||
|
||
if (uploadResult.error) {
|
||
toastService.error(`模块${isEditMode ? '更新' : '创建'}成功,但图片上传失败: ${uploadResult.error}`);
|
||
return;
|
||
}
|
||
|
||
console.log('✅ 图片上传成功,访问URL:', uploadResult.data?.url);
|
||
}
|
||
|
||
toastService.success(isEditMode ? '更新成功!' : '创建成功!');
|
||
setTimeout(() => {
|
||
navigate('/entry-modules');
|
||
}, 1000);
|
||
} catch (error) {
|
||
console.error('提交失败:', error);
|
||
toastService.error(error instanceof Error ? error.message : '操作失败,请重试');
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
// 处理取消
|
||
const handleCancel = () => {
|
||
navigate('/entry-modules');
|
||
};
|
||
|
||
|
||
return (
|
||
<div className="entry-modules-new-page">
|
||
<Card>
|
||
{/* 页面头部 */}
|
||
<div className="page-header">
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-gray-900">
|
||
{isEditMode ? (isReadOnly ? '查看入口模块' : '编辑入口模块') : '新建入口模块'}
|
||
</h1>
|
||
<p className="text-sm text-gray-600 mt-1">
|
||
{isEditMode ? (isReadOnly ? '查看入口模块信息' : '修改入口模块信息') : '创建新的入口模块'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 表单内容 */}
|
||
<div className="form-content space-y-6 mt-6">
|
||
{/* 模块名称 */}
|
||
<div className="form-item">
|
||
<label className="form-label">
|
||
<span className="text-red-500 mr-1">*</span>
|
||
模块名称
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
placeholder="请输入模块名称,如:合同管理"
|
||
maxLength={255}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||
disabled={isReadOnly}
|
||
/>
|
||
</div>
|
||
|
||
{/* 描述 */}
|
||
<div className="form-item">
|
||
<label className="form-label">描述</label>
|
||
<textarea
|
||
value={description}
|
||
onChange={(e) => setDescription(e.target.value)}
|
||
placeholder="请输入模块描述"
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||
rows={4}
|
||
disabled={isReadOnly}
|
||
/>
|
||
</div>
|
||
|
||
{/* Logo图片上传 */}
|
||
<div className="form-item">
|
||
<label className="form-label">Logo图片</label>
|
||
<div className="space-y-3">
|
||
<div className="flex items-center space-x-3">
|
||
<Button
|
||
type="default"
|
||
icon="ri-upload-line"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
disabled={isReadOnly}
|
||
>
|
||
{logoPreview ? '更换图片' : '上传图片'}
|
||
</Button>
|
||
<span className="text-sm text-gray-500">
|
||
支持 JPG、PNG、GIF 格式,大小不超过 5MB
|
||
</span>
|
||
</div>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
onChange={handleLogoChange}
|
||
className="hidden"
|
||
disabled={isReadOnly}
|
||
/>
|
||
{logoPreview && (
|
||
<div className="mt-3">
|
||
<div className="inline-block border border-gray-300 rounded p-2">
|
||
<img
|
||
src={logoPreview}
|
||
alt="Logo预览"
|
||
className="h-24 w-24 object-contain"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 适用地区 */}
|
||
<div className="form-item">
|
||
<label className="form-label">
|
||
<span className="text-red-500 mr-1">*</span>
|
||
适用地区
|
||
</label>
|
||
<div className="flex flex-wrap gap-3">
|
||
{AREA_OPTIONS.map(option => (
|
||
<label
|
||
key={option.value}
|
||
className={`flex items-center space-x-2 ${isReadOnly ? 'cursor-not-allowed' : 'cursor-pointer'}`}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedAreas.includes(option.value)}
|
||
onChange={() => handleAreaToggle(option.value)}
|
||
className="w-4 h-4 border-gray-300 rounded cursor-pointer"
|
||
disabled={isReadOnly}
|
||
/>
|
||
<span className="text-sm text-gray-700">{option.label}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 操作按钮 */}
|
||
<div className="form-actions mt-8 flex justify-end space-x-3">
|
||
<Button
|
||
type="default"
|
||
onClick={handleCancel}
|
||
disabled={isSubmitting}
|
||
>
|
||
取消
|
||
</Button>
|
||
{/* ✅ 仅在有对应权限时显示保存/创建按钮 */}
|
||
{hasEditPermission && (
|
||
<Button
|
||
type="primary"
|
||
onClick={handleSubmit}
|
||
loading={isSubmitting}
|
||
disabled={isSubmitting}
|
||
>
|
||
{isSubmitting ? '提交中...' : (isEditMode ? '保存' : '创建')}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|