chenlei 1 ngày trước cách đây
mục cha
commit
7ef359b7dc

+ 7 - 1
src/pages/Dmanage/D4resource/D4edit/index.module.scss

@@ -6,7 +6,13 @@
       padding: 15px;
       display: flex;
       align-items: center;
-      gap: 15px;
+      justify-content: space-between;
+
+      > div {
+        display: flex;
+        align-items: center;
+        gap: 15px;
+      }
     }
     .D4editResourceTitle {
       font-weight: 700;

+ 32 - 8
src/pages/Dmanage/D4resource/D4edit/index.tsx

@@ -7,7 +7,7 @@ import SonGoodsList from '@/pages/Zother/SonGoodsList'
 import FileArchive from '@/pages/Zother/FileArchive'
 import { getDictFu, selectObj } from '@/utils/dataChange'
 import { D4_APIgetClueList, D4API_obj } from '@/store/action/Dmanage/D4resource'
-import { Checkbox } from 'antd'
+import { Button, Checkbox } from 'antd'
 import { useParams } from 'react-router-dom'
 
 const rowArr = [
@@ -56,6 +56,7 @@ function D4editContent() {
     key === '1' ? selectObj['附件类型'].map((v: any) => v.value) : []
   )
   const canEdit = useMemo(() => ['1', '2'].includes(key), [key])
+  const isOk = useMemo(() => info.status === 4, [info.status])
 
   const handleResourceTypeChange = useCallback((val: string[]) => {
     setResourceType(val)
@@ -84,13 +85,21 @@ function D4editContent() {
         />
 
         <div className='D4editResource'>
-          <p className='D4editResourceTitle'>资源类型</p>
-          <Checkbox.Group
-            disabled={!canEdit}
-            options={selectObj['附件类型']}
-            value={resourceType}
-            onChange={handleResourceTypeChange}
-          />
+          <div>
+            <p className='D4editResourceTitle'>资源类型</p>
+            <Checkbox.Group
+              disabled={!canEdit}
+              options={selectObj['附件类型']}
+              value={resourceType}
+              onChange={handleResourceTypeChange}
+            />
+          </div>
+          {isOk && (
+            <div>
+              <Button>查看下载记录</Button>
+              <Button>下载全部资源</Button>
+            </div>
+          )}
         </div>
 
         {/* 藏品清单 */}
@@ -100,6 +109,21 @@ function D4editContent() {
           addShow={false}
           isClueSelect={false}
           btnTxt='选择藏品'
+          customTableLastBtn={
+            isOk
+              ? item => (
+                  <Button
+                    size='small'
+                    type='text'
+                    onClick={() => {
+                      // TODO: 调用下载资源接口,传入 item.id
+                    }}
+                  >
+                    下载资源
+                  </Button>
+                )
+              : undefined
+          }
         />
 
         {/* 附件归档 */}

+ 17 - 2
src/pages/Fstorehouse/F1inStorage/F1edit/index.tsx

@@ -8,6 +8,7 @@ import { rowArrTemp } from '@/pages/Zother/data'
 import SonGoodsList from '@/pages/Zother/SonGoodsList'
 import {
   F1_APIgetClueList,
+  F1_APIgetShowList,
   F1_APIgetShelfEmptyList,
   F1_APIgetShelfList,
   F1_APIgetStorageList,
@@ -17,7 +18,22 @@ import { Button, Select } from 'antd'
 import { selectObj } from '@/utils/dataChange'
 import FileArchive from '@/pages/Zother/FileArchive'
 
-const rowArr = rowArrTemp('入库')
+const rowArrBase = rowArrTemp('入库')
+const rowArr = [
+  rowArrBase[0],
+  {
+    name: '借展归还',
+    key: 'showId',
+    type: 'SelectSearch',
+    placeholder: '请搜索展览名称',
+    api: F1_APIgetShowList,
+    searchParam: 'searchKey',
+    labelKey: 'name',
+    valueKey: 'id',
+    optionLabel: (info: any) => info?.showName
+  },
+  ...rowArrBase.slice(1)
+]
 
 function F1editContent() {
   const { info } = useInfo() as { info: any }
@@ -128,7 +144,6 @@ function F1editContent() {
     <div className={styles.F1edit} id='editBox'>
       <div className='editMain'>
         {/* 顶部 */}
-        {/* TODO: 借展归还待完善 */}
         <EditTop
           pageTxt='藏品入库'
           rowArr={rowArr}

+ 33 - 3
src/pages/Fstorehouse/F3outStorage/F3edit/index.tsx

@@ -10,12 +10,31 @@ import { F3_APIgetClueList, F3API_obj } from '@/store/action/Fstorehouse/F3outSt
 import { Select } from 'antd'
 import { selectObj } from '@/utils/dataChange'
 import FileArchive from '@/pages/Zother/FileArchive'
-import { F1_APIgetStorageList } from '@/store/action/Fstorehouse/F1inStorage'
+import { F1_APIgetShowList, F1_APIgetStorageList } from '@/store/action/Fstorehouse/F1inStorage'
 
-const rowArr = rowArrTemp('出库')
+const rowArrBase = rowArrTemp('出库')
+const rowArr = [
+  rowArrBase[0],
+  {
+    name: '借展出库',
+    key: 'showId',
+    type: 'SelectSearch',
+    placeholder: '请搜索展览名称',
+    api: F1_APIgetShowList,
+    searchParam: 'searchKey',
+    labelKey: 'name',
+    valueKey: 'id',
+    optionLabel: (info: any) => info?.showName
+  },
+  ...rowArrBase.slice(1)
+]
 
 function F3editContent() {
-  const { info, setSnapsFu } = useInfo() as { info: any; setSnapsFu: (snaps: any[]) => void }
+  const { info, setSnapsFu, setInfoFu } = useInfo() as {
+    info: any
+    setSnapsFu: (snaps: any[]) => void
+    setInfoFu: (info: any) => void
+  }
   const { key } = useParams<any>()
   const canEdit = useMemo(() => ['1', '2'].includes(key), [key])
   const sonGoodsListRef = useRef<any>(null)
@@ -60,6 +79,17 @@ function F3editContent() {
     }
   }
 
+  useEffect(() => {
+    const urlAll = window.location.href
+    if (urlAll.includes('?showId=')) {
+      const showIdStr = urlAll.split('?showId=')[1]?.split('&')[0]?.split('#')[0]
+      if (showIdStr) {
+        const showId = Number(showIdStr)
+        setInfoFu({ showId } as any)
+      }
+    }
+  }, [setInfoFu])
+
   return (
     <div className={styles.F3edit} id='editBox'>
       <div className='editMain'>

+ 4 - 4
src/pages/Fstorehouse/F5staff/F5edit/components/StaffModal.tsx

@@ -58,16 +58,16 @@ function StaffModal({ open, editingStaff, onOk, onCancel }: Props) {
     >
       <Form form={form} layout='vertical'>
         <Form.Item label='姓名' name='name' rules={[{ required: true, message: '请输入姓名' }]}>
-          <Input placeholder='请输入姓名' />
+          <Input placeholder='请输入姓名' maxLength={30} showCount />
         </Form.Item>
         <Form.Item label='证件信息' name='papers'>
-          <Input placeholder='请输入证件信息' />
+          <Input placeholder='请输入证件信息' maxLength={30} showCount />
         </Form.Item>
         <Form.Item label='联系方式' name='phone'>
-          <Input placeholder='请输入联系方式' />
+          <Input placeholder='请输入联系方式' maxLength={30} showCount />
         </Form.Item>
         <Form.Item label='所在单位' name='unit'>
-          <Input placeholder='请输入所在单位' />
+          <Input placeholder='请输入所在单位' maxLength={30} showCount />
         </Form.Item>
       </Form>
     </Modal>

+ 97 - 0
src/pages/Hexhibits/H1loan/H1detail/index.module.scss

@@ -0,0 +1,97 @@
+.H1detail {
+  :global {
+    .H1detailMain {
+      width: 100%;
+      border-bottom: 1px solid #ccc;
+      display: flex;
+      flex-direction: column;
+    }
+    .H1detailMainTitle {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      width: 100%;
+      font-weight: 700;
+      font-size: 18px;
+      padding: 15px;
+
+      > div {
+        display: flex;
+        align-items: center;
+        gap: 15px;
+      }
+    }
+  }
+}
+
+.detailContent {
+  display: flex;
+  flex-direction: column;
+  gap: 15px;
+  border-top: 1px solid #ccc;
+  padding: 15px;
+}
+
+.detailGrid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 15px 24px;
+}
+
+.detailRow {
+  display: flex;
+  align-items: flex-start;
+  gap: 12px;
+  line-height: 35px;
+
+  &[data-full='true'] {
+    grid-column: 1 / -1;
+  }
+}
+
+.detailLabel {
+  flex-shrink: 0;
+  font-weight: 700;
+  width: 100px;
+  text-align: right;
+  font-size: 16px;
+}
+
+.detailValue {
+  flex: 1;
+  word-break: break-all;
+}
+
+.fileList {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+
+  a {
+    color: #1677ff;
+    text-decoration: none;
+
+    &:hover {
+      text-decoration: underline;
+    }
+  }
+}
+
+.goodSearchRow {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 10px;
+  padding: 15px;
+  border-top: 1px solid #ccc;
+
+  .ant-input {
+    width: 200px;
+  }
+}
+
+.goodTableWrap {
+  background: var(--boxBcaColor, #fff);
+  border-radius: 8px;
+  margin: 0 15px 15px;
+}

+ 362 - 0
src/pages/Hexhibits/H1loan/H1detail/index.tsx

@@ -0,0 +1,362 @@
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import styles from './index.module.scss'
+import { Button, Cascader, Input, Select } from 'antd'
+import { useParams } from 'react-router-dom'
+import {
+  H1_APIgetInfo,
+  H1_APIsave,
+  H1_APIdelete,
+  H1_APIgetShowGoodPage
+} from '@/store/action/Hexhibits/H1loan'
+import { API_getFileListByIds } from '@/store/action/Cledger/C4file'
+import ExhibitionModal, { ExhibitionFormValues } from '../components/ExhibitionModal'
+import { MessageFu } from '@/utils/message'
+import MyPopconfirm from '@/components/MyPopconfirm'
+import history from '@/utils/history'
+import dayjs from 'dayjs'
+import { baseURL } from '@/utils/http'
+import { FileListType } from '@/components/Z3upFiles/data'
+import MyTable from '@/components/MyTable'
+import { getDictFu, selectObj } from '@/utils/dataChange'
+import { showGoodTableC } from '@/utils/tableData'
+
+type DetailInfo = {
+  id?: number
+  name?: string
+  type?: string
+  dateOpen?: string
+  dateEnd?: string
+  region?: string
+  planner?: string
+  remark?: string
+  fileIds?: string
+}
+
+const EMPTY_VAL = '(空)'
+
+const formatVal = (v: React.ReactNode) =>
+  v === undefined || v === null || v === '' ? EMPTY_VAL : v
+
+const DetailRow = ({
+  label,
+  value,
+  fullRow
+}: {
+  label: string
+  value: React.ReactNode
+  fullRow?: boolean
+}) => (
+  <div className={styles.detailRow} data-full={fullRow}>
+    <span className={styles.detailLabel}>{label}</span>
+    <span className={styles.detailValue}>{formatVal(value)}</span>
+  </div>
+)
+
+function H1detail() {
+  const { id } = useParams<any>()
+  const [info, setInfo] = useState<DetailInfo>({})
+  const [fileList, setFileList] = useState<FileListType[]>([])
+  const [exhibitionModalOpen, setExhibitionModalOpen] = useState(false)
+
+  const getInfo = useCallback(async () => {
+    if (!id) return
+    const res = await H1_APIgetInfo(Number(id))
+    if (res?.code === 0 && res.data) {
+      setInfo(res.data)
+      const fileIds = res.data.fileIds || ''
+      if (fileIds) {
+        const fileRes = await API_getFileListByIds(fileIds.split(',').filter(Boolean))
+        if (fileRes?.code === 0 && fileRes.data) {
+          setFileList(fileRes.data)
+        }
+      }
+    }
+  }, [id])
+
+  useEffect(() => {
+    getInfo()
+  }, [getInfo])
+
+  const dateStr = React.useMemo(() => {
+    const { dateOpen, dateEnd } = info
+    if (!dateOpen && !dateEnd) return '长期'
+    const open = dateOpen ? dayjs(dateOpen).format('YYYY年MM月DD日') : EMPTY_VAL
+    const end = dateEnd ? dayjs(dateEnd).format('YYYY年MM月DD日') : EMPTY_VAL
+    return `${open} - ${end}`
+  }, [info])
+
+  const regionStr = React.useMemo(() => {
+    const r = info.region
+    if (!r) return EMPTY_VAL
+    const arr = typeof r === 'string' ? r.split(',').filter(Boolean) : Array.isArray(r) ? r : []
+    return arr.length ? arr.join('、') : EMPTY_VAL
+  }, [info])
+
+  const titleTag = React.useMemo(() => {
+    const type = info.type || EMPTY_VAL
+    const planner = info.planner || EMPTY_VAL
+    return `${type} | ${dateStr} | ${planner}`
+  }, [info, dateStr])
+
+  const handleDelete = useCallback(async () => {
+    if (!info.id) return
+    const res = await H1_APIdelete(info.id)
+    if (res?.code === 0) {
+      MessageFu.success('删除成功')
+      history.push('/loan')
+    } else {
+      MessageFu.error(res?.message || '删除失败')
+    }
+  }, [info.id])
+
+  const handleEditOk = useCallback(
+    async (values: ExhibitionFormValues) => {
+      const res = await H1_APIsave({
+        ...values,
+        id: info.id,
+        region: values.region.join(',')
+      })
+      if (res?.code === 0) {
+        MessageFu.success('编辑成功')
+        setExhibitionModalOpen(false)
+        getInfo()
+      } else {
+        return Promise.reject(res?.message || '编辑失败')
+      }
+    },
+    [info.id, getInfo]
+  )
+
+  // 展品清单
+  const [goodFormData, setGoodFormData] = useState({
+    pageNum: 1,
+    pageSize: 10,
+    searchKey: '',
+    searchTagName: '',
+    level: undefined as string | undefined,
+    typeDictId: undefined as (string | number)[] | undefined,
+    region: undefined as string | undefined,
+    status: undefined as number | undefined
+  })
+  const goodFormDataRef = useRef(goodFormData)
+  useEffect(() => {
+    goodFormDataRef.current = goodFormData
+  }, [goodFormData])
+  const [goodTimeKey, setGoodTimeKey] = useState(0)
+  const [goodList, setGoodList] = useState<any[]>([])
+  const [goodTotal, setGoodTotal] = useState(0)
+
+  const getGoodList = useCallback(async () => {
+    if (!info.id) return
+    const fd = goodFormDataRef.current
+    const params: any = {
+      ...fd,
+      showId: info.id
+    }
+    if (Array.isArray(fd.typeDictId) && fd.typeDictId.length) {
+      params.typeDictId = fd.typeDictId[fd.typeDictId.length - 1]
+    }
+    const res = await H1_APIgetShowGoodPage(params)
+    if (res?.code === 0) {
+      setGoodList(res.data?.records || [])
+      setGoodTotal(res.data?.total || 0)
+    }
+  }, [info.id])
+
+  useEffect(() => {
+    getGoodList()
+  }, [getGoodList, goodTimeKey])
+
+  const goodClickSearch = useCallback(() => {
+    setGoodFormData(d => ({ ...d, pageNum: 1 }))
+    setTimeout(() => setGoodTimeKey(Date.now()), 50)
+  }, [])
+
+  const goodResetFu = useCallback(() => {
+    setGoodFormData({
+      pageNum: 1,
+      pageSize: 10,
+      searchKey: '',
+      searchTagName: '',
+      level: undefined,
+      typeDictId: undefined,
+      region: undefined,
+      status: undefined
+    })
+    setTimeout(() => setGoodTimeKey(Date.now()), 50)
+  }, [])
+
+  const goodPaginationChange = useCallback((pageNum: number, pageSize: number) => {
+    setGoodFormData(d => ({ ...d, pageNum, pageSize }))
+    setTimeout(() => setGoodTimeKey(Date.now()), 50)
+  }, [])
+
+  const regionOptions = useMemo(() => {
+    const r = info.region
+    if (!r) return []
+    const arr = typeof r === 'string' ? r.split(',').filter(Boolean) : []
+    return arr.map(v => ({ label: v, value: v }))
+  }, [info.region])
+
+  const typeDictOptions = useMemo(() => {
+    try {
+      return getDictFu('藏品类别') || []
+    } catch {
+      return []
+    }
+  }, [])
+
+  return (
+    <div className={styles.H1detail}>
+      <div className='H1detailMain'>
+        <div className='H1detailMainTitle'>
+          <div>
+            <span>{info.name}</span>
+            <Button type='dashed' style={{ pointerEvents: 'none' }}>
+              {titleTag}
+            </Button>
+          </div>
+          <div>
+            <Button type='primary' onClick={() => setExhibitionModalOpen(true)} disabled={!info.id}>
+              编辑
+            </Button>
+            <MyPopconfirm
+              txtK='删除'
+              Dom={
+                <Button color='danger' disabled={!info.id}>
+                  删除
+                </Button>
+              }
+              onConfirm={handleDelete}
+            />
+          </div>
+        </div>
+
+        <div className={styles.detailContent}>
+          <div className={styles.detailGrid}>
+            <DetailRow label='起止日期:' value={dateStr} />
+            <DetailRow label='展览类型:' value={info.type} />
+            <DetailRow label='展区设置:' value={regionStr} />
+            <DetailRow label='策展人:' value={info.planner} />
+            <DetailRow label='展览说明:' value={info.remark} fullRow />
+            <DetailRow
+              label='附件:'
+              value={
+                fileList.length ? (
+                  <span className={styles.fileList}>
+                    {fileList.map(f => (
+                      <a
+                        key={f.id}
+                        href={baseURL + f.filePath}
+                        download
+                        target='_blank'
+                        rel='noreferrer'
+                      >
+                        {f.fileName}
+                      </a>
+                    ))}
+                  </span>
+                ) : undefined
+              }
+              fullRow
+            />
+          </div>
+        </div>
+      </div>
+
+      <div className='H1detailMain'>
+        <div className='H1detailMainTitle'>
+          <span>展品清单</span>
+          <div>
+            <Button>展位设置</Button>
+            <Button
+              onClick={() => info.id && history.push(`/outStorage_edit/1/null?showId=${info.id}`)}
+              disabled={!info.id}
+            >
+              借展出库
+            </Button>
+            <Button>归还展品</Button>
+          </div>
+        </div>
+        <div className={styles.goodSearchRow}>
+          <Input
+            placeholder='搜索藏品登记号、藏品名称'
+            maxLength={30}
+            value={goodFormData.searchKey}
+            onChange={e => setGoodFormData(d => ({ ...d, searchKey: e.target.value }))}
+            allowClear
+            style={{ width: 250 }}
+          />
+          <Input
+            placeholder='搜索藏品标签'
+            value={goodFormData.searchTagName}
+            onChange={e => setGoodFormData(d => ({ ...d, searchTagName: e.target.value }))}
+            allowClear
+            style={{ width: 200 }}
+          />
+          <Select
+            placeholder='级别'
+            allowClear
+            value={goodFormData.level}
+            onChange={v => setGoodFormData(d => ({ ...d, level: v }))}
+            options={selectObj['藏品级别']}
+            style={{ width: 120 }}
+          />
+          <Cascader
+            placeholder='类别'
+            allowClear
+            value={goodFormData.typeDictId}
+            onChange={(v: any) => setGoodFormData(d => ({ ...d, typeDictId: v }))}
+            options={typeDictOptions}
+            fieldNames={{ label: 'name', value: 'id', children: 'children' }}
+            style={{ width: 160 }}
+          />
+          <Select
+            placeholder='展区'
+            allowClear
+            value={goodFormData.region}
+            onChange={v => setGoodFormData(d => ({ ...d, region: v }))}
+            options={regionOptions}
+            style={{ width: 120 }}
+          />
+          <Select
+            placeholder='展览状态'
+            allowClear
+            value={goodFormData.status}
+            onChange={v => setGoodFormData(d => ({ ...d, status: v }))}
+            options={selectObj['展览状态']}
+            style={{ width: 120 }}
+          />
+          <Button type='primary' onClick={goodClickSearch}>
+            查询
+          </Button>
+          <Button onClick={goodResetFu}>重置</Button>
+        </div>
+        <div className={styles.goodTableWrap}>
+          <MyTable
+            classKey='H1detailGood'
+            list={goodList}
+            columnsTemp={showGoodTableC}
+            total={goodTotal}
+            pageNum={goodFormData.pageNum}
+            pageSize={goodFormData.pageSize}
+            yHeight={400}
+            onChange={goodPaginationChange}
+          />
+        </div>
+      </div>
+
+      <ExhibitionModal
+        open={exhibitionModalOpen}
+        onOk={handleEditOk}
+        onCancel={() => setExhibitionModalOpen(false)}
+        initialValues={info}
+        initialFileList={fileList}
+      />
+    </div>
+  )
+}
+
+const MemoH1detail = React.memo(H1detail)
+
+export default MemoH1detail

+ 243 - 0
src/pages/Hexhibits/H1loan/components/ExhibitionModal.tsx

@@ -0,0 +1,243 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react'
+import { Button, Col, DatePicker, Form, Input, Modal, Row, Select } from 'antd'
+import TextArea from 'antd/es/input/TextArea'
+import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'
+import dayjs from 'dayjs'
+import Z3upFilesRef from '@/components/Z3upFilesRef'
+import { FileListType } from '@/components/Z3upFiles/data'
+
+const { RangePicker } = DatePicker
+
+const EXHIBITION_TYPE_OPTIONS = [
+  { label: '常设展', value: '常设展' },
+  { label: '临时展', value: '临时展' },
+  { label: '巡回展', value: '巡回展' },
+  { label: '专题展', value: '专题展' },
+  { label: '交流展', value: '交流展' },
+  { label: '学术展', value: '学术展' },
+  { label: '其他', value: '其他' }
+]
+
+export type ExhibitionFormValues = {
+  name: string
+  type: string
+  dateOpen: string
+  dateEnd: string
+  planner: string
+  remark: string
+  fileIds: string
+  region: string[]
+}
+
+export type ExhibitionInitialValues = {
+  name?: string
+  type?: string
+  dateOpen?: string
+  dateEnd?: string
+  planner?: string
+  remark?: string
+  region?: string | string[]
+}
+
+type Props = {
+  open: boolean
+  onOk: (values: ExhibitionFormValues) => void | Promise<void>
+  onCancel: () => void
+  initialValues?: ExhibitionInitialValues
+  initialFileList?: FileListType[]
+}
+
+function ExhibitionModal({ open, onOk, onCancel, initialValues, initialFileList }: Props) {
+  const [form] = Form.useForm<ExhibitionFormValues>()
+  const [confirmLoading, setConfirmLoading] = useState(false)
+  const fileUploadRef = useRef<{
+    sonResListFu: () => { list: FileListType[] }
+    sonSetListFu: (list: FileListType[]) => void
+  }>(null)
+
+  const handleOk = useCallback(async () => {
+    try {
+      const values = await form.validateFields()
+      const fileIds = fileUploadRef.current
+        ?.sonResListFu()
+        ?.list?.map(f => f.id)
+        .join(',')
+      const dateRange = (values as any).dateRange
+      const submitData: ExhibitionFormValues = {
+        ...values,
+        dateOpen: dateRange?.[0] ? dayjs(dateRange[0]).format('YYYY-MM-DD') : '',
+        dateEnd: dateRange?.[1] ? dayjs(dateRange[1]).format('YYYY-MM-DD') : '',
+        fileIds: fileIds || '',
+        region: (values.region || []).map((r: string) => (r || '').trim()).filter(Boolean)
+      }
+      delete (submitData as any).dateRange
+      setConfirmLoading(true)
+      await onOk(submitData)
+      form.resetFields()
+      onCancel()
+    } catch (e) {
+      // 校验失败
+    } finally {
+      setConfirmLoading(false)
+    }
+  }, [form, onOk, onCancel])
+
+  const handleCancel = useCallback(() => {
+    form.resetFields()
+    onCancel()
+  }, [form, onCancel])
+
+  useEffect(() => {
+    if (open) {
+      if (initialValues) {
+        const regionArr =
+          typeof initialValues.region === 'string'
+            ? initialValues.region.split(',').filter(Boolean)
+            : Array.isArray(initialValues.region)
+              ? initialValues.region
+              : ['']
+        form.setFieldsValue({
+          name: initialValues.name ?? '',
+          type: initialValues.type ?? undefined,
+          planner: initialValues.planner ?? '',
+          remark: initialValues.remark ?? '',
+          region: regionArr.length ? regionArr : [''],
+          dateRange:
+            initialValues.dateOpen || initialValues.dateEnd
+              ? [
+                  initialValues.dateOpen ? dayjs(initialValues.dateOpen) : null,
+                  initialValues.dateEnd ? dayjs(initialValues.dateEnd) : null
+                ]
+              : undefined
+        } as ExhibitionFormValues & { dateRange?: any })
+        fileUploadRef.current?.sonSetListFu(initialFileList || [])
+      } else {
+        form.resetFields()
+        fileUploadRef.current?.sonSetListFu([])
+      }
+    }
+  }, [open, initialValues, initialFileList, form])
+
+  return (
+    <Modal
+      destroyOnClose
+      title='展览信息'
+      open={open}
+      onOk={handleOk}
+      onCancel={handleCancel}
+      okText='提交'
+      cancelText='取消'
+      width={800}
+      maskClosable={false}
+      confirmLoading={confirmLoading}
+    >
+      <Form form={form} layout='vertical' initialValues={{ region: [''] }}>
+        <Row gutter={16}>
+          <Col span={12}>
+            <Form.Item
+              label='展览名称'
+              name='name'
+              rules={[
+                { required: true, message: '请输入展览名称' },
+                { max: 50, message: '不超过50个字' }
+              ]}
+            >
+              <Input placeholder='请输入展览名称' maxLength={50} showCount />
+            </Form.Item>
+          </Col>
+          <Col span={12}>
+            <Form.Item
+              label='展览类型'
+              name='type'
+              rules={[{ required: true, message: '请选择展览类型' }]}
+            >
+              <Select placeholder='请选择' options={EXHIBITION_TYPE_OPTIONS} allowClear />
+            </Form.Item>
+          </Col>
+        </Row>
+
+        <Row gutter={16}>
+          <Col span={12}>
+            <Form.Item label='起止日期' name='dateRange'>
+              <RangePicker style={{ width: '100%' }} placeholder={['开始日期', '结束日期']} />
+            </Form.Item>
+          </Col>
+          <Col span={12}>
+            <Form.Item label='策展人' name='planner' rules={[{ max: 20, message: '不超过20个字' }]}>
+              <Input placeholder='请输入策展人' maxLength={20} showCount />
+            </Form.Item>
+          </Col>
+        </Row>
+
+        <Form.Item label='备注' name='remark' rules={[{ max: 500, message: '不超过500个字' }]}>
+          <TextArea placeholder='请输入备注' maxLength={500} showCount rows={4} />
+        </Form.Item>
+
+        <Form.Item label='附件'>
+          <Z3upFilesRef
+            ref={fileUploadRef}
+            myUrl='cms/show/upload'
+            dirCode='exhibits'
+            moduleId={0}
+          />
+        </Form.Item>
+
+        <Form.Item
+          label='展区设置'
+          name='region'
+          rules={[
+            {
+              validator: (_, value) => {
+                const list = value || []
+                const names = list.map((v: string) => (v || '').trim()).filter(Boolean)
+                if (names.length > 20) {
+                  return Promise.reject(new Error('最多20个展区'))
+                }
+                const unique = new Set(names)
+                if (unique.size !== names.length) {
+                  return Promise.reject(new Error('同一展览内展区名称不能重复'))
+                }
+                return Promise.resolve()
+              }
+            }
+          ]}
+        >
+          <Form.List name='region'>
+            {(fields, { add, remove }) => (
+              <>
+                {fields.map(({ key, name, ...restField }) => (
+                  <div key={key} style={{ display: 'flex', marginBottom: 8, gap: 8 }}>
+                    <Form.Item {...restField} name={name} style={{ flex: 1, marginBottom: 0 }}>
+                      <Input placeholder='请填写展厅或展区名称' />
+                    </Form.Item>
+                    <Form.Item style={{ marginBottom: 0 }}>
+                      {fields.length > 1 ? (
+                        <MinusCircleOutlined
+                          onClick={() => remove(name)}
+                          style={{ fontSize: 18, color: '#ff4d4f' }}
+                        />
+                      ) : null}
+                    </Form.Item>
+                  </div>
+                ))}
+                <Form.Item style={{ marginBottom: 0 }}>
+                  <Button
+                    type='dashed'
+                    onClick={() => add()}
+                    block
+                    icon={<PlusOutlined rev={undefined} />}
+                    disabled={fields.length >= 20}
+                  >
+                    添加展区
+                  </Button>
+                </Form.Item>
+              </>
+            )}
+          </Form.List>
+        </Form.Item>
+      </Form>
+    </Modal>
+  )
+}
+
+export default ExhibitionModal

+ 110 - 1
src/pages/Hexhibits/H1loan/index.tsx

@@ -1,9 +1,118 @@
-import React from 'react'
+import React, { useCallback, useMemo, useRef, useState } from 'react'
 import styles from './index.module.scss'
+import { baseFormData } from '@/pages/Zother/data'
+import TableList from '@/pages/Zother/TableList'
+import { RootState } from '@/store'
+import { H1_APIgetList, H1_APIsave } from '@/store/action/Hexhibits/H1loan'
+import { tableListAuditBtnFu } from '@/utils/authority'
+import { loanTableC } from '@/utils/tableData'
+import { Button } from 'antd'
+import { useSelector } from 'react-redux'
+import ExhibitionModal, { ExhibitionFormValues } from './components/ExhibitionModal'
+import { MessageFu } from '@/utils/message'
+import { openLink } from '@/utils/history'
+
+const H1baseFormData = baseFormData()
+
 function H1loan() {
+  const tableListRef = useRef<any>(null)
+  const [exhibitionModalOpen, setExhibitionModalOpen] = useState(false)
+  // 从仓库拿数据
+  const tableInfo = useSelector((state: RootState) => state.H1loan.tableInfo)
+  const SEARCH_DOM = useMemo(
+    () => [
+      {
+        type: 'time',
+        key: ['dateOpen', 'dateEnd'],
+        placeholder: `入藏日期`
+      },
+      {
+        type: 'input',
+        key: 'searchKey',
+        placeholder: `请输入申请编号、发起人或藏品编号`
+      },
+      {
+        type: 'time',
+        key: ['startTime', 'endTime'],
+        placeholder: `发起日期`
+      }
+    ],
+    []
+  )
+
+  const dataExport = () => {
+    console.log('数据导出了')
+  }
+
+  const tableBtnFu = useCallback((id: number | null, key: string) => {
+    openLink(`/loan_detail/${id}`)
+  }, [])
+
+  const handleExhibitionOk = useCallback(async (values: ExhibitionFormValues) => {
+    const res = await H1_APIsave({
+      ...values,
+      region: values.region.join(',')
+    })
+    if (res?.code === 0) {
+      MessageFu.success('新增成功')
+      tableListRef.current?.getListFu?.()
+    } else {
+      return Promise.reject(res?.message || '新增失败')
+    }
+  }, [])
+
   return (
     <div className={styles.H1loan}>
       <div className='pageTitle'>借展管理</div>
+
+      <TableList
+        ref={tableListRef}
+        baseFormData={H1baseFormData}
+        getListAPI={H1_APIgetList}
+        pageKey='position'
+        tableInfo={tableInfo}
+        columnsTemp={loanTableC}
+        rightBtnWidth={340}
+        yHeight={585}
+        searchDom={SEARCH_DOM}
+        storyTableListToprr={({ clickSearch, resetSelectFu }) => (
+          <>
+            <Button type='primary' onClick={dataExport}>
+              数据导出
+            </Button>
+            <Button type='primary' ghost onClick={() => setExhibitionModalOpen(true)}>
+              新增
+            </Button>
+            <Button type='primary' onClick={clickSearch}>
+              查询
+            </Button>
+            <Button onClick={resetSelectFu}>重置</Button>
+          </>
+        )}
+        storyTableLastBtn={[
+          {
+            title: '操作',
+            render: (item: any) => (
+              <>
+                <Button type='text' onClick={() => tableBtnFu(item.id, '4')}>
+                  查看
+                </Button>
+                {tableListAuditBtnFu(item) ? (
+                  <Button size='small' type='text' onClick={() => tableBtnFu(item.id, '3')}>
+                    审批
+                  </Button>
+                ) : null}
+              </>
+            )
+          }
+        ]}
+      />
+
+      <ExhibitionModal
+        open={exhibitionModalOpen}
+        onOk={handleExhibitionOk}
+        onCancel={() => setExhibitionModalOpen(false)}
+      />
     </div>
   )
 }

+ 6 - 0
src/pages/Layout/data.ts

@@ -434,5 +434,11 @@ export const routerSon: RouterTypeRow[] = [
     name: '人员出入库-详情页',
     path: '/staff_edit/:key/:id',
     Com: React.lazy(() => import('../Fstorehouse/F5staff/F5edit'))
+  },
+  {
+    id: 910,
+    name: '借展管理-详情页',
+    path: '/loan_detail/:id',
+    Com: React.lazy(() => import('../Hexhibits/H1loan/H1detail'))
   }
 ]

+ 125 - 2
src/pages/Zother/EditInput/index.tsx

@@ -1,4 +1,4 @@
-import React, { useCallback, useMemo } from 'react'
+import React, { useCallback, useMemo, useRef, useState } from 'react'
 import classNames from 'classnames'
 import dayjs from 'dayjs'
 import { useInfo } from '../InfoContext'
@@ -7,6 +7,121 @@ import TextArea from 'antd/es/input/TextArea'
 
 const { RangePicker } = DatePicker
 
+const PAGE_SIZE = 20
+
+/** 可搜索+分页的远程 Select,用于 SelectSearch 类型 */
+function SelectSearchRemote({
+  item,
+  isLook,
+  infoRes,
+  dataChangeFu,
+  value
+}: {
+  item: any
+  isLook: boolean
+  infoRes: any
+  dataChangeFu: (val: any, item: any) => void
+  value: any
+}) {
+  const [options, setOptions] = useState<{ label: string; value: number }[]>([])
+  const [loading, setLoading] = useState(false)
+  const [pageNum, setPageNum] = useState(1)
+  const [total, setTotal] = useState(0)
+  const [searchKey, setSearchKey] = useState('')
+  const fetchRef = useRef(0)
+
+  const api = item.api
+  const labelKey = item.labelKey || 'name'
+  const valueKey = item.valueKey || 'id'
+  const searchParam = item.searchParam || 'searchKey'
+  const hasMore = options.length < total
+
+  const fetchList = useCallback(
+    async (page: number, keyword: string, append: boolean) => {
+      if (!api) return
+      const current = ++fetchRef.current
+      setLoading(true)
+      try {
+        const res = await api({
+          pageNum: page,
+          pageSize: PAGE_SIZE,
+          [searchParam]: keyword || undefined
+        })
+        if (current !== fetchRef.current) return
+        const records = res?.data?.records || []
+        const totalRes = res?.data?.total || 0
+        setTotal(totalRes)
+        setOptions(prev => (append ? [...prev, ...records] : records))
+      } finally {
+        if (current === fetchRef.current) setLoading(false)
+      }
+    },
+    [api, searchParam]
+  )
+
+  const onSearch = useCallback(
+    (val: string) => {
+      setSearchKey(val)
+      setPageNum(1)
+      fetchList(1, val, false)
+    },
+    [fetchList]
+  )
+
+  const onPopupScroll = useCallback(
+    (e: React.UIEvent<HTMLDivElement>) => {
+      const { scrollTop, scrollHeight, clientHeight } = e.currentTarget
+      if (scrollHeight - scrollTop - clientHeight < 50 && hasMore && !loading) {
+        const next = pageNum + 1
+        setPageNum(next)
+        fetchList(next, searchKey, true)
+      }
+    },
+    [fetchList, hasMore, loading, pageNum, searchKey]
+  )
+
+  const onDropdownVisibleChange = useCallback(
+    (open: boolean) => {
+      if (open && options.length === 0 && !loading) {
+        fetchList(1, '', false)
+      }
+    },
+    [fetchList, options.length, loading]
+  )
+
+  const opts = useMemo(() => {
+    const list = options.map((r: any) => ({
+      label: r[labelKey] ?? r.name,
+      value: r[valueKey] ?? r.id
+    }))
+    // 编辑回显:有 value 但不在列表中时,用 optionLabel 补充
+    const optionLabel =
+      typeof item.optionLabel === 'function' ? item.optionLabel(infoRes) : item.optionLabel
+    if (value != null && value !== '' && !list.some((o: any) => o.value === value) && optionLabel) {
+      list.unshift({ label: optionLabel, value })
+    }
+    return list
+  }, [options, item, infoRes, value, labelKey, valueKey])
+
+  return (
+    <Select
+      disabled={isLook}
+      showSearch
+      filterOption={false}
+      placeholder={(isLook ? '(空)' : item.placeholder) || '请选择'}
+      value={value}
+      onChange={e => dataChangeFu(e, item)}
+      options={opts}
+      allowClear={!item.must}
+      loading={loading}
+      onSearch={onSearch}
+      onPopupScroll={onPopupScroll}
+      onDropdownVisibleChange={onDropdownVisibleChange}
+      notFoundContent={loading ? '加载中...' : '暂无数据'}
+    />
+  )
+}
+
 type Props = {
   item: any
   isLook: boolean
@@ -39,7 +154,7 @@ function EditInput({ item, isLook, isTow }: Props) {
         )
       } else if (type === 'Cascader') {
         setInfoFu({ ...infoRes, dictIdApply: val ? val.join(',') : '' }, isTow)
-      } else if (type === 'Select') {
+      } else if (['Select', 'SelectSearch'].includes(type)) {
         setInfoFu({ ...infoRes, [key]: val }, isTow)
       } else if (type === 'DatePicker2') {
         const keys = Array.isArray(key) ? key : [key]
@@ -127,6 +242,14 @@ function EditInput({ item, isLook, isTow }: Props) {
             options={item.options}
             allowClear={!item.must}
           />
+        ) : item.type === 'SelectSearch' ? (
+          <SelectSearchRemote
+            item={item}
+            isLook={isLook}
+            infoRes={infoRes}
+            dataChangeFu={dataChangeFu}
+            value={(infoRes as any)[item.key]}
+          />
         ) : item.type === 'DatePicker2' ? (
           (() => {
             const keys = Array.isArray(item.key) ? item.key : [item.key]

+ 11 - 0
src/store/action/Fstorehouse/F1inStorage.ts

@@ -50,6 +50,17 @@ export const F1_APIgetShelfEmptyList = (params: any) => {
   return http.get('cms/orderSite/in/getStorageEmpty', { params })
 }
 
+/**
+ * 展览列表-分页(借展归还用,可搜索展览名称)
+ */
+export const F1_APIgetShowList = (data: {
+  pageNum: number
+  pageSize: number
+  searchKey?: string
+}) => {
+  return http.post('cms/show/pageList', data)
+}
+
 export const F1API_obj = {
   创建订单: () => APIbase('get', 'cms/orderSite/in/create'),
   获取详情: (id: number) => APIbase('get', `cms/orderSite/in/detail/${id}`),

+ 38 - 0
src/store/action/Hexhibits/H1loan.ts

@@ -0,0 +1,38 @@
+import { AppDispatch } from '@/store'
+import http from '@/utils/http'
+
+/**
+ * 分页列表
+ */
+export const H1_APIgetList = (data: any): any => {
+  return async (dispatch: AppDispatch) => {
+    const res = await http.post('cms/show/pageList', data)
+    if (res.code === 0) {
+      const obj = {
+        list: res.data.records,
+        total: res.data.total
+      }
+
+      dispatch({ type: 'H1/getList', payload: obj })
+    }
+  }
+}
+
+export const H1_APIsave = (data: any): any => {
+  return http.post('cms/show/saveEntity', data)
+}
+
+export const H1_APIgetInfo = (id: number): any => {
+  return http.get(`cms/show/detail/${id}`)
+}
+
+export const H1_APIdelete = (id: number): any => {
+  return http.get(`cms/show/remove/${id}`)
+}
+
+/**
+ * 展品清单 - 分页列表
+ */
+export const H1_APIgetShowGoodPage = (data: any): any => {
+  return http.post('cms/show/showGoodPage', data)
+}

+ 28 - 0
src/store/reducer/Hexhibits/H1loan.ts

@@ -0,0 +1,28 @@
+import { Typetable } from '@/pages/Zother/data'
+
+// 初始化状态
+const initState = {
+  // 列表数据
+  tableInfo: {
+    list: [] as Typetable[],
+    total: 0
+  }
+}
+
+// 定义 action 类型
+type Props = {
+  type: 'H1/getList'
+  payload: { list: Typetable[]; total: number }
+}
+
+// reducer
+export default function Reducer(state = initState, action: Props) {
+  switch (action.type) {
+    // 获取列表数据
+    case 'H1/getList':
+      return { ...state, tableInfo: action.payload }
+
+    default:
+      return state
+  }
+}

+ 3 - 1
src/store/reducer/index.ts

@@ -33,7 +33,7 @@ import I5organization from './Isystem/I5organization'
 import I6role from './Isystem/I6role'
 import I7user from './Isystem/I7user'
 import I8log from './Isystem/I8log'
-
+import H1loan from './Hexhibits/H1loan'
 // 所有列表回调需要保存当前筛选数据
 import ZformData from './ZformData'
 
@@ -73,6 +73,8 @@ const rootReducer = combineReducers({
   I7user,
   I8log,
 
+  H1loan,
+
   ZformData
 })
 

+ 5 - 0
src/utils/dataChange.tsx

@@ -168,6 +168,11 @@ export const selectObj = {
   校验结果: [
     { value: 1, label: '成功' },
     { value: 0, label: '失败' }
+  ],
+  展览状态: [
+    { value: 1, label: '展览中' },
+    { value: 2, label: '归还中' },
+    { value: 3, label: '已归还' }
   ]
 }
 

+ 28 - 0
src/utils/tableData.ts

@@ -15,6 +15,7 @@
 //   ];
 
 import { selectObj } from './dataChange'
+import dayjs from 'dayjs'
 
 // 列表页面(大部分相同和复用的)
 export const baseTableC = (val: string) => {
@@ -287,3 +288,30 @@ export const staffTableC = [
   ['txt', '发起日期', 'date'],
   ['select', '申请状态', 'status', selectObj['藏品入库申请状态']]
 ]
+
+// 借展详情-展品清单
+export const showGoodTableC = [
+  ['txt', '藏品登记号', 'num'],
+  ['img', '封面', 'thumb'],
+  ['txtCTag', '藏品标签', 'tagDictId'],
+  ['txt', '藏品名称', 'name'],
+  ['txt', '展区', 'region'],
+  ['txt', '具体位置', 'siteLoc'],
+  ['select', '展览状态', 'status', selectObj['展览状态']]
+]
+
+// 展品管理
+export const loanTableC = [
+  ['txt', '展览名称', 'name'],
+  ['txt', '展览类型', 'type'],
+  [
+    'custom',
+    '起止日期',
+    (item: any) =>
+      !item.dateOpen && !item.dateEnd
+        ? '长期'
+        : `${dayjs(item.dateOpen).format('YYYY年MM月DD日')} - ${dayjs(item.dateEnd).format('YYYY年MM月DD日')}`
+  ],
+  ['txt', '相关展区', 'region'],
+  ['txt', '相关展品', 'creatorName']
+]