From 277c54f34d8a97dbaf0688afdfc5f8a83e9cc587 Mon Sep 17 00:00:00 2001 From: Wren Date: Wed, 16 Jul 2025 19:01:13 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E7=89=88=E7=BC=96=E5=86=99=E4=BA=A4?= =?UTF-8?q?=E5=8F=89=E8=AF=84=E6=9F=A5=E9=A1=B5=E9=9D=A2=EF=BC=8C=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E9=9D=9E=E8=8C=83=E5=9B=B4=E6=97=A5=E6=9C=9F=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/components/ui/Cascader.tsx | 83 +++ app/components/ui/DateRangePicker.tsx | 124 ++++- app/routes/cross-checking.upload.tsx | 586 +++++++++++++++------ app/styles/pages/cross-checking-upload.css | 68 +++ 4 files changed, 687 insertions(+), 174 deletions(-) create mode 100644 app/components/ui/Cascader.tsx diff --git a/app/components/ui/Cascader.tsx b/app/components/ui/Cascader.tsx new file mode 100644 index 0000000..ce6e2d3 --- /dev/null +++ b/app/components/ui/Cascader.tsx @@ -0,0 +1,83 @@ +import React, { useState, useEffect, useRef } from 'react'; + +type Option = { + value: string; + label: string; + children?: Option[]; +}; + +type CascaderProps = { + options: Option[]; + defaultValue?: string[]; + onChange?: (value: string[]) => void; + placeholder?: string; +}; + +const Cascader: React.FC = ({ options, defaultValue = [], onChange, placeholder = 'Select' }) => { + const [visible, setVisible] = useState(false); + const [selected, setSelected] = useState(defaultValue); + const [menus, setMenus] = useState([options]); + const containerRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setVisible(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleItemClick = (option: Option, level: number) => { + const newSelected = selected.slice(0, level).concat(option.value); + setSelected(newSelected); + if (option.children) { + setMenus(menus.slice(0, level + 1).concat([option.children])); + } else { + setMenus(menus.slice(0, level + 1)); + setVisible(false); + onChange?.(newSelected); + } + }; + + const getDisplayText = () => { + if (selected.length === 0) return placeholder; + let currentOptions = options; + return selected.map(val => { + const opt = currentOptions.find(o => o.value === val); + currentOptions = opt?.children || []; + return opt?.label || ''; + }).join(' / '); + }; + + return ( +
+
setVisible(!visible)} + > + {getDisplayText()} +
+ {visible && ( +
+ {menus.map((menu, level) => ( +
    + {menu.map(option => ( +
  • handleItemClick(option, level)} + > + {option.label} +
  • + ))} +
+ ))} +
+ )} +
+ ); +}; + +export default Cascader; \ No newline at end of file diff --git a/app/components/ui/DateRangePicker.tsx b/app/components/ui/DateRangePicker.tsx index 400c7cb..05942c0 100644 --- a/app/components/ui/DateRangePicker.tsx +++ b/app/components/ui/DateRangePicker.tsx @@ -16,6 +16,17 @@ export interface DateRangePickerProps { colorMode?: 'light' | 'dark' | 'auto'; } +export interface SingleDatePickerProps { + date: string; + onDateChange: (value: string) => void; + label?: string; + className?: string; + id?: string; + format?: string; + placeholder?: string; + colorMode?: 'light' | 'dark' | 'auto'; +} + export function links() { return [ { rel: "stylesheet", href: dateRangePickerStyles } @@ -199,6 +210,117 @@ export function DateRangePicker({ ); } +/** + * 单日期选择器组件 + * 用于选择单个日期 + */ +export function SingleDatePicker({ + date, + onDateChange, + label = "日期", + className = "", + id = "date-single", + format = "yyyy-MM-dd", + placeholder = "请选择日期", + colorMode = 'auto' +}: SingleDatePickerProps) { + // 使用ref获取input元素 + const inputRef = useRef(null); + + // 添加状态跟踪日期输入框是否被聚焦 + const [isFocused, setIsFocused] = useState(false); + + // 格式化显示日期 + const formattedDate = date ? formatDate(date, format) : ""; + + // 保持原始日期输入框的显示格式 + useEffect(() => { + if (inputRef.current) { + inputRef.current.setAttribute('data-display-value', formattedDate || placeholder); + } + }, [formattedDate, placeholder]); + + // 处理日期输入框全局点击 + const handleWrapperClick = () => { + if (inputRef.current) { + // 点击整个包装器区域时,触发输入框点击 + inputRef.current.focus(); + inputRef.current.click(); + + try { + // 检查并使用showPicker API + const hasShowPicker = typeof inputRef.current.showPicker === 'function'; + + // 尝试使用showPicker API + if (hasShowPicker) { + inputRef.current.showPicker(); + } + } catch (error) { + console.error('Failed to show date picker:', error); + } + } + }; + + // 处理键盘事件 + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleWrapperClick(); + } + }; + + // 处理聚焦和失焦 + const handleFocus = () => { + setIsFocused(true); + }; + + const handleBlur = () => { + setIsFocused(false); + }; + + // 获取颜色模式类名 + const getColorModeClass = () => { + if (colorMode === 'light') return 'color-mode-light'; + if (colorMode === 'dark') return 'color-mode-dark'; + return ''; // auto模式不添加额外类名 + }; + + return ( +
+
+ +
{ + e.stopPropagation(); + handleWrapperClick(); + }} + onKeyDown={handleKeyDown} + tabIndex={0} + role="button" + aria-label={`选择${label}`} + > + onDateChange(e.target.value)} + onFocus={handleFocus} + onBlur={handleBlur} + /> +
+ {formattedDate || placeholder} +
+
+
+
+ ); +} + // 简化版日期范围选择器,适用于紧凑布局,不显示标签 export function SimpleDateRangePicker({ startDate, @@ -344,4 +466,4 @@ export function SimpleDateRangePicker({ ); -} \ No newline at end of file +} \ No newline at end of file diff --git a/app/routes/cross-checking.upload.tsx b/app/routes/cross-checking.upload.tsx index edbd255..7bf8de0 100644 --- a/app/routes/cross-checking.upload.tsx +++ b/app/routes/cross-checking.upload.tsx @@ -1,4 +1,4 @@ -import { useState, useRef } from "react"; +import { useState, useRef, useEffect } from "react"; import { type MetaFunction, type ActionFunctionArgs } from "@remix-run/node"; import { Form, useNavigation } from "@remix-run/react"; import { UploadArea, type UploadAreaRef } from "~/components/ui/UploadArea"; @@ -6,6 +6,8 @@ import { Button } from "~/components/ui/Button"; import { messageService } from "~/components/ui/MessageModal"; import { toastService } from "~/components/ui/Toast"; import crossCheckingUploadStyles from "~/styles/pages/cross-checking-upload.css?url"; +import MultiCascader from "~/components/ui/MultiCascader"; +import { SingleDatePicker, links as dateRangePickerLinks } from "~/components/ui/DateRangePicker"; import { CaseType, CASE_TYPE_TO_TYPE_ID, @@ -14,6 +16,7 @@ import { formatFileSize, batchUploadCrossCheckingFiles } from "~/api/cross-checking/cross-files-upload"; +import React from "react"; // Added for React.useState export const meta: MetaFunction = () => { return [ @@ -28,7 +31,8 @@ export const handle = { export function links() { return [ - { rel: "stylesheet", href: crossCheckingUploadStyles } + { rel: "stylesheet", href: crossCheckingUploadStyles }, + ...dateRangePickerLinks() ]; } @@ -39,6 +43,152 @@ const STEPS = [ { id: 3, label: "选择卷宗" } ]; +// 1. TreeNode类型和MOCK_TREE +export interface TreeNode { + label: string; + value: string; + children?: TreeNode[]; +} + +const MOCK_TREE: TreeNode[] = [ + { + label: "梅州市", + value: "梅州市", + children: [ + { + label: "梅江区", + value: "梅江区", + children: [ + { + label: "梅江区烟草局", + value: "梅江区烟草局", + children: [ + { label: "张三", value: "梅州市-梅江区-梅江区烟草局-张三" }, + { label: "李四", value: "梅州市-梅江区-梅江区烟草局-李四" } + ] + } + ] + } + ] + }, + { + label: "揭阳市", + value: "揭阳市", + children: [ + { + label: "榕城区", + value: "榕城区", + children: [ + { + label: "榕城区烟草局", + value: "榕城区烟草局", + children: [ + { label: "王五", value: "揭阳市-榕城区-榕城区烟草局-王五" } + ] + } + ] + } + ] + } +]; + +// 2. TreeMultiSelect递归组件 +function getAllLeafValues(node: TreeNode): string[] { + if (!node.children || node.children.length === 0) return [node.value]; + return node.children.flatMap(getAllLeafValues); +} +function getAllLeafValuesFromTree(tree: TreeNode[]): string[] { + return tree.flatMap(getAllLeafValues); +} +function isAllChildrenChecked(node: TreeNode, checked: string[]): boolean { + if (!node.children || node.children.length === 0) return checked.includes(node.value); + return node.children.every(child => isAllChildrenChecked(child, checked)); +} +function isSomeChildrenChecked(node: TreeNode, checked: string[]): boolean { + if (!node.children || node.children.length === 0) return checked.includes(node.value); + return node.children.some(child => isSomeChildrenChecked(child, checked)); +} +const TreeNodeCheckbox: React.FC<{ + node: TreeNode; + checked: string[]; + onCheck: (node: TreeNode, checked: boolean) => void; + level?: number; +}> = ({ node, checked, onCheck, level = 0 }) => { + const [expanded, setExpanded] = React.useState(true); + const allChecked = isAllChildrenChecked(node, checked); + const someChecked = isSomeChildrenChecked(node, checked); + const isLeaf = !node.children || node.children.length === 0; + return ( +
+
+ {!isLeaf && ( + setExpanded(e => !e)} + role="button" + tabIndex={0} + onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && setExpanded(e => !e)} + style={{ width: 16, display: "inline-block", textAlign: "center" }} + > + {expanded ? "▼" : "▶"} + + )} + { if (el) el.indeterminate = !allChecked && someChecked; }} + onChange={e => onCheck(node, e.target.checked)} + id={node.value} + /> + +
+ {expanded && node.children && ( +
+ {node.children.map(child => ( + + ))} +
+ )} +
+ ); +}; +const TreeMultiSelect: React.FC<{ + treeData: TreeNode[]; + value: string[]; + onChange: (v: string[]) => void; +}> = ({ treeData, value, onChange }) => { + // 递归处理选中/取消 + const handleCheck = (node: TreeNode, checked: boolean) => { + const leafValues = getAllLeafValues(node); + let newValue: string[]; + if (checked) { + newValue = Array.from(new Set([...value, ...leafValues])); + } else { + newValue = value.filter(v => !leafValues.includes(v)); + } + onChange(newValue); + }; + return ( +
+ {treeData.map(node => ( + + ))} +
+ ); +}; + export const action = async ({ request }: ActionFunctionArgs) => { const formData = await request.formData(); const caseType = formData.get("caseType") as string; @@ -54,8 +204,16 @@ export const action = async ({ request }: ActionFunctionArgs) => { export default function CrossCheckingUpload() { // 基础状态 const [caseType, setCaseType] = useState(CaseType.ADMINISTRATIVE_PENALTY); - const [currentStep] = useState(1); - const navigation = useNavigation(); + // 步骤状态 + const [currentStep, setCurrentStep] = useState(1); + // 步骤1:任务信息 + const [taskInfo, setTaskInfo] = useState({ + name: '', + date: '', + type: '市局交叉评查', + }); + // 步骤2状态 + const [groupChecked, setGroupChecked] = useState([]); // 上传配置状态 - 设置默认值 const [priority] = useState("normal"); @@ -299,8 +457,20 @@ export default function CrossCheckingUpload() { } }; + // 步骤切换 + const handleNext = () => setCurrentStep((s) => Math.min(s + 1, 3)); + const handlePrev = () => setCurrentStep((s) => Math.max(s - 1, 1)); + + // 步骤1表单校验 + const canNextStep1 = taskInfo.name.trim() && taskInfo.date.trim() && taskInfo.type.trim(); + // 小组多选逻辑 + useEffect(() => { + setGroupChecked(getAllLeafValuesFromTree(MOCK_TREE)); + }, []); + // 检查是否可以完成 const canComplete = (singleFiles.length > 0 || multipleFiles.length > 0) && !isUploading; + const navigation = useNavigation(); const isSubmitting = navigation.state === "submitting"; return ( @@ -309,13 +479,11 @@ export default function CrossCheckingUpload() { {/* 步骤指示器 */}
{STEPS.map((step) => ( -
-
- {step.id} -
+
{step.id}
{step.label}
))} @@ -343,179 +511,251 @@ export default function CrossCheckingUpload() {
- {/* 文件上传区域 */} -
- - - -
- {/* 单案件导入 */} -
-
- - 单案件导入 - {uploadType === 'single' && singleFiles.length > 0 && ( - - )} -
- - 请上传案件相关PDF文件 -
- } - disabled={uploadType === 'multiple' || isUploading} + {/* 步骤1:创建任务 */} + {currentStep === 1 && ( +
+
+ + setTaskInfo({ ...taskInfo, name: e.target.value })} + placeholder="请输入任务名称" /> - - {/* 单案件文件列表 */} - {singleFiles.length > 0 && ( -
-
- 已选择 {singleFiles.length} 个文件: -
-
- {singleFiles.map((file) => ( -
-
- - {file.name} - {formatFileSize(file.size)} -
- -
- ))} -
-
- )}
- - {/* 多案件导入 */} -
-
- - 多案件导入 - {uploadType === 'multiple' && multipleFiles.length > 0 && ( - - )} -
- - 请上传多个案件作为压缩包zip、rar、7z、tar文件 -
- } - disabled={uploadType === 'single' || isUploading} +
+ + setTaskInfo({ ...taskInfo, date: value })} + className="w-full" + id="task-date" + placeholder="请选择日期" /> - - {/* 多案件文件列表 */} - {multipleFiles.length > 0 && ( -
-
- 已选择 {multipleFiles.length} 个压缩包: -
-
- {multipleFiles.map((file) => ( -
-
- - {file.name} - {formatFileSize(file.size)} -
- -
- ))} -
-
- )}
-
- - {/* 完成按钮 */} -
- -
- - - {/* 文件选择状态提示 */} - {!canComplete && !isUploading && ( -
- 请至少选择一种导入方式的文件 +
+ + setTaskInfo({ ...taskInfo, type: e.target.value })} + placeholder="请输入任务类型" + /> +
+
+ +
)} - {/* 上传进度提示 */} - {isUploading && ( -
-
-
- - 正在上传文件... + {/* 步骤2:创建评查小组 */} + {currentStep === 2 && ( + <> +
+ {/* 左侧树状多选 */} +
+
+ + { + setGroupChecked(values); + }} + /> +
-

- 正在上传 {uploadType === 'single' ? singleFiles.length : multipleFiles.length} 个文件,请稍候 -

+ {/* 右侧预留区域 */} +
-
+
+ + +
+ + )} + + {/* 步骤3:原有上传区域 */} + {currentStep === 3 && ( + <> + {/* 文件上传区域 */} +
+ + + +
+ {/* 单案件导入 */} +
+
+ + 单案件导入 + {uploadType === 'single' && singleFiles.length > 0 && ( + + )} +
+ + 请上传案件相关PDF文件 +
+ } + disabled={uploadType === 'multiple' || isUploading} + /> + + {/* 单案件文件列表 */} + {singleFiles.length > 0 && ( +
+
+ 已选择 {singleFiles.length} 个文件: +
+
+ {singleFiles.map((file) => ( +
+
+ + {file.name} + {formatFileSize(file.size)} +
+ +
+ ))} +
+
+ )} +
+ + {/* 多案件导入 */} +
+
+ + 多案件导入 + {uploadType === 'multiple' && multipleFiles.length > 0 && ( + + )} +
+ + 请上传多个案件作为压缩包zip、rar、7z、tar文件 +
+ } + disabled={uploadType === 'single' || isUploading} + /> + + {/* 多案件文件列表 */} + {multipleFiles.length > 0 && ( +
+
+ 已选择 {multipleFiles.length} 个压缩包: +
+
+ {multipleFiles.map((file) => ( +
+
+ + {file.name} + {formatFileSize(file.size)} +
+ +
+ ))} +
+
+ )} +
+
+ + {/* 完成按钮 */} +
+ +
+ + + {/* 文件选择状态提示 */} + {!canComplete && !isUploading && ( +
+ 请至少选择一种导入方式的文件 +
+ )} + + {/* 上传进度提示 */} + {isUploading && ( +
+
+
+ + 正在上传文件... +
+

+ 正在上传 {uploadType === 'single' ? singleFiles.length : multipleFiles.length} 个文件,请稍候 +

+
+
+ )} + )}
diff --git a/app/styles/pages/cross-checking-upload.css b/app/styles/pages/cross-checking-upload.css index 1e06371..91e098d 100644 --- a/app/styles/pages/cross-checking-upload.css +++ b/app/styles/pages/cross-checking-upload.css @@ -272,4 +272,72 @@ left: 60px; width: calc(100% - 20px); } +} + +.form-input[type="date"]::-webkit-calendar-picker-indicator { + opacity: 0; + width: 0; + height: 0; +} +.form-input[type="date"] { + position: relative; + z-index: 1; +} + +.group-multiselect { + width: 100%; + max-width: 350px; + background: #fff; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0,0,0,0.04); + padding: 0.5rem 1.5rem 1rem 1.5rem; + margin-top: 0.5rem; +} +.group-select-header { + display: flex; + align-items: center; + font-size: 1rem; + font-weight: 500; + margin-bottom: 0.5rem; + cursor: pointer; + user-select: none; +} +.group-select-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} +.group-select-item { + display: flex; + align-items: center; + font-size: 1rem; + margin-left: 0.5rem; + user-select: none; +} +.group-select-item input[type="checkbox"] { + width: 20px; + height: 20px; + accent-color: #059669; + border-radius: 4px; + margin-right: 0.5rem; + border: 1.5px solid #d1d5db; + transition: border 0.2s; +} +.group-select-item input[type="checkbox"]:hover { + border: 1.5px solid #059669; +} +.group-select-item label { + font-size: 1rem; + color: #374151; + cursor: pointer; + margin-bottom: 0; +} +@media (max-width: 600px) { + .group-multiselect { + max-width: 100%; + padding: 0.5rem 0.5rem 1rem 0.5rem; + } + .group-select-item label { + font-size: 0.95rem; + } } \ No newline at end of file