From 277c54f34d8a97dbaf0688afdfc5f8a83e9cc587 Mon Sep 17 00:00:00 2001 From: Wren Date: Wed, 16 Jul 2025 19:01:13 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E5=88=9D=E7=89=88=E7=BC=96=E5=86=99?= =?UTF-8?q?=E4=BA=A4=E5=8F=89=E8=AF=84=E6=9F=A5=E9=A1=B5=E9=9D=A2=EF=BC=8C?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E9=9D=9E=E8=8C=83=E5=9B=B4=E6=97=A5=E6=9C=9F?= =?UTF-8?q?=E9=80=89=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 From d10309db997cf5ec92fb8fef0a0caa05be503fcf Mon Sep 17 00:00:00 2001 From: Wren Date: Wed, 16 Jul 2025 22:10:15 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E4=BA=A4=E5=8F=89=E8=AF=84=E6=9F=A5=20?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E4=BB=BB=E5=8A=A1=E6=B5=81=E7=A8=8B=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E4=BC=98=E5=8C=96=E7=BB=86=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/routes/cross-checking._index.tsx | 2 +- app/routes/cross-checking.upload.tsx | 773 ++++++++++++++------- app/styles/main.css | 4 +- app/styles/pages/cross-checking-upload.css | 6 +- 4 files changed, 529 insertions(+), 256 deletions(-) diff --git a/app/routes/cross-checking._index.tsx b/app/routes/cross-checking._index.tsx index 802e534..081b6e0 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 7bf8de0..258b5c1 100644 --- a/app/routes/cross-checking.upload.tsx +++ b/app/routes/cross-checking.upload.tsx @@ -1,6 +1,6 @@ -import { useState, useRef, useEffect } from "react"; +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"; @@ -50,21 +50,139 @@ export interface TreeNode { children?: TreeNode[]; } +// 无限层级组织架构数据结构 const MOCK_TREE: TreeNode[] = [ { label: "梅州市", value: "梅州市", children: [ { - label: "梅江区", + 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: "梅江区烟草分局", children: [ - { label: "张三", value: "梅州市-梅江区-梅江区烟草局-张三" }, - { label: "李四", value: "梅州市-梅江区-梅江区烟草局-李四" } + { 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: "梅州市-丰顺县-丰顺县烟草分局-专卖管理所-罗管理员" } + ] + } ] } ] @@ -75,15 +193,160 @@ 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: "榕城区烟草分局", children: [ - { label: "王五", value: "揭阳市-榕城区-榕城区烟草局-王五" } + { 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: "汕头市-南澳县-南澳县烟草分局-林管理员" } ] } ] @@ -92,14 +355,7 @@ const MOCK_TREE: TreeNode[] = [ } ]; -// 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)); @@ -159,36 +415,6 @@ const TreeNodeCheckbox: React.FC<{ ); }; -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; @@ -418,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) { // 全部失败 @@ -463,20 +687,19 @@ export default function CrossCheckingUpload() { // 步骤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"; + const navigate = useNavigate(); + return (
- {/* 步骤指示器 */} + {/* 步骤指示器 */
{STEPS.map((step) => (
))}
+ } - {/* 案卷类型选择器 */} -
-
- - -
-
- - {/* 步骤1:创建任务 */} + {/* 步骤1:创建任务 */} {currentStep === 1 && ( -
-
- - setTaskInfo({ ...taskInfo, name: e.target.value })} - placeholder="请输入任务名称" - /> -
-
- - setTaskInfo({ ...taskInfo, date: value })} - className="w-full" - id="task-date" - placeholder="请选择日期" - /> -
-
- - setTaskInfo({ ...taskInfo, type: e.target.value })} - placeholder="请输入任务类型" - /> -
-
- +
+
+
+ + setTaskInfo({ ...taskInfo, name: e.target.value })} + placeholder="请输入任务名称" + /> +
+
+ + setTaskInfo({ ...taskInfo, date: value })} + className="w-full" + id="task-date" + placeholder="请选择日期" + /> +
+
+ + setTaskInfo({ ...taskInfo, type: e.target.value })} + placeholder="请输入任务类型" + /> +
+
+ + +
)} @@ -553,56 +764,116 @@ export default function CrossCheckingUpload() { {/* 步骤2:创建评查小组 */} {currentStep === 2 && ( <> -
- {/* 左侧树状多选 */} -
-
- - { - setGroupChecked(values); - }} - /> +
+
+
+ {/* 左侧树状多选 */} +
+
+ + { + 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} 名成员 +
+
+
+
+
+ + {/* 按钮区域移到卡片内部 */} +
+ +
+ + +
- {/* 右侧预留区域 */} -
-
-
- -
)} {/* 步骤3:原有上传区域 */} {currentStep === 3 && ( - <> - {/* 文件上传区域 */} -
+
+
+ {/* 案卷类型选择器 */} +
+
+
选择案卷类型
+
+ + +
+
+
+ + {/* 文件上传区域 */} + + {/* 上传框区域 */}
{/* 单案件导入 */}
单案件导入 - {uploadType === 'single' && singleFiles.length > 0 && ( - - )}
- - {/* 单案件文件列表 */} - {singleFiles.length > 0 && ( -
-
- 已选择 {singleFiles.length} 个文件: -
-
- {singleFiles.map((file) => ( -
-
- - {file.name} - {formatFileSize(file.size)} -
- -
- ))} -
-
- )}
{/* 多案件导入 */} @@ -655,17 +898,6 @@ export default function CrossCheckingUpload() {
多案件导入 - {uploadType === 'multiple' && multipleFiles.length > 0 && ( - - )}
+
+
+ + {/* 文件预览区域 */} + {(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)} +
+ +
+ ))} +
+ )} {/* 多案件文件列表 */} - {multipleFiles.length > 0 && ( -
-
- 已选择 {multipleFiles.length} 个压缩包: -
-
- {multipleFiles.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} 个文件,请稍候 -

-
- )} - + + + {/* 文件选择状态提示 */} + {!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 91e098d..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; @@ -340,4 +340,4 @@ .group-select-item label { font-size: 0.95rem; } -} \ No newline at end of file +} \ No newline at end of file From b30e4c92c8ea33340f10a30b6e76ff6d2801484e Mon Sep 17 00:00:00 2001 From: Wren Date: Wed, 16 Jul 2025 22:11:27 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E6=97=A5=E6=9C=9F=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E5=99=A8=20=E6=96=B0=E5=A2=9E=E9=9D=9E=E8=8C=83=E5=9B=B4?= =?UTF-8?q?=E6=97=A5=E6=9C=9F=E9=80=89=E6=8B=A9=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/components/ui/MultiCascader.tsx | 175 ++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 app/components/ui/MultiCascader.tsx diff --git a/app/components/ui/MultiCascader.tsx b/app/components/ui/MultiCascader.tsx new file mode 100644 index 0000000..e40bea0 --- /dev/null +++ b/app/components/ui/MultiCascader.tsx @@ -0,0 +1,175 @@ +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 ( +
+
setVisible(!visible)} + > + + {getDisplayText()} + + + + +
+ {visible && ( +
+
+ {options.map(option => renderOption(option))} +
+
+ )} +
+ ); +}; + +export default MultiCascader; \ No newline at end of file From a7474d7fc4ad5b2a6c83e225f6832ff6bb0106ce Mon Sep 17 00:00:00 2001 From: Wren Date: Wed, 16 Jul 2025 22:18:38 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/components/ui/MultiCascader.tsx | 12 ++++++++++-- app/routes/cross-checking.upload.tsx | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/app/components/ui/MultiCascader.tsx b/app/components/ui/MultiCascader.tsx index e40bea0..9d57164 100644 --- a/app/components/ui/MultiCascader.tsx +++ b/app/components/ui/MultiCascader.tsx @@ -131,7 +131,7 @@ const MultiCascader: React.FC = ({ toggleExpand(option.value); }} > - {isExpanded ? '▼' : '▶'} + )}
@@ -151,7 +151,15 @@ const MultiCascader: React.FC = ({ return (
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setVisible(!visible); + } + }} onClick={() => setVisible(!visible)} > diff --git a/app/routes/cross-checking.upload.tsx b/app/routes/cross-checking.upload.tsx index 258b5c1..ff7c69b 100644 --- a/app/routes/cross-checking.upload.tsx +++ b/app/routes/cross-checking.upload.tsx @@ -386,7 +386,7 @@ const TreeNodeCheckbox: React.FC<{ onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && setExpanded(e => !e)} style={{ width: 16, display: "inline-block", textAlign: "center" }} > - {expanded ? "▼" : "▶"} + )}