Merge branch 'PingChuan' into shiy-login
# Conflicts: # app/config/api-config.ts fix: 1. 修复无法加载数据的问题:没有从入口页中进来会缺少数据。 2. 加强后端接口关于token的校验错误和权限校验错误的管理。 feat: 1. 对接后端的数据看板的接口。 2. 将系统设置单独抽出来作为管理员的固定一个入口。
This commit is contained in:
+85
-25
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, Form, useLoaderData } from '@remix-run/react';
|
||||
import { type MetaFunction, type ActionFunctionArgs, LoaderFunctionArgs, redirect } from "@remix-run/node";
|
||||
import { type MetaFunction, type ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
|
||||
import styles from "~/styles/pages/home.css?url";
|
||||
import dayjs from 'dayjs';
|
||||
import { getUserSession, logout } from "~/api/login/auth.server";
|
||||
@@ -49,8 +49,21 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
console.warn('⚠️ [Index Loader] 用户角色为空,返回空模块列表');
|
||||
}
|
||||
|
||||
// 返回用户信息和入口模块给客户端
|
||||
return Response.json({ userRole, userInfo, entryModules });
|
||||
// 🔑 检查用户是否有系统设置权限
|
||||
let hasSettingsAccess = false;
|
||||
if (userRole && frontendJWT) {
|
||||
const { getUserRoutesByRole } = await import('~/api/auth/user-routes');
|
||||
const routesResult = await getUserRoutesByRole(userRole, frontendJWT, true); // includeHidden=true
|
||||
|
||||
if (routesResult.success && routesResult.data) {
|
||||
// 检查是否存在顶级路由 '/settings'
|
||||
hasSettingsAccess = routesResult.data.some(route => route.path === '/settings');
|
||||
// console.log(`🔑 [Index Loader] 用户${hasSettingsAccess ? '有' : '没有'}系统设置权限`);
|
||||
}
|
||||
}
|
||||
|
||||
// 返回用户信息、入口模块和系统设置权限给客户端
|
||||
return Response.json({ userRole, userInfo, entryModules, hasSettingsAccess });
|
||||
}
|
||||
|
||||
export default function Index() {
|
||||
@@ -62,7 +75,7 @@ export default function Index() {
|
||||
});
|
||||
|
||||
// 检查是否通过51707端口访问
|
||||
const [isPort51707, setIsPort51707] = useState(false);
|
||||
// const [isPort51707, setIsPort51707] = useState(false);
|
||||
|
||||
// 用户信息:优先使用服务端返回的,否则从 localStorage 读取
|
||||
const [userInfo, setUserInfo] = useState(loaderData.userInfo);
|
||||
@@ -70,7 +83,7 @@ export default function Index() {
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setIsPort51707(window.location.port === '51707');
|
||||
// setIsPort51707(window.location.port === '51707');
|
||||
|
||||
// 如果服务端没有返回用户信息,从 localStorage 读取
|
||||
if (!loaderData.userInfo || !loaderData.userRole) {
|
||||
@@ -91,10 +104,21 @@ export default function Index() {
|
||||
|
||||
// 打印用户角色
|
||||
useEffect(() => {
|
||||
console.log('📋 [Index] 当前用户角色:', userRole);
|
||||
console.log('👤 [Index] 当前用户信息:', userInfo);
|
||||
// console.log('📋 [Index] 当前用户角色:', userRole);
|
||||
// console.log('👤 [Index] 当前用户信息:', userInfo);
|
||||
}, [userRole, userInfo]);
|
||||
|
||||
// 🔑 清除系统设置模式标志(当用户返回首页时)
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const settingsMode = sessionStorage.getItem('settingsMode');
|
||||
if (settingsMode === 'true') {
|
||||
sessionStorage.removeItem('settingsMode');
|
||||
console.log('🔄 [Index] 清除系统设置模式标志');
|
||||
}
|
||||
}
|
||||
}, []); // 只在组件挂载时执行一次
|
||||
|
||||
// 更新日期时间
|
||||
useEffect(() => {
|
||||
const updateDateTime = () => {
|
||||
@@ -200,6 +224,21 @@ export default function Index() {
|
||||
}
|
||||
};
|
||||
|
||||
// 处理进入系统设置
|
||||
const handleEnterSettings = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
// 🔑 设置标志:表示用户通过系统设置入口进入
|
||||
sessionStorage.setItem('settingsMode', 'true');
|
||||
// 清除模块相关的标志(因为不是从入口模块进入)
|
||||
sessionStorage.removeItem('selectedModuleId');
|
||||
sessionStorage.removeItem('selectedModuleName');
|
||||
sessionStorage.removeItem('selectedModulePicPath');
|
||||
}
|
||||
|
||||
// 跳转到系统设置的默认页面
|
||||
navigate('/rule-groups');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="home-page">
|
||||
{/* 登出表单 - 隐藏 */}
|
||||
@@ -250,24 +289,45 @@ export default function Index() {
|
||||
<div className="modules-container">
|
||||
{/* 动态渲染入口模块 */}
|
||||
{loaderData.entryModules && loaderData.entryModules.length > 0 ? (
|
||||
loaderData.entryModules.map((module) => (
|
||||
<div
|
||||
key={module.id}
|
||||
className="module-card"
|
||||
onClick={() => handleModuleClick(module)}
|
||||
onKeyDown={(e) => handleKeyDown(module, e)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={module.name}
|
||||
>
|
||||
<img
|
||||
src={getModuleIcon(module)}
|
||||
alt={module.name}
|
||||
className="w-12 h-12 mx-1"
|
||||
/>
|
||||
<span className="module-name">{module.name}</span>
|
||||
</div>
|
||||
))
|
||||
<>
|
||||
{loaderData.entryModules.map((module) => (
|
||||
<div
|
||||
key={module.id}
|
||||
className="module-card"
|
||||
onClick={() => handleModuleClick(module)}
|
||||
onKeyDown={(e) => handleKeyDown(module, e)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={module.name}
|
||||
>
|
||||
<img
|
||||
src={getModuleIcon(module)}
|
||||
alt={module.name}
|
||||
className="w-12 h-12 mx-1"
|
||||
/>
|
||||
<span className="module-name">{module.name}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 🔑 系统设置入口 - 只有有权限的用户才能看到 */}
|
||||
{loaderData.hasSettingsAccess && (
|
||||
<div
|
||||
className="module-card"
|
||||
onClick={handleEnterSettings}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
handleEnterSettings();
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="系统设置"
|
||||
>
|
||||
<i className="ri-settings-4-line text-5xl text-primary"></i>
|
||||
<span className="module-name">系统设置</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
暂无可用模块
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Collabora 配置生成 API 路由
|
||||
*
|
||||
* 功能:
|
||||
* - 生成 Collabora iframe URL
|
||||
* - 生成 WOPI access token
|
||||
* - 返回完整的 Collabora 配置
|
||||
*
|
||||
* @encoding UTF-8
|
||||
*/
|
||||
|
||||
import { type LoaderFunctionArgs, json } from '@remix-run/node';
|
||||
import { getUserSession } from '~/api/login/auth.server';
|
||||
import { generateCollaboraConfig } from '~/services/collabora.config.server';
|
||||
|
||||
/**
|
||||
* GET /api/collabora/config
|
||||
*
|
||||
* 查询参数:
|
||||
* - fileId: 文件路径(例如:contracts/test.docx)
|
||||
* - mode: 模式(view 或 edit),默认 view
|
||||
* - userId: 用户 ID(可选,从 session 获取)
|
||||
* - userName: 用户名(可选,从 session 获取)
|
||||
*/
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
try {
|
||||
// 获取用户会话信息和 frontendJWT
|
||||
const { userInfo, frontendJWT } = await getUserSession(request);
|
||||
|
||||
// 解析查询参数
|
||||
const url = new URL(request.url);
|
||||
const fileId = url.searchParams.get('fileId');
|
||||
const mode = (url.searchParams.get('mode') || 'view') as 'view' | 'edit';
|
||||
const userId = url.searchParams.get('userId') || userInfo?.sub || 'guest';
|
||||
const userName = url.searchParams.get('userName') || userInfo?.nick_name || '访客';
|
||||
|
||||
// 验证必需参数
|
||||
if (!fileId) {
|
||||
return json(
|
||||
{ error: '文件路径不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证 frontendJWT
|
||||
if (!frontendJWT) {
|
||||
return json(
|
||||
{ error: '用户未认证' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// 生成 Collabora 配置
|
||||
const config = await generateCollaboraConfig({
|
||||
fileId,
|
||||
mode,
|
||||
userId,
|
||||
userName,
|
||||
frontendJWT,
|
||||
});
|
||||
|
||||
return json(config);
|
||||
} catch (error) {
|
||||
console.error('生成 Collabora 配置失败:', error);
|
||||
return json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : '生成配置失败',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* WOPI 协议 API 路由(Splat路由,支持多级路径)
|
||||
*
|
||||
* 功能:
|
||||
* - CheckFileInfo: GET /api/collabora/wopi/files/{...fileId}
|
||||
* - GetFile: GET /api/collabora/wopi/files/{...fileId}/contents
|
||||
* - PutFile: POST /api/collabora/wopi/files/{...fileId}/contents
|
||||
*
|
||||
* 注意:使用splat路由($)匹配多级文件路径,如 documents/mz/合同文档/2025/test.docx
|
||||
*
|
||||
* @encoding UTF-8
|
||||
*/
|
||||
|
||||
import { type LoaderFunctionArgs, type ActionFunctionArgs } from '@remix-run/node';
|
||||
import { WopiService } from '../services/collabora.wopi.server';
|
||||
|
||||
const wopiService = new WopiService();
|
||||
|
||||
/**
|
||||
* GET 请求处理
|
||||
* - 无 /contents 后缀 → CheckFileInfo
|
||||
* - 有 /contents 后缀 → GetFile
|
||||
*/
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const accessToken = url.searchParams.get('access_token');
|
||||
|
||||
if (!accessToken) {
|
||||
return new Response('访问令牌缺失', { status: 401 });
|
||||
}
|
||||
|
||||
// 获取文件 ID(使用 splat 参数 '*')
|
||||
let fileId = params['*'] || '';
|
||||
|
||||
// 判断是否是 GetFile 请求(路径以 /contents 结尾)
|
||||
const isContentsRequest = url.pathname.endsWith('/contents');
|
||||
|
||||
// 如果是 GetFile 请求,需要移除路径末尾的 /contents
|
||||
if (isContentsRequest && fileId.endsWith('/contents')) {
|
||||
fileId = fileId.slice(0, -9); // 移除 '/contents'
|
||||
}
|
||||
|
||||
if (isContentsRequest) {
|
||||
// GetFile: 返回文件内容
|
||||
const { buffer, metadata } = await wopiService.getFile(fileId, accessToken);
|
||||
|
||||
return new Response(buffer, {
|
||||
headers: {
|
||||
'Content-Type': metadata.contentType,
|
||||
'Content-Length': metadata.size.toString(),
|
||||
'Content-Disposition': 'inline',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// CheckFileInfo: 返回文件元数据
|
||||
const checkFileInfo = await wopiService.checkFileInfo(fileId, accessToken);
|
||||
|
||||
// 注意:CheckFileInfo 必须返回纯 JSON,不能使用 Result.success() 包装
|
||||
return Response.json(checkFileInfo);
|
||||
} catch (error) {
|
||||
console.error('WOPI GET 失败:', error);
|
||||
return new Response(
|
||||
error instanceof Error ? error.message : 'Internal server error',
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST 请求处理
|
||||
* - PutFile: 保存文件内容
|
||||
*/
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const accessToken = url.searchParams.get('access_token');
|
||||
|
||||
if (!accessToken) {
|
||||
return new Response('访问令牌缺失', { status: 401 });
|
||||
}
|
||||
|
||||
// 获取文件 ID(使用 splat 参数 '*')
|
||||
let fileId = params['*'] || '';
|
||||
|
||||
// 判断是否是 PutFile 请求(路径以 /contents 结尾)
|
||||
const isContentsRequest = url.pathname.endsWith('/contents');
|
||||
|
||||
if (!isContentsRequest) {
|
||||
return new Response('PutFile 必须使用 /contents 路径', { status: 400 });
|
||||
}
|
||||
|
||||
// 移除路径末尾的 /contents
|
||||
if (fileId.endsWith('/contents')) {
|
||||
fileId = fileId.slice(0, -9);
|
||||
}
|
||||
|
||||
// PutFile: 保存文件
|
||||
const fileBuffer = await request.arrayBuffer();
|
||||
await wopiService.putFile(fileId, accessToken, fileBuffer);
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error('WOPI POST 失败:', error);
|
||||
return new Response(
|
||||
error instanceof Error ? error.message : 'Internal server error',
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* WOPI 协议 API 路由
|
||||
*
|
||||
* 功能:
|
||||
* - CheckFileInfo: GET /api/collabora/wopi/files/{fileId}
|
||||
* - GetFile: GET /api/collabora/wopi/files/{fileId}/contents
|
||||
* - PutFile: POST /api/collabora/wopi/files/{fileId}/contents
|
||||
*
|
||||
* @encoding UTF-8
|
||||
*/
|
||||
|
||||
import { type LoaderFunctionArgs, type ActionFunctionArgs } from '@remix-run/node';
|
||||
import { WopiService } from '../services/collabora.wopi.server';
|
||||
|
||||
const wopiService = new WopiService();
|
||||
|
||||
/**
|
||||
* GET 请求处理
|
||||
* - 无 /contents 后缀 → CheckFileInfo
|
||||
* - 有 /contents 后缀 → GetFile
|
||||
*/
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const accessToken = url.searchParams.get('access_token');
|
||||
|
||||
if (!accessToken) {
|
||||
return new Response('访问令牌缺失', { status: 401 });
|
||||
}
|
||||
|
||||
// 获取文件 ID
|
||||
const fileId = params.fileId || '';
|
||||
|
||||
// 判断是否是 GetFile 请求(路径以 /contents 结尾)
|
||||
const isContentsRequest = url.pathname.endsWith('/contents');
|
||||
|
||||
if (isContentsRequest) {
|
||||
// GetFile: 返回文件内容
|
||||
const { buffer, metadata } = await wopiService.getFile(fileId, accessToken);
|
||||
|
||||
return new Response(buffer, {
|
||||
headers: {
|
||||
'Content-Type': metadata.contentType,
|
||||
'Content-Length': metadata.size.toString(),
|
||||
'Content-Disposition': 'inline',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// CheckFileInfo: 返回文件元数据
|
||||
const checkFileInfo = await wopiService.checkFileInfo(fileId, accessToken);
|
||||
|
||||
// 注意:CheckFileInfo 必须返回纯 JSON,不能使用 Result.success() 包装
|
||||
return Response.json(checkFileInfo);
|
||||
} catch (error) {
|
||||
console.error('WOPI GET 失败:', error);
|
||||
return new Response(
|
||||
error instanceof Error ? error.message : 'Internal server error',
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST 请求处理
|
||||
* - PutFile: 保存文件内容
|
||||
*/
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const accessToken = url.searchParams.get('access_token');
|
||||
|
||||
if (!accessToken) {
|
||||
return new Response('访问令牌缺失', { status: 401 });
|
||||
}
|
||||
|
||||
const fileId = params.fileId || '';
|
||||
|
||||
// 判断是否是 PutFile 请求(路径以 /contents 结尾)
|
||||
const isContentsRequest = url.pathname.endsWith('/contents');
|
||||
|
||||
if (!isContentsRequest) {
|
||||
return new Response('PutFile 必须使用 /contents 路径', { status: 400 });
|
||||
}
|
||||
|
||||
// PutFile: 保存文件
|
||||
const fileBuffer = await request.arrayBuffer();
|
||||
await wopiService.putFile(fileId, accessToken, fileBuffer);
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error('WOPI POST 失败:', error);
|
||||
return new Response(
|
||||
error instanceof Error ? error.message : 'Internal server error',
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -145,8 +145,9 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
}
|
||||
console.log("✅ [Callback] 用户信息获取成功");
|
||||
|
||||
// 获取重定向URL
|
||||
const redirectTo = url.searchParams.get("redirect") || "/";
|
||||
// 🔑 强制重定向到首页,确保用户选择入口模块并初始化 sessionStorage
|
||||
// 忽略 redirect 参数,总是跳转到首页让用户选择模块
|
||||
const redirectTo = "/";
|
||||
|
||||
// 调用后端登录接口,传递 OAuth 用户信息,获取 JWT token
|
||||
const loginRequest: LoginRequest = {
|
||||
@@ -271,7 +272,8 @@ export default function Callback() {
|
||||
// 从 URL 参数中获取 token(如果有)
|
||||
const token = searchParams.get("token");
|
||||
const userInfo = searchParams.get("userInfo");
|
||||
const redirectTo = searchParams.get("redirectTo") || "/";
|
||||
// 🔑 强制重定向到首页,确保用户选择入口模块并初始化 sessionStorage
|
||||
const redirectTo = "/";
|
||||
|
||||
if (token && typeof window !== 'undefined') {
|
||||
console.log('🔑 [Callback] 开始保存 token 到 localStorage');
|
||||
|
||||
@@ -150,11 +150,13 @@ export default function ContractTemplateDetail() {
|
||||
}; */
|
||||
|
||||
// 创建文件内容对象用于FilePreview组件
|
||||
const fileContent = template.pdf_file_path ? {
|
||||
// 优先使用原始文件路径(支持docx),如果没有则使用pdf_file_path
|
||||
const previewPath = template.file_path || template.pdf_file_path;
|
||||
const fileContent = previewPath ? {
|
||||
title: template.title,
|
||||
contractNumber: template.template_code,
|
||||
// 使用pdf_file_path字段
|
||||
path: template.pdf_file_path,
|
||||
// 使用file_path以支持多种格式(docx/pdf)
|
||||
path: previewPath,
|
||||
parties: {
|
||||
partyA: {
|
||||
name: '',
|
||||
|
||||
@@ -31,6 +31,11 @@ export const meta: MetaFunction = () => {
|
||||
];
|
||||
};
|
||||
|
||||
// 面包屑配置
|
||||
export const handle = {
|
||||
breadcrumb: "入口模块管理"
|
||||
};
|
||||
|
||||
|
||||
// 定义加载器返回的数据类型
|
||||
interface LoaderData {
|
||||
|
||||
@@ -22,7 +22,11 @@ export const meta: MetaFunction = () => {
|
||||
};
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: "新建/编辑入口模块"
|
||||
breadcrumb: "新建/编辑入口模块",
|
||||
previousRoute: {
|
||||
title: "入口模块管理",
|
||||
to: "/entry-modules"
|
||||
}
|
||||
};
|
||||
|
||||
// 定义加载器返回的数据类型
|
||||
|
||||
@@ -79,7 +79,8 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
const formData = await request.formData();
|
||||
const username = formData.get("username") as string;
|
||||
const password = formData.get("password") as string;
|
||||
const redirectTo = formData.get("redirectTo") as string || "/";
|
||||
// 🔑 强制重定向到首页,确保用户选择入口模块并初始化 sessionStorage
|
||||
const redirectTo = "/";
|
||||
|
||||
// 验证输入
|
||||
if (!username?.trim()) {
|
||||
|
||||
Reference in New Issue
Block a user