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/components/ui/MultiCascader.tsx b/app/components/ui/MultiCascader.tsx new file mode 100644 index 0000000..9d57164 --- /dev/null +++ b/app/components/ui/MultiCascader.tsx @@ -0,0 +1,183 @@ +import React, { useState, useEffect, useRef } from 'react'; + +type Option = { + value: string; + label: string; + children?: Option[]; +}; + +type MultiCascaderProps = { + options: Option[]; + defaultValue?: string[]; + value?: string[]; + onChange?: (value: string[]) => void; + placeholder?: string; +}; + +// 获取所有叶子节点的值 +const getAllLeafValues = (option: Option): string[] => { + if (!option.children || option.children.length === 0) { + return [option.value]; + } + return option.children.flatMap(getAllLeafValues); +}; + +// 检查节点是否所有子节点都被选中 +const isAllChildrenChecked = (option: Option, selected: string[]): boolean => { + if (!option.children || option.children.length === 0) { + return selected.includes(option.value); + } + // 对于有子节点的节点,检查其所有叶子节点是否都被选中 + const leafValues = getAllLeafValues(option); + return leafValues.every(value => selected.includes(value)); +}; + +// 检查节点是否有部分子节点被选中 +const isSomeChildrenChecked = (option: Option, selected: string[]): boolean => { + if (!option.children || option.children.length === 0) { + return selected.includes(option.value); + } + // 对于有子节点的节点,检查其叶子节点是否有部分被选中 + const leafValues = getAllLeafValues(option); + return leafValues.some(value => selected.includes(value)); +}; + +const MultiCascader: React.FC = ({ + options, + defaultValue = [], + value, + onChange, + placeholder = '请选择' +}) => { + const [visible, setVisible] = useState(false); + const [selected, setSelected] = useState(value ?? defaultValue); + const [expandedKeys, setExpandedKeys] = useState>(new Set()); + const containerRef = useRef(null); + + // 当外部 value 变化时,同步内部状态 + useEffect(() => { + if (value !== undefined) { + setSelected(value); + } + }, [value]); + + 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 handleItemCheck = (option: Option, checked: boolean) => { + const leafValues = getAllLeafValues(option); + let newSelected: string[]; + + if (checked) { + newSelected = Array.from(new Set([...selected, ...leafValues])); + } else { + newSelected = selected.filter(v => !leafValues.includes(v)); + } + + setSelected(newSelected); + onChange?.(newSelected); + }; + + const getDisplayText = () => { + if (selected.length === 0) return placeholder; + return `已选择 ${selected.length} 项`; + }; + + const toggleExpand = (key: string) => { + setExpandedKeys(prev => { + const newSet = new Set(prev); + if (newSet.has(key)) { + newSet.delete(key); + } else { + newSet.add(key); + } + return newSet; + }); + }; + + const renderOption = (option: Option, level: number = 0) => { + const allChecked = isAllChildrenChecked(option, selected); + const someChecked = isSomeChildrenChecked(option, selected); + const hasChildren = option.children && option.children.length > 0; + const isExpanded = expandedKeys.has(option.value); + + return ( +
0 ? "pl-4" : ""}> +
+ { if (el) el.indeterminate = !allChecked && someChecked; }} + onChange={e => handleItemCheck(option, e.target.checked)} + id={`cascader-${option.value}`} + /> + + {hasChildren && ( + + )} +
+ {hasChildren && isExpanded && ( +
+ {option.children?.map(child => ( + + {renderOption(child, level + 1)} + + ))} +
+ )} +
+ ); + }; + + return ( +
+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setVisible(!visible); + } + }} + onClick={() => setVisible(!visible)} + > + + {getDisplayText()} + + + + +
+ {visible && ( +
+
+ {options.map(option => renderOption(option))} +
+
+ )} +
+ ); +}; + +export default MultiCascader; \ No newline at end of file diff --git a/app/routes/cross-checking._index.tsx b/app/routes/cross-checking._index.tsx index daa84b6..374c3dd 100644 --- a/app/routes/cross-checking._index.tsx +++ b/app/routes/cross-checking._index.tsx @@ -458,7 +458,7 @@ export default function CrossCheckingIndex() { - diff --git a/app/routes/cross-checking.upload.tsx b/app/routes/cross-checking.upload.tsx index edbd255..ff7c69b 100644 --- a/app/routes/cross-checking.upload.tsx +++ b/app/routes/cross-checking.upload.tsx @@ -1,11 +1,13 @@ import { useState, useRef } from "react"; import { type MetaFunction, type ActionFunctionArgs } from "@remix-run/node"; -import { Form, useNavigation } from "@remix-run/react"; +import { Form, useNavigation, useNavigate } from "@remix-run/react"; import { UploadArea, type UploadAreaRef } from "~/components/ui/UploadArea"; 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,378 @@ 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: "梅州市-梅州市烟草局-李局长" }, + { label: "王副局长", value: "梅州市-梅州市烟草局-王副局长" }, + { + label: "市场监管科", // 市级局下的科室 + value: "梅州市烟草局-市场监管科", + children: [ + { label: "张科长", value: "梅州市-梅州市烟草局-市场监管科-张科长" }, + { label: "陈主任", value: "梅州市-梅州市烟草局-市场监管科-陈主任" } + ] + }, + { + label: "法规科", + value: "梅州市烟草局-法规科", + children: [ + { label: "刘科长", value: "梅州市-梅州市烟草局-法规科-刘科长" }, + { label: "周专员", value: "梅州市-梅州市烟草局-法规科-周专员" } + ] + } + ] + }, + { + label: "梅江区", // 区级 + value: "梅江区", + children: [ + { + label: "梅江区烟草分局", // 区级分局 + value: "梅江区烟草分局", + children: [ + { label: "张分局长", value: "梅州市-梅江区-梅江区烟草分局-张分局长" }, + { label: "李副分局长", value: "梅州市-梅江区-梅江区烟草分局-李副分局长" }, + { + label: "执法大队", // 分局下的大队 + value: "梅江区烟草分局-执法大队", + children: [ + { label: "王队长", value: "梅州市-梅江区-梅江区烟草分局-执法大队-王队长" }, + { label: "陈副队长", value: "梅州市-梅江区-梅江区烟草分局-执法大队-陈副队长" }, + { + label: "第一中队", // 大队下的中队 + value: "梅江区烟草分局-执法大队-第一中队", + children: [ + { label: "赵中队长", value: "梅州市-梅江区-梅江区烟草分局-执法大队-第一中队-赵中队长" }, + { label: "孙执法员", value: "梅州市-梅江区-梅江区烟草分局-执法大队-第一中队-孙执法员" }, + { label: "钱执法员", value: "梅州市-梅江区-梅江区烟草分局-执法大队-第一中队-钱执法员" } + ] + }, + { + label: "第二中队", + value: "梅江区烟草分局-执法大队-第二中队", + children: [ + { label: "吴中队长", value: "梅州市-梅江区-梅江区烟草分局-执法大队-第二中队-吴中队长" }, + { label: "郑执法员", value: "梅州市-梅江区-梅江区烟草分局-执法大队-第二中队-郑执法员" } + ] + } + ] + }, + { + label: "办公室", + value: "梅江区烟草分局-办公室", + children: [ + { label: "林主任", value: "梅州市-梅江区-梅江区烟草分局-办公室-林主任" }, + { label: "黄秘书", value: "梅州市-梅江区-梅江区烟草分局-办公室-黄秘书" } + ] + } + ] + }, + { + label: "梅江区市场监管局", + value: "梅江区市场监管局", + children: [ + { label: "刘局长", value: "梅州市-梅江区-梅江区市场监管局-刘局长" }, + { label: "周副局长", value: "梅州市-梅江区-梅江区市场监管局-周副局长" }, + { + label: "执法监察科", + value: "梅江区市场监管局-执法监察科", + children: [ + { label: "谢科长", value: "梅州市-梅江区-梅江区市场监管局-执法监察科-谢科长" }, + { label: "何专员", value: "梅州市-梅江区-梅江区市场监管局-执法监察科-何专员" } + ] + } + ] + } + ] + }, + { + label: "梅县区", // 另一个区 + value: "梅县区", + children: [ + { + label: "梅县区烟草分局", + value: "梅县区烟草分局", + children: [ + { label: "黄分局长", value: "梅州市-梅县区-梅县区烟草分局-黄分局长" }, + { label: "林副分局长", value: "梅州市-梅县区-梅县区烟草分局-林副分局长" }, + { + label: "稽查队", + value: "梅县区烟草分局-稽查队", + children: [ + { label: "吴队长", value: "梅州市-梅县区-梅县区烟草分局-稽查队-吴队长" }, + { label: "郑稽查员", value: "梅州市-梅县区-梅县区烟草分局-稽查队-郑稽查员" }, + { label: "谢稽查员", value: "梅州市-梅县区-梅县区烟草分局-稽查队-谢稽查员" } + ] + } + ] + } + ] + }, + { + label: "丰顺县", // 县级 + value: "丰顺县", + children: [ + { + label: "丰顺县烟草分局", + value: "丰顺县烟草分局", + children: [ + { label: "曾分局长", value: "梅州市-丰顺县-丰顺县烟草分局-曾分局长" }, + { + label: "专卖管理所", + value: "丰顺县烟草分局-专卖管理所", + children: [ + { label: "邓所长", value: "梅州市-丰顺县-丰顺县烟草分局-专卖管理所-邓所长" }, + { label: "罗管理员", value: "梅州市-丰顺县-丰顺县烟草分局-专卖管理所-罗管理员" } + ] + } + ] + } + ] + } + ] + }, + { + label: "揭阳市", + value: "揭阳市", + children: [ + { + label: "揭阳市烟草局", // 市级局 + value: "揭阳市烟草局", + children: [ + { label: "苏局长", value: "揭阳市-揭阳市烟草局-苏局长" }, + { label: "叶副局长", value: "揭阳市-揭阳市烟草局-叶副局长" }, + { + label: "专卖监督管理处", + value: "揭阳市烟草局-专卖监督管理处", + children: [ + { label: "潘处长", value: "揭阳市-揭阳市烟草局-专卖监督管理处-潘处长" }, + { label: "方副处长", value: "揭阳市-揭阳市烟草局-专卖监督管理处-方副处长" } + ] + } + ] + }, + { + label: "榕城区", + value: "榕城区", + children: [ + { + label: "榕城区烟草分局", + value: "榕城区烟草分局", + children: [ + { label: "王分局长", value: "揭阳市-榕城区-榕城区烟草分局-王分局长" }, + { label: "李明华", value: "揭阳市-榕城区-榕城区烟草分局-李明华" }, + { label: "张丽萍", value: "揭阳市-榕城区-榕城区烟草分局-张丽萍" }, + { + label: "市场检查组", + value: "榕城区烟草分局-市场检查组", + children: [ + { label: "陈组长", value: "揭阳市-榕城区-榕城区烟草分局-市场检查组-陈组长" }, + { label: "林检查员", value: "揭阳市-榕城区-榕城区烟草分局-市场检查组-林检查员" } + ] + } + ] + }, + { + label: "榕城区质监局", + value: "榕城区质监局", + children: [ + { label: "陈国强", value: "揭阳市-榕城区-榕城区质监局-陈国强" }, + { label: "林小芳", value: "揭阳市-榕城区-榕城区质监局-林小芳" } + ] + } + ] + }, + { + label: "揭东区", + value: "揭东区", + children: [ + { + label: "揭东区烟草分局", + value: "揭东区烟草分局", + children: [ + { label: "黄建军", value: "揭阳市-揭东区-揭东区烟草分局-黄建军" }, + { label: "吴秀英", value: "揭阳市-揭东区-揭东区烟草分局-吴秀英" }, + { label: "刘志华", value: "揭阳市-揭东区-揭东区烟草分局-刘志华" } + ] + } + ] + }, + { + label: "惠来县", // 县级 + value: "惠来县", + children: [ + { + label: "惠来县烟草分局", + value: "惠来县烟草分局", + children: [ + { label: "杨分局长", value: "揭阳市-惠来县-惠来县烟草分局-杨分局长" }, + { + label: "案件审理室", + value: "惠来县烟草分局-案件审理室", + children: [ + { label: "蔡主任", value: "揭阳市-惠来县-惠来县烟草分局-案件审理室-蔡主任" }, + { label: "郭审理员", value: "揭阳市-惠来县-惠来县烟草分局-案件审理室-郭审理员" } + ] + } + ] + } + ] + } + ] + }, + { + label: "汕头市", + value: "汕头市", + children: [ + { + label: "汕头市烟草局", // 市级局 + value: "汕头市烟草局", + children: [ + { label: "何局长", value: "汕头市-汕头市烟草局-何局长" }, + { label: "许副局长", value: "汕头市-汕头市烟草局-许副局长" } + ] + }, + { + label: "龙湖区", + value: "龙湖区", + children: [ + { + label: "龙湖区烟草分局", + value: "龙湖区烟草分局", + children: [ + { label: "许志明", value: "汕头市-龙湖区-龙湖区烟草分局-许志明" }, + { label: "蔡丽娜", value: "汕头市-龙湖区-龙湖区烟草分局-蔡丽娜" }, + { label: "郭建华", value: "汕头市-龙湖区-龙湖区烟草分局-郭建华" }, + { label: "何美霞", value: "汕头市-龙湖区-龙湖区烟草分局-何美霞" } + ] + }, + { + label: "龙湖区工商局", + value: "龙湖区工商局", + children: [ + { label: "方国庆", value: "汕头市-龙湖区-龙湖区工商局-方国庆" }, + { label: "杨小红", value: "汕头市-龙湖区-龙湖区工商局-杨小红" } + ] + } + ] + }, + { + label: "金平区", + value: "金平区", + children: [ + { + label: "金平区烟草分局", + value: "金平区烟草分局", + children: [ + { label: "邓志强", value: "汕头市-金平区-金平区烟草分局-邓志强" }, + { label: "罗美玲", value: "汕头市-金平区-金平区烟草分局-罗美玲" } + ] + }, + { + label: "金平区市场监管局", + value: "金平区市场监管局", + children: [ + { label: "苏建国", value: "汕头市-金平区-金平区市场监管局-苏建国" }, + { label: "叶丽华", value: "汕头市-金平区-金平区市场监管局-叶丽华" }, + { label: "潘志明", value: "汕头市-金平区-金平区市场监管局-潘志明" } + ] + } + ] + }, + { + label: "南澳县", // 县级 + value: "南澳县", + children: [ + { + label: "南澳县烟草分局", + value: "南澳县烟草分局", + children: [ + { label: "陈分局长", value: "汕头市-南澳县-南澳县烟草分局-陈分局长" }, + { label: "林管理员", value: "汕头市-南澳县-南澳县烟草分局-林管理员" } + ] + } + ] + } + ] + } +]; + + +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" }} + > + + + )} + { if (el) el.indeterminate = !allChecked && someChecked; }} + onChange={e => onCheck(node, e.target.checked)} + id={node.value} + /> + +
+ {expanded && node.children && ( +
+ {node.children.map(child => ( + + ))} +
+ )} +
+ ); +}; export const action = async ({ request }: ActionFunctionArgs) => { const formData = await request.formData(); const caseType = formData.get("caseType") as string; @@ -54,8 +430,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"); @@ -260,13 +644,11 @@ export default function CrossCheckingUpload() { if (failures.length === 0) { // 全部成功 toastService.success(`成功上传 ${successes.length} 个文件`); + // 立即清空文件列表 + clearAllFiles(); messageService.success(`文件上传完成!成功上传 ${successes.length} 个文件,现在可以进行下一步操作。`, { title: '上传成功', - confirmText: '确定', - onConfirm: () => { - // 清空文件列表 - clearAllFiles(); - } + confirmText: '确定' }); } else if (successes.length === 0) { // 全部失败 @@ -299,221 +681,352 @@ 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(); + // 小组多选逻辑 - 默认不选择任何项 + // 检查是否可以完成 const canComplete = (singleFiles.length > 0 || multipleFiles.length > 0) && !isUploading; + const navigation = useNavigation(); const isSubmitting = navigation.state === "submitting"; + const navigate = useNavigate(); + return (
- {/* 步骤指示器 */} + {/* 步骤指示器 */
{STEPS.map((step) => ( -
-
- {step.id} -
+
{step.id}
{step.label}
))}
+ } - {/* 案卷类型选择器 */} -
-
- - -
-
- - {/* 文件上传区域 */} -
- - - -
- {/* 单案件导入 */} -
-
- - 单案件导入 - {uploadType === 'single' && singleFiles.length > 0 && ( - - )} + {/* 步骤1:创建任务 */} + {currentStep === 1 && ( +
+
+
+ + setTaskInfo({ ...taskInfo, name: e.target.value })} + placeholder="请输入任务名称" + />
- - 请上传案件相关PDF文件 -
- } - disabled={uploadType === 'multiple' || isUploading} - /> - - {/* 单案件文件列表 */} - {singleFiles.length > 0 && ( -
-
- 已选择 {singleFiles.length} 个文件: -
-
- {singleFiles.map((file) => ( -
-
- - {file.name} - {formatFileSize(file.size)} -
- -
- ))} -
-
- )} -
- - {/* 多案件导入 */} -
-
- - 多案件导入 - {uploadType === 'multiple' && multipleFiles.length > 0 && ( - - )} +
+ + setTaskInfo({ ...taskInfo, date: value })} + className="w-full" + id="task-date" + placeholder="请选择日期" + /> +
+
+ + setTaskInfo({ ...taskInfo, type: e.target.value })} + placeholder="请输入任务类型" + /> +
+
+ +
- - 请上传多个案件作为压缩包zip、rar、7z、tar文件 -
- } - disabled={uploadType === 'single' || isUploading} - /> - - {/* 多案件文件列表 */} - {multipleFiles.length > 0 && ( -
-
- 已选择 {multipleFiles.length} 个压缩包: -
-
- {multipleFiles.map((file) => ( -
-
- - {file.name} - {formatFileSize(file.size)} -
- -
- ))} -
-
- )}
- - {/* 完成按钮 */} -
- -
- - - {/* 文件选择状态提示 */} - {!canComplete && !isUploading && ( -
- 请至少选择一种导入方式的文件 -
)} - {/* 上传进度提示 */} - {isUploading && ( -
-
-
- - 正在上传文件... + {/* 步骤2:创建评查小组 */} + {currentStep === 2 && ( + <> +
+
+
+ {/* 左侧树状多选 */} +
+
+ + { + setGroupChecked(values); + }} + /> +
+
+ {/* 右侧已选择成员显示区域 */} +
+
+

已选择的评查小组成员

+ {groupChecked.length > 0 ? ( +
+ {groupChecked.map((member, index) => { + const parts = member.split('-'); + const name = parts[parts.length - 1]; + const org = parts.slice(0, -1).join(' - '); + return ( +
+
{name}
+
{org}
+
+ ); + })} +
+ ) : ( +
+ + 暂未选择评查小组成员 +
+ )} +
+
+ 共选择 {groupChecked.length} 名成员 +
+
+
+
+
+ + {/* 按钮区域移到卡片内部 */} +
+ +
+ + +
+
-

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

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

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

+
+
+ )}
)} diff --git a/app/styles/main.css b/app/styles/main.css index 0c7c161..2d91c53 100644 --- a/app/styles/main.css +++ b/app/styles/main.css @@ -45,7 +45,7 @@ --color-primary: #00684a; --color-primary-hover: #005a3f; --color-primary-light: rgba(0, 104, 74, 0.1); - + /* 成功、警告、错误颜色 */ --color-success: #52c41a; --color-warning: #faad14; @@ -296,4 +296,4 @@ i[class^="ri-"], i[class*=" ri-"] { font-family: 'remixicon' !important; font-style: normal !important; -} \ No newline at end of file +} \ No newline at end of file diff --git a/app/styles/pages/cross-checking-upload.css b/app/styles/pages/cross-checking-upload.css index 1e06371..d257f91 100644 --- a/app/styles/pages/cross-checking-upload.css +++ b/app/styles/pages/cross-checking-upload.css @@ -25,8 +25,8 @@ content: ''; position: absolute; top: 25px; - left: 75px; - width: calc(100% - 50px); + left: 135px; + width: calc(100% - 70px); height: 2px; background-color: #e5e7eb; z-index: 1; @@ -272,4 +272,72 @@ left: 60px; width: calc(100% - 20px); } -} \ No newline at end of file +} + +.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