index.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619
  1. <script setup lang="tsx">
  2. import {
  3. NButton,
  4. NDataTable,
  5. NCard,
  6. NForm,
  7. NFormItem,
  8. NInput,
  9. NSelect,
  10. NPopconfirm,
  11. useMessage,
  12. useModal,
  13. NPagination,
  14. NButtonGroup,
  15. NDropdown,
  16. NTag,
  17. NNumberAnimation,
  18. NAlert,
  19. } from 'naive-ui'
  20. import { defineComponent, reactive, ref, useTemplateRef, nextTick } from 'vue'
  21. import { ScrollContainer } from '@/components'
  22. import { useInjection, useComponentModifier, useResettableReactive } from '@/composables'
  23. import { mediaQueryInjectionKey } from '@/injection'
  24. import ActionModal from './ActionModal.vue'
  25. import type { DataTableColumns, PaginationProps, FormRules, DropdownProps } from 'naive-ui'
  26. import type { PropType } from 'vue'
  27. export interface UserInfo {
  28. address: string
  29. age: number | null
  30. company: string
  31. email: string
  32. fullName: string
  33. number: number
  34. id: number | string
  35. phone: string
  36. registerDate: null | null
  37. sex: string | null
  38. children: UserInfo[]
  39. }
  40. defineOptions({
  41. name: 'DataTable',
  42. })
  43. const { isMaxMd, isMaxLg } = useInjection(mediaQueryInjectionKey)
  44. const formRef = useTemplateRef<InstanceType<typeof NForm>>('formRef')
  45. const dataTableRef = useTemplateRef<InstanceType<typeof NDataTable>>('dataTableRef')
  46. const message = useMessage()
  47. const modal = useModal()
  48. const { getPopconfirmModifier } = useComponentModifier()
  49. const [form, , resetForm] = useResettableReactive<Partial<UserInfo>>({
  50. fullName: '',
  51. sex: null,
  52. phone: '',
  53. company: '',
  54. })
  55. const rules: FormRules = {
  56. sex: {
  57. required: true,
  58. message: '请选择性别',
  59. },
  60. }
  61. const sexOptions = [
  62. { label: '男', value: '男' },
  63. { label: '女', value: '女' },
  64. ]
  65. const isRequestLoading = ref(false)
  66. const enableStriped = ref(false)
  67. const enableScrollX = ref(true)
  68. const enableSingleLine = ref(true)
  69. const enableContextmenu = ref(true)
  70. const showDropdown = ref(false)
  71. const contextmenuId = ref<number | string | null>(null)
  72. const dataList = ref<UserInfo[]>([])
  73. const checkedRowKeys = ref<Array<number | string>>([])
  74. const CellActions = (row: UserInfo) => (
  75. <div class='flex gap-2'>
  76. <NButton
  77. secondary
  78. type='primary'
  79. size='small'
  80. onClick={() => createOrEditData(row)}
  81. >
  82. 编辑
  83. </NButton>
  84. <NPopconfirm
  85. {...getPopconfirmModifier()}
  86. positiveText='确定'
  87. negativeText='取消'
  88. onPositiveClick={() => {
  89. message.success('点击了删除')
  90. }}
  91. >
  92. {{
  93. default: () => '确认删除吗?',
  94. trigger: () => (
  95. <NButton
  96. secondary
  97. type='error'
  98. size='small'
  99. >
  100. 删除
  101. </NButton>
  102. ),
  103. }}
  104. </NPopconfirm>
  105. </div>
  106. )
  107. const ShowOrEdit = defineComponent({
  108. name: 'ShowOrEdit',
  109. props: {
  110. value: {
  111. type: String,
  112. required: true,
  113. },
  114. onUpdateValue: {
  115. type: Function as PropType<(value: string) => void>,
  116. },
  117. },
  118. setup(props) {
  119. const isEdit = ref(false)
  120. const inputRef = ref<InstanceType<typeof NInput> | null>(null)
  121. const inputValue = ref(props.value)
  122. function onClick() {
  123. isEdit.value = true
  124. nextTick(() => {
  125. inputRef.value?.focus()
  126. })
  127. }
  128. function onBlur() {
  129. if (!inputValue.value.trim()) {
  130. message.error('为空就再也编辑不了了')
  131. inputValue.value = props.value
  132. }
  133. isEdit.value = false
  134. props.onUpdateValue?.(inputValue.value)
  135. }
  136. return () => (
  137. <div onClick={onClick}>
  138. {isEdit.value ? (
  139. <NInput
  140. ref={inputRef}
  141. value={inputValue.value}
  142. clearable
  143. onUpdateValue={(value) => {
  144. inputValue.value = value
  145. }}
  146. onBlur={onBlur}
  147. />
  148. ) : (
  149. <span>{props.value}</span>
  150. )}
  151. </div>
  152. )
  153. },
  154. })
  155. const columns: DataTableColumns<UserInfo> = [
  156. {
  157. type: 'selection',
  158. options: [
  159. 'all',
  160. 'none',
  161. {
  162. label: '选中前 3 行可选数据',
  163. key: 'f2',
  164. onSelect: (pageData) => {
  165. checkedRowKeys.value = pageData
  166. .filter((row) => row.number < 500)
  167. .map((row) => row.id)
  168. .slice(0, 3)
  169. },
  170. },
  171. ],
  172. disabled: (row) => {
  173. return ['4', '5', '8', '9'].includes(String(row.number)[0])
  174. },
  175. },
  176. {
  177. key: 'number',
  178. title: '编号',
  179. width: 100,
  180. },
  181. {
  182. key: 'fullName',
  183. width: 160,
  184. title: () => {
  185. return (
  186. <div class='flex items-center gap-x-2'>
  187. <span>姓名</span>
  188. <span class='iconify ph--pencil-simple-line' />
  189. </div>
  190. )
  191. },
  192. render: (row, index) => (
  193. <ShowOrEdit
  194. value={row.fullName}
  195. onUpdateValue={(value) => {
  196. dataList.value[index].fullName = value
  197. }}
  198. />
  199. ),
  200. },
  201. {
  202. key: 'sex',
  203. title: '性别',
  204. width: 100,
  205. render: (row) => {
  206. const isMale = row.sex === '男'
  207. return (
  208. <div>
  209. <span
  210. class={
  211. isMale
  212. ? 'iconify text-sky-500 ph--gender-male'
  213. : 'iconify text-pink-500 ph--gender-female'
  214. }
  215. />
  216. </div>
  217. )
  218. },
  219. },
  220. {
  221. key: 'age',
  222. title: '年龄',
  223. width: 100,
  224. render: (row) => {
  225. const age = row.age ?? 0
  226. return (
  227. <NTag
  228. bordered={false}
  229. size='small'
  230. type={age > 50 ? 'error' : age > 40 ? 'warning' : age > 30 ? 'info' : 'success'}
  231. >
  232. {row.age}
  233. </NTag>
  234. )
  235. },
  236. },
  237. {
  238. key: 'email',
  239. title: '邮箱',
  240. },
  241. {
  242. key: 'phone',
  243. title: '电话',
  244. },
  245. {
  246. key: 'address',
  247. title: '地址',
  248. },
  249. {
  250. key: 'company',
  251. title: '公司',
  252. },
  253. {
  254. key: 'registerDate',
  255. title: '注册日期',
  256. },
  257. {
  258. width: 140,
  259. key: 'actions',
  260. align: 'center',
  261. title: '操作',
  262. fixed: 'right',
  263. render: (row) => <CellActions {...row} />,
  264. },
  265. ]
  266. function rowProps(row: UserInfo) {
  267. return {
  268. onContextmenu: (e: MouseEvent) => {
  269. e.preventDefault()
  270. showDropdown.value = false
  271. nextTick().then(() => {
  272. contextmenuId.value = row.number
  273. showDropdown.value = true
  274. dropdownOptions.x = e.clientX
  275. dropdownOptions.y = e.clientY
  276. })
  277. },
  278. }
  279. }
  280. const pagination = reactive<PaginationProps>({
  281. page: 1,
  282. pageSize: 10,
  283. showSizePicker: true,
  284. pageSizes: [10, 20, 50, 100],
  285. itemCount: 0,
  286. showQuickJumper: true,
  287. showQuickJumpDropdown: true,
  288. onUpdatePage: (page: number) => {
  289. pagination.page = page
  290. getDataList()
  291. },
  292. onUpdatePageSize: (pageSize: number) => {
  293. pagination.pageSize = pageSize
  294. pagination.page = 1
  295. getDataList()
  296. },
  297. })
  298. const prevUserListTotal = ref(0)
  299. const paginationPrefix: PaginationProps['prefix'] = (info) => {
  300. const { itemCount } = info
  301. return (
  302. itemCount && (
  303. <div>
  304. <span>总&nbsp;</span>
  305. <NNumberAnimation
  306. from={prevUserListTotal.value}
  307. to={itemCount}
  308. onFinish={() => {
  309. prevUserListTotal.value = itemCount
  310. }}
  311. />
  312. <span>&nbsp;条</span>
  313. </div>
  314. )
  315. )
  316. }
  317. const dropdownOptions = reactive<DropdownProps>({
  318. x: 0,
  319. y: 0,
  320. options: [
  321. {
  322. label: '编辑',
  323. key: 'edit',
  324. },
  325. {
  326. label: () => <span class='text-red-500'>删除</span>,
  327. key: 'delete',
  328. },
  329. ],
  330. onClickoutside: () => {
  331. showDropdown.value = false
  332. },
  333. onSelect: () => {
  334. message.info(`id: ${contextmenuId.value}`)
  335. showDropdown.value = false
  336. },
  337. })
  338. async function request(pageSize: number): Promise<{ data: UserInfo[]; total: number }> {
  339. return fetch(`https://lithe-admin-serverless.havenovelgod.com/api/faker?limit=${pageSize}`, {
  340. method: 'GET',
  341. }).then((res) => res.json())
  342. }
  343. function inputOnlyAllowNumber(value: string) {
  344. return !value || /^\d+$/.test(value)
  345. }
  346. function createOrEditData(data?: UserInfo) {
  347. const title = data ? '编辑数据' : '新增数据'
  348. const handleSubmitClick = () => {
  349. message.success('点击了提交')
  350. m.destroy()
  351. }
  352. function handleUpdateClick() {
  353. message.info('点击了更新')
  354. m.destroy()
  355. }
  356. function handleCancelClick() {
  357. m.destroy()
  358. }
  359. const m = modal.create({
  360. title,
  361. preset: 'card',
  362. draggable: true,
  363. style: {
  364. width: '500px',
  365. ...(isMaxMd.value ? { marginInline: '16px' } : {}),
  366. },
  367. content: () => (
  368. <ActionModal
  369. data={data || {}}
  370. onSubmit={handleSubmitClick}
  371. onUpdate={handleUpdateClick}
  372. onCancel={handleCancelClick}
  373. />
  374. ),
  375. })
  376. }
  377. const handleQueryClick = () => {
  378. formRef.value?.validate((errors) => {
  379. if (!errors) {
  380. getDataList()
  381. }
  382. })
  383. }
  384. function handleDownloadCsvClick() {
  385. if (!dataTableRef.value) return
  386. dataTableRef.value.downloadCsv()
  387. }
  388. async function getDataList() {
  389. isRequestLoading.value = true
  390. const pageSize = pagination.pageSize || 10
  391. const res = await request(pageSize).finally(() => {
  392. isRequestLoading.value = false
  393. })
  394. dataList.value = res.data
  395. pagination.itemCount = 300
  396. }
  397. getDataList()
  398. </script>
  399. <template>
  400. <ScrollContainer
  401. wrapper-class="flex flex-col gap-y-2"
  402. :scrollable="isMaxLg"
  403. >
  404. <NAlert
  405. type="info"
  406. closable
  407. >
  408. 一个数据表格的例子,不算复杂,也许对你有帮助
  409. </NAlert>
  410. <NCard
  411. :size="isMaxMd ? 'small' : undefined"
  412. class="flex-1"
  413. content-class="flex flex-col"
  414. >
  415. <div class="mb-2 flex justify-end gap-x-4 max-xl:mb-4 max-xl:flex-wrap">
  416. <NForm
  417. ref="formRef"
  418. :model="form"
  419. :rules="rules"
  420. :inline="!isMaxLg"
  421. label-placement="left"
  422. class="max-lg:w-full max-lg:flex-col"
  423. :label-width="isMaxLg ? 70 : undefined"
  424. >
  425. <NFormItem
  426. label="姓名"
  427. path="fullName"
  428. >
  429. <NInput
  430. v-model:value="form.fullName"
  431. clearable
  432. />
  433. </NFormItem>
  434. <NFormItem
  435. label="性别"
  436. path="sex"
  437. >
  438. <NSelect
  439. v-model:value="form.sex"
  440. :options="sexOptions"
  441. style="min-width: 88px"
  442. clearable
  443. />
  444. </NFormItem>
  445. <NFormItem
  446. label="联系方式"
  447. path="phone"
  448. >
  449. <NInput
  450. v-model:value="form.phone"
  451. clearable
  452. :allow-input="inputOnlyAllowNumber"
  453. />
  454. </NFormItem>
  455. <NFormItem
  456. label="公司"
  457. path="company"
  458. >
  459. <NInput
  460. v-model:value="form.company"
  461. clearable
  462. />
  463. </NFormItem>
  464. </NForm>
  465. <div class="flex gap-2">
  466. <NButton
  467. type="success"
  468. @click="createOrEditData()"
  469. >
  470. <template #icon>
  471. <span class="iconify ph--plus-circle" />
  472. </template>
  473. 新增数据
  474. </NButton>
  475. <NButton
  476. type="info"
  477. @click="handleQueryClick"
  478. :loading="isRequestLoading"
  479. :disabled="isRequestLoading"
  480. >
  481. <template #icon>
  482. <span class="iconify ph--magnifying-glass" />
  483. </template>
  484. 查询
  485. </NButton>
  486. <NButton
  487. type="warning"
  488. @click="resetForm"
  489. >
  490. <template #icon>
  491. <span class="iconify ph--arrow-clockwise" />
  492. </template>
  493. 重置
  494. </NButton>
  495. </div>
  496. </div>
  497. <div class="flex flex-1 flex-col">
  498. <NDataTable
  499. class="flex-1"
  500. ref="dataTableRef"
  501. v-model:checked-row-keys="checkedRowKeys"
  502. :remote="true"
  503. :flex-height="!isMaxLg"
  504. :scroll-x="enableScrollX ? 1800 : 0"
  505. :columns="columns"
  506. :data="dataList"
  507. :row-key="(row) => row.id"
  508. :loading="isRequestLoading"
  509. :striped="enableStriped"
  510. :row-props="rowProps"
  511. :single-line="enableSingleLine"
  512. />
  513. <div class="mt-3 flex items-end justify-between max-xl:flex-col max-xl:gap-y-2">
  514. <div class="flex items-center justify-between gap-x-3">
  515. <span>已选择&nbsp;{{ checkedRowKeys.length }}&nbsp; 条</span>
  516. <NButtonGroup
  517. size="small"
  518. :ghost="true"
  519. >
  520. <NButton
  521. @click="enableStriped = !enableStriped"
  522. :type="enableStriped ? 'primary' : 'default'"
  523. secondary
  524. >
  525. 条纹风格
  526. </NButton>
  527. <NButton
  528. @click="enableSingleLine = !enableSingleLine"
  529. :type="!enableSingleLine ? 'primary' : 'default'"
  530. secondary
  531. >
  532. 单线风格
  533. </NButton>
  534. <NButton
  535. @click="enableScrollX = !enableScrollX"
  536. :type="enableScrollX ? 'primary' : 'default'"
  537. secondary
  538. >
  539. 横向滚动
  540. </NButton>
  541. <NButton
  542. v-show="!isMaxMd"
  543. @click="enableContextmenu = !enableContextmenu"
  544. :type="enableContextmenu ? 'primary' : 'default'"
  545. secondary
  546. >
  547. 右键菜单
  548. </NButton>
  549. <NButton
  550. v-show="!isMaxMd"
  551. @click="handleDownloadCsvClick"
  552. secondary
  553. type="info"
  554. >
  555. 下载为Csv
  556. </NButton>
  557. </NButtonGroup>
  558. </div>
  559. <NPagination
  560. v-bind="pagination"
  561. :prefix="paginationPrefix"
  562. :page-slot="isMaxMd ? 5 : undefined"
  563. :show-quick-jump-dropdown="!isMaxMd"
  564. :show-quick-jumper="!isMaxMd"
  565. :show-size-picker="!isMaxMd"
  566. />
  567. </div>
  568. </div>
  569. </NCard>
  570. <NDropdown
  571. placement="bottom-start"
  572. trigger="manual"
  573. v-bind="dropdownOptions"
  574. :show="enableContextmenu && showDropdown"
  575. />
  576. </ScrollContainer>
  577. </template>