PageContent.vue 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005
  1. <template>
  2. <el-card
  3. class="data-table flex-1 min-h-0"
  4. :shadow="config.cardShadow ?? 'never'"
  5. :class="contentConfig.cardClass"
  6. :body-style="cardBodyStyle"
  7. >
  8. <template v-if="slots.header" #header>
  9. <slot name="header" />
  10. </template>
  11. <!-- 表格工具栏:#toolbar 可完全自定义;默认左右分栏 -->
  12. <div
  13. v-if="
  14. config.showToolbar !== false &&
  15. (slots.toolbar || toolbarLeftBtn.length > 0 || toolbarRightBtn.length > 0)
  16. "
  17. class="data-table__toolbar"
  18. >
  19. <slot
  20. name="toolbar"
  21. :toolbar-left="toolbarLeftBtn"
  22. :toolbar-right="toolbarRightBtn"
  23. :on-toolbar="handleToolbar"
  24. :remove-ids="removeIds"
  25. :cols="cols"
  26. >
  27. <CrudToolbarLeft
  28. v-if="toolbarLeftBtn.length > 0"
  29. :remove-ids="removeIds"
  30. :config-buttons="toolbarLeftBtn"
  31. @toolbar="handleToolbar"
  32. />
  33. <div class="data-table__toolbar--right">
  34. <CrudToolbarRight :buttons="toolbarRightBtn" :cols="cols" :on-toolbar="handleToolbar" />
  35. </div>
  36. </slot>
  37. </div>
  38. <!-- 列表:默认内置 el-table;传入 #table 插槽可完全自定义(普通表 / 树表等) -->
  39. <div class="data-table__content">
  40. <slot
  41. name="table"
  42. :data="pageData"
  43. :loading="loading"
  44. :table-ref="tableRef"
  45. :on-selection-change="handleSelectionChange"
  46. :pagination="pagination"
  47. >
  48. <el-table
  49. ref="tableRef"
  50. v-loading="loading"
  51. v-bind="contentConfig.table"
  52. :data="pageData"
  53. :row-key="pk"
  54. class="flex-1"
  55. @selection-change="handleSelectionChange"
  56. @filter-change="handleFilterChange"
  57. >
  58. <template v-for="col in cols" :key="col.prop">
  59. <el-table-column v-if="col.show" v-bind="col">
  60. <template #default="scope">
  61. <!-- 显示图片 -->
  62. <template v-if="col.templet === 'image'">
  63. <template v-if="col.prop">
  64. <template v-if="Array.isArray(scope.row[col.prop])">
  65. <template v-for="(item, index) in scope.row[col.prop]" :key="item">
  66. <el-image
  67. :src="item"
  68. :preview-src-list="scope.row[col.prop]"
  69. :initial-index="Number(index)"
  70. :preview-teleported="true"
  71. :style="`width: ${col.imageWidth ?? 40}px; height: ${col.imageHeight ?? 40}px`"
  72. />
  73. </template>
  74. </template>
  75. <template v-else>
  76. <el-image
  77. :src="scope.row[col.prop]"
  78. :preview-src-list="[scope.row[col.prop]]"
  79. :preview-teleported="true"
  80. :style="`width: ${col.imageWidth ?? 40}px; height: ${col.imageHeight ?? 40}px`"
  81. />
  82. </template>
  83. </template>
  84. </template>
  85. <!-- 根据行的selectList属性返回对应列表值 -->
  86. <template v-else-if="col.templet === 'list'">
  87. <template v-if="col.prop">
  88. {{ (col.selectList ?? {})[scope.row[col.prop]] }}
  89. </template>
  90. </template>
  91. <!-- 格式化显示链接 -->
  92. <template v-else-if="col.templet === 'url'">
  93. <template v-if="col.prop">
  94. <el-link type="primary" :href="scope.row[col.prop]" target="_blank">
  95. {{ scope.row[col.prop] }}
  96. </el-link>
  97. </template>
  98. </template>
  99. <!-- 生成开关组件 -->
  100. <template v-else-if="col.templet === 'switch'">
  101. <template v-if="col.prop">
  102. <!-- pageData.length>0: 解决el-switch组件会在表格初始化的时候触发一次change事件 -->
  103. <el-switch
  104. v-model="scope.row[col.prop]"
  105. :active-value="col.activeValue ?? 1"
  106. :inactive-value="col.inactiveValue ?? 0"
  107. :inline-prompt="true"
  108. :active-text="col.activeText ?? ''"
  109. :inactive-text="col.inactiveText ?? ''"
  110. :validate-event="false"
  111. :disabled="col.disabled"
  112. @change="
  113. pageData.length > 0 &&
  114. handleModify(col.prop, scope.row[col.prop], scope.row)
  115. "
  116. />
  117. </template>
  118. </template>
  119. <!-- 生成输入框组件 -->
  120. <template v-else-if="col.templet === 'input'">
  121. <template v-if="col.prop">
  122. <el-input
  123. v-model="scope.row[col.prop]"
  124. :type="col.inputType ?? 'text'"
  125. :disabled="col.disabled"
  126. @blur="handleModify(col.prop, scope.row[col.prop], scope.row)"
  127. />
  128. </template>
  129. </template>
  130. <!-- 格式化为价格 -->
  131. <template v-else-if="col.templet === 'price'">
  132. <template v-if="col.prop">
  133. {{ `${col.priceFormat ?? "¥"}${scope.row[col.prop]}` }}
  134. </template>
  135. </template>
  136. <!-- 格式化为百分比 -->
  137. <template v-else-if="col.templet === 'percent'">
  138. <template v-if="col.prop">{{ scope.row[col.prop] }}%</template>
  139. </template>
  140. <!-- 显示图标 -->
  141. <template v-else-if="col.templet === 'icon'">
  142. <template v-if="col.prop">
  143. <template v-if="scope.row[col.prop].startsWith('el-icon-')">
  144. <el-icon>
  145. <component :is="scope.row[col.prop].replace('el-icon-', '')" />
  146. </el-icon>
  147. </template>
  148. <template v-else>
  149. <div class="i-svg:{{ scope.row[col.prop] }}" />
  150. </template>
  151. </template>
  152. </template>
  153. <!-- 格式化时间 -->
  154. <template v-else-if="col.templet === 'date'">
  155. <template v-if="col.prop">
  156. {{
  157. scope.row[col.prop]
  158. ? useDateFormat(
  159. scope.row[col.prop],
  160. col.dateFormat ?? "YYYY-MM-DD HH:mm:ss"
  161. ).value
  162. : ""
  163. }}
  164. </template>
  165. </template>
  166. <!-- 列操作栏 -->
  167. <template v-else-if="col.templet === 'tool'">
  168. <template v-for="(btn, index) in tableToolbarBtn" :key="index">
  169. <el-button
  170. v-if="btn.render === undefined || btn.render(scope.row)"
  171. v-hasPerm="btn.perm ?? '*:*:*'"
  172. v-bind="btn.attrs"
  173. @click="
  174. handleOperate({
  175. name: btn.name,
  176. row: scope.row,
  177. column: scope.column,
  178. $index: scope.$index,
  179. })
  180. "
  181. >
  182. {{ btn.text }}
  183. </el-button>
  184. </template>
  185. </template>
  186. <!-- 自定义 -->
  187. <template v-else-if="col.templet === 'custom'">
  188. <slot :name="col.slotName ?? col.prop" :prop="col.prop" v-bind="scope" />
  189. </template>
  190. </template>
  191. </el-table-column>
  192. </template>
  193. </el-table>
  194. </slot>
  195. </div>
  196. <template v-if="showPagination" #footer>
  197. <el-scrollbar :class="['h-8!', { 'flex-x-end': contentConfig?.pagePosition === 'right' }]">
  198. <el-pagination
  199. v-bind="pagination"
  200. @size-change="handleSizeChange"
  201. @current-change="handleCurrentChange"
  202. />
  203. </el-scrollbar>
  204. </template>
  205. <!-- 导出弹窗 -->
  206. <EnhancedDialog
  207. v-model="exportsModalVisible"
  208. title="导出数据"
  209. width="600px"
  210. dialog-class="curd-embed-dialog"
  211. modal-class="curd-embed-dialog"
  212. @close="handleCloseExportsModal"
  213. >
  214. <!-- 滚动 -->
  215. <el-scrollbar max-height="60vh">
  216. <!-- 表单 -->
  217. <el-form
  218. ref="exportsFormRef"
  219. style="padding-right: var(--el-dialog-padding-primary)"
  220. :model="exportsFormData"
  221. :rules="exportsFormRules"
  222. >
  223. <el-form-item label="文件名" prop="filename">
  224. <el-input v-model="exportsFormData.filename" clearable />
  225. </el-form-item>
  226. <el-form-item label="工作表名" prop="sheetname">
  227. <el-input v-model="exportsFormData.sheetname" clearable />
  228. </el-form-item>
  229. <el-form-item label="数据源" prop="origin">
  230. <el-select v-model="exportsFormData.origin">
  231. <el-option label="当前数据 (当前页的数据)" :value="ExportsOriginEnum.CURRENT" />
  232. <el-option
  233. label="选中数据 (所有选中的数据)"
  234. :value="ExportsOriginEnum.SELECTED"
  235. :disabled="selectionData.length <= 0"
  236. />
  237. <el-option
  238. label="全量数据 (所有分页的数据)"
  239. :value="ExportsOriginEnum.REMOTE"
  240. :disabled="contentConfig.exportsAction === undefined"
  241. />
  242. </el-select>
  243. </el-form-item>
  244. <el-form-item label="字段" prop="fields">
  245. <el-checkbox-group v-model="exportsFormData.fields">
  246. <template v-for="col in cols" :key="col.prop">
  247. <el-checkbox v-if="col.prop" :value="col.prop" :label="col.label" />
  248. </template>
  249. </el-checkbox-group>
  250. </el-form-item>
  251. </el-form>
  252. </el-scrollbar>
  253. <!-- 弹窗底部操作按钮 -->
  254. <template #footer>
  255. <div style="padding-right: var(--el-dialog-padding-primary)">
  256. <el-button type="primary" @click="handleExportsSubmit">确 定</el-button>
  257. <el-button @click="handleCloseExportsModal">取 消</el-button>
  258. </div>
  259. </template>
  260. </EnhancedDialog>
  261. <!-- 导入弹窗 -->
  262. <EnhancedDialog
  263. v-model="importModalVisible"
  264. title="导入数据"
  265. width="600px"
  266. dialog-class="curd-embed-dialog"
  267. modal-class="curd-embed-dialog"
  268. @close="handleCloseImportModal"
  269. >
  270. <!-- 滚动 -->
  271. <el-scrollbar max-height="60vh">
  272. <!-- 表单 -->
  273. <el-form
  274. ref="importFormRef"
  275. style="padding-right: var(--el-dialog-padding-primary)"
  276. :model="importFormData"
  277. :rules="importFormRules"
  278. >
  279. <el-form-item label="文件名" prop="files">
  280. <el-upload
  281. ref="uploadRef"
  282. v-model:file-list="importFormData.files"
  283. class="w-full"
  284. accept="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel"
  285. :drag="true"
  286. :limit="1"
  287. :auto-upload="false"
  288. :on-exceed="handleFileExceed"
  289. >
  290. <el-icon class="el-icon--upload"><upload-filled /></el-icon>
  291. <div class="el-upload__text">
  292. <span>将文件拖到此处,或</span>
  293. <em>点击上传</em>
  294. </div>
  295. <template #tip>
  296. <div class="el-upload__tip">
  297. *.xlsx / *.xls
  298. <el-link
  299. v-if="contentConfig.importTemplate"
  300. type="primary"
  301. icon="download"
  302. underline="never"
  303. @click="handleDownloadTemplate"
  304. >
  305. 下载模板
  306. </el-link>
  307. </div>
  308. </template>
  309. </el-upload>
  310. </el-form-item>
  311. </el-form>
  312. </el-scrollbar>
  313. <!-- 弹窗底部操作按钮 -->
  314. <template #footer>
  315. <div style="padding-right: var(--el-dialog-padding-primary)">
  316. <el-button
  317. type="primary"
  318. :disabled="importFormData.files.length === 0"
  319. @click="handleImportSubmit"
  320. >
  321. 确 定
  322. </el-button>
  323. <el-button @click="handleCloseImportModal">取 消</el-button>
  324. </div>
  325. </template>
  326. </EnhancedDialog>
  327. </el-card>
  328. </template>
  329. <script setup lang="ts">
  330. import { useDateFormat, useThrottleFn } from "@vueuse/core";
  331. import {
  332. ElMessage,
  333. genFileId,
  334. type FormInstance,
  335. type FormRules,
  336. type UploadInstance,
  337. type UploadRawFile,
  338. type UploadUserFile,
  339. type TableInstance,
  340. } from "element-plus";
  341. import ExcelJS from "exceljs";
  342. import { reactive, ref, computed, nextTick, useSlots } from "vue";
  343. import CrudToolbarLeft from "./CrudToolbarLeft.vue";
  344. import CrudToolbarRight from "./CrudToolbarRight.vue";
  345. import EnhancedDialog from "./EnhancedDialog.vue";
  346. import type { IContentConfig, IObject, IOperateData } from "./types";
  347. import type { IToolsButton } from "./types";
  348. const slots = useSlots();
  349. const cardBodyStyle = {
  350. display: "flex",
  351. flex: 1,
  352. flexDirection: "column" as const,
  353. minHeight: 0,
  354. overflow: "hidden",
  355. padding: "16px 20px",
  356. };
  357. // 定义接收的属性
  358. const props = defineProps<{ contentConfig: IContentConfig }>();
  359. // 定义自定义事件
  360. const emit = defineEmits<{
  361. addClick: [];
  362. exportClick: [];
  363. searchClick: [];
  364. toolbarClick: [name: string];
  365. editClick: [row: IObject];
  366. filterChange: [data: IObject];
  367. operateClick: [data: IOperateData];
  368. }>();
  369. // 表格工具栏按钮配置
  370. const config = computed(() => props.contentConfig);
  371. const buttonConfig = reactive<Record<string, IObject>>({
  372. add: { text: "新增", attrs: { icon: "plus", type: "info" }, perm: "add" },
  373. delete: { text: "删除", attrs: { icon: "delete", type: "danger" }, perm: "delete" },
  374. /** 右侧圆形工具条:与业务页手写工具栏(如 user、demo)语义色一致 */
  375. import: { text: "导入", attrs: { icon: "upload", type: "default" }, perm: "import" },
  376. export: { text: "导出", attrs: { icon: "download", type: "default" }, perm: "export" },
  377. refresh: { text: "刷新", attrs: { icon: "refresh", type: "default" }, perm: "*:*:*" },
  378. filter: { text: "筛选列", attrs: { icon: "operation", type: "default" }, perm: "*:*:*" },
  379. search: { text: "搜索", attrs: { icon: "search", type: "info" }, perm: "search" },
  380. imports: { text: "批量导入", attrs: { icon: "upload", type: "default" }, perm: "imports" },
  381. exports: { text: "批量导出", attrs: { icon: "download", type: "default" }, perm: "exports" },
  382. view: { text: "查看", attrs: { icon: "view", type: "primary" }, perm: "view" },
  383. edit: { text: "编辑", attrs: { icon: "edit", type: "primary" }, perm: "edit" },
  384. });
  385. // 主键
  386. const pk = props.contentConfig.pk ?? "id";
  387. // 权限名称前缀
  388. const authPrefix = computed(() => props.contentConfig.permPrefix);
  389. // 获取按钮权限标识
  390. function getButtonPerm(action: string): string | null {
  391. // 如果action已经包含完整路径(包含冒号),则直接使用
  392. if (action.includes(":")) {
  393. return action;
  394. }
  395. // 否则使用权限前缀组合
  396. return authPrefix.value ? `${authPrefix.value}:${action}` : null;
  397. }
  398. // 检查是否有权限
  399. // function hasButtonPerm(action: string): boolean {
  400. // const perm = getButtonPerm(action);
  401. // // 如果没有设置权限标识,则默认具有权限
  402. // if (!perm) return true;
  403. // return hasAuth(perm);
  404. // }
  405. // 创建工具栏按钮
  406. function createToolbar(toolbar: Array<string | IToolsButton>, attr = {}) {
  407. return toolbar.map((item) => {
  408. const isString = typeof item === "string";
  409. const name = isString ? item : item?.name || "";
  410. const base = (isString ? buttonConfig[item] : buttonConfig[name]) as IObject | undefined;
  411. return {
  412. name,
  413. text: isString ? buttonConfig[item].text : (item?.text ?? base?.text),
  414. // 对象写法(如 { name: 'refresh', perm: 'refresh' })需合并 buttonConfig 默认 attrs,否则无 icon/type
  415. attrs: {
  416. ...attr,
  417. ...(isString ? buttonConfig[item].attrs : { ...base?.attrs, ...item?.attrs }),
  418. },
  419. render: isString ? undefined : (item?.render ?? undefined),
  420. perm: isString
  421. ? getButtonPerm(buttonConfig[item].perm)
  422. : item?.perm
  423. ? getButtonPerm(item.perm as string)
  424. : "*:*:*",
  425. };
  426. });
  427. }
  428. // 左侧工具栏按钮
  429. const toolbarLeftBtn = computed(() => {
  430. if (!config.value.toolbar || config.value.toolbar.length === 0) return [];
  431. return createToolbar(config.value.toolbar, {});
  432. });
  433. const hideColumnFilter = computed(() => {
  434. if (config.value.hideColumnFilter === true) return true;
  435. if (config.value.hideColumnFilter === false) return false;
  436. return Boolean(slots.table);
  437. });
  438. // 右侧工具栏按钮
  439. const toolbarRightBtn = computed(() => {
  440. if (!config.value.defaultToolbar || config.value.defaultToolbar.length === 0) return [];
  441. const raw = createToolbar(config.value.defaultToolbar, { circle: true });
  442. if (hideColumnFilter.value) {
  443. return raw.filter((b) => b.name !== "filter");
  444. }
  445. return raw;
  446. });
  447. // 表格操作工具栏(优先使用 templet 为 tool 的列,避免仅数据列时取错最后一列)
  448. const toolColumn = computed(
  449. () => config.value.cols.find((c) => c.templet === "tool") ?? config.value.cols.at(-1)
  450. );
  451. const tableToolbar = computed(() => toolColumn.value?.operat ?? ["edit", "delete"]);
  452. const tableToolbarBtn = computed(() =>
  453. createToolbar(tableToolbar.value, { link: true, size: "small" })
  454. );
  455. // 表格列
  456. const cols = ref(
  457. props.contentConfig.cols.map((col) => {
  458. if (col.initFn) {
  459. col.initFn(col);
  460. }
  461. if (col.show === undefined) {
  462. col.show = true;
  463. }
  464. if (col.prop !== undefined && col.columnKey === undefined && col["column-key"] === undefined) {
  465. col.columnKey = col.prop;
  466. }
  467. if (
  468. col.type === "selection" &&
  469. col.reserveSelection === undefined &&
  470. col["reserve-selection"] === undefined
  471. ) {
  472. // 配合表格row-key实现跨页多选
  473. col.reserveSelection = true;
  474. }
  475. return col;
  476. })
  477. );
  478. // 加载状态
  479. const loading = ref(false);
  480. // 列表数据
  481. const pageData = ref<IObject[]>([]);
  482. // 显示分页
  483. const showPagination = props.contentConfig.pagination !== false;
  484. // 分页配置
  485. const defaultPagination = {
  486. background: true,
  487. layout: "total, sizes, prev, pager, next, jumper",
  488. pageSize: 20,
  489. pageSizes: [10, 20, 30, 50],
  490. total: 0,
  491. currentPage: 1,
  492. };
  493. const pagination = reactive(
  494. typeof props.contentConfig.pagination === "object"
  495. ? { ...defaultPagination, ...props.contentConfig.pagination }
  496. : defaultPagination
  497. );
  498. // 分页相关的请求参数
  499. const request = props.contentConfig.request ?? {
  500. page_no: "page_no",
  501. page_size: "page_size",
  502. };
  503. const tableRef = ref<TableInstance>();
  504. // 行选中
  505. const selectionData = ref<IObject[]>([]);
  506. // 删除ID集合 用于批量删除
  507. const removeIds = ref<(number | string)[]>([]);
  508. function handleSelectionChange(selection: any[]) {
  509. selectionData.value = selection;
  510. removeIds.value = selection.map((item) => item[pk]);
  511. }
  512. // 获取行选中
  513. function getSelectionData() {
  514. return selectionData.value;
  515. }
  516. // 刷新
  517. function handleRefresh(isRestart = false) {
  518. fetchPageData(lastFormData, isRestart);
  519. }
  520. // 删除
  521. function handleDelete(id?: number | string) {
  522. let ids = "";
  523. if (id !== undefined && id !== null && id !== "") {
  524. ids = String(id);
  525. } else if (removeIds.value.length) {
  526. ids = removeIds.value.map(String).join(",");
  527. }
  528. if (!ids) {
  529. ElMessage.warning("请勾选删除项");
  530. return;
  531. }
  532. const dc = props.contentConfig.deleteConfirm;
  533. ElMessageBox.confirm(dc?.message ?? "确认删除?", dc?.title ?? "警告", {
  534. confirmButtonText: dc?.confirmButtonText ?? "确定",
  535. cancelButtonText: dc?.cancelButtonText ?? "取消",
  536. type: dc?.type ?? "warning",
  537. })
  538. .then(function () {
  539. if (props.contentConfig.deleteAction) {
  540. props.contentConfig
  541. .deleteAction(ids)
  542. .then(() => {
  543. // ElMessage.success("删除成功");
  544. removeIds.value = [];
  545. //清空选中项
  546. tableRef.value?.clearSelection();
  547. handleRefresh(true);
  548. })
  549. .catch(() => {});
  550. } else {
  551. ElMessage.error("未配置deleteAction");
  552. }
  553. })
  554. .catch(() => {});
  555. }
  556. // 导出表单
  557. const fields: string[] = [];
  558. cols.value.forEach((item) => {
  559. if (item.prop !== undefined) {
  560. fields.push(item.prop);
  561. }
  562. });
  563. const enum ExportsOriginEnum {
  564. CURRENT = "current",
  565. SELECTED = "selected",
  566. REMOTE = "remote",
  567. }
  568. const exportsModalVisible = ref(false);
  569. const exportsFormRef = ref<FormInstance>();
  570. const exportsFormData = reactive({
  571. filename: "",
  572. sheetname: "",
  573. fields,
  574. origin: ExportsOriginEnum.CURRENT,
  575. });
  576. const exportsFormRules: FormRules = {
  577. fields: [{ required: true, message: "请选择字段" }],
  578. origin: [{ required: true, message: "请选择数据源" }],
  579. };
  580. // 打开导出弹窗
  581. function handleOpenExportsModal() {
  582. exportsModalVisible.value = true;
  583. }
  584. // 导出确认
  585. const handleExportsSubmit = useThrottleFn(() => {
  586. exportsFormRef.value?.validate((valid: boolean) => {
  587. if (valid) {
  588. handleExports();
  589. handleCloseExportsModal();
  590. }
  591. });
  592. }, 3000);
  593. // 关闭导出弹窗
  594. function handleCloseExportsModal() {
  595. exportsModalVisible.value = false;
  596. exportsFormRef.value?.resetFields();
  597. nextTick(() => {
  598. exportsFormRef.value?.clearValidate();
  599. });
  600. }
  601. // 导出
  602. function handleExports() {
  603. const filename = exportsFormData.filename
  604. ? exportsFormData.filename
  605. : props.contentConfig.permPrefix || "export";
  606. const sheetname = exportsFormData.sheetname ? exportsFormData.sheetname : "sheet";
  607. const workbook = new ExcelJS.Workbook();
  608. const worksheet = workbook.addWorksheet(sheetname);
  609. const columns: Partial<ExcelJS.Column>[] = [];
  610. cols.value.forEach((col) => {
  611. if (col.label && col.prop && exportsFormData.fields.includes(col.prop)) {
  612. columns.push({ header: col.label, key: col.prop });
  613. }
  614. });
  615. worksheet.columns = columns;
  616. if (exportsFormData.origin === ExportsOriginEnum.REMOTE) {
  617. if (props.contentConfig.exportsAction) {
  618. props.contentConfig.exportsAction(lastFormData).then((res) => {
  619. worksheet.addRows(res);
  620. workbook.xlsx
  621. .writeBuffer()
  622. .then((buffer) => {
  623. saveXlsx(buffer, filename as string);
  624. })
  625. .catch((error) => console.log(error));
  626. });
  627. } else {
  628. ElMessage.error("未配置exportsAction");
  629. }
  630. } else {
  631. worksheet.addRows(
  632. exportsFormData.origin === ExportsOriginEnum.SELECTED ? selectionData.value : pageData.value
  633. );
  634. workbook.xlsx
  635. .writeBuffer()
  636. .then((buffer) => {
  637. saveXlsx(buffer, filename as string);
  638. })
  639. .catch((error) => console.log(error));
  640. }
  641. }
  642. // 导入表单
  643. let isFileImport = false;
  644. const uploadRef = ref<UploadInstance>();
  645. const importModalVisible = ref(false);
  646. const importFormRef = ref<FormInstance>();
  647. const importFormData = reactive<{
  648. files: UploadUserFile[];
  649. }>({
  650. files: [],
  651. });
  652. const importFormRules: FormRules = {
  653. files: [{ required: true, message: "请选择文件" }],
  654. };
  655. // 打开导入弹窗
  656. function handleOpenImportModal(isFile: boolean = false) {
  657. importModalVisible.value = true;
  658. isFileImport = isFile;
  659. }
  660. // 覆盖前一个文件
  661. function handleFileExceed(files: File[]) {
  662. uploadRef.value!.clearFiles();
  663. const file = files[0] as UploadRawFile;
  664. file.uid = genFileId();
  665. uploadRef.value!.handleStart(file);
  666. }
  667. // 下载导入模板
  668. function handleDownloadTemplate() {
  669. const importTemplate = props.contentConfig.importTemplate;
  670. if (typeof importTemplate === "string") {
  671. window.open(importTemplate);
  672. } else if (typeof importTemplate === "function") {
  673. importTemplate().then((response) => {
  674. const fileData = response.data;
  675. const fileName = decodeURI(
  676. response.headers["content-disposition"].split(";")[1].split("=")[1]
  677. );
  678. saveXlsx(fileData, fileName);
  679. });
  680. } else {
  681. ElMessage.error("未配置importTemplate");
  682. }
  683. }
  684. // 导入确认
  685. const handleImportSubmit = useThrottleFn(() => {
  686. importFormRef.value?.validate((valid: boolean) => {
  687. if (valid) {
  688. if (isFileImport) {
  689. handleImport();
  690. } else {
  691. handleImports();
  692. }
  693. }
  694. });
  695. }, 3000);
  696. // 关闭导入弹窗
  697. function handleCloseImportModal() {
  698. importModalVisible.value = false;
  699. importFormRef.value?.resetFields();
  700. nextTick(() => {
  701. importFormRef.value?.clearValidate();
  702. });
  703. }
  704. // 文件导入
  705. function handleImport() {
  706. const importAction = props.contentConfig.importAction;
  707. if (importAction === undefined) {
  708. ElMessage.error("未配置importAction");
  709. return;
  710. }
  711. importAction(importFormData.files[0].raw as File).then(() => {
  712. ElMessage.success("导入数据成功");
  713. handleCloseImportModal();
  714. handleRefresh(true);
  715. });
  716. }
  717. // 导入
  718. function handleImports() {
  719. const importsAction = props.contentConfig.importsAction;
  720. if (importsAction === undefined) {
  721. ElMessage.error("未配置importsAction");
  722. return;
  723. }
  724. // 获取选择的文件
  725. const file = importFormData.files[0].raw as File;
  726. // 创建Workbook实例
  727. const workbook = new ExcelJS.Workbook();
  728. // 使用FileReader对象来读取文件内容
  729. const fileReader = new FileReader();
  730. // 二进制字符串的形式加载文件
  731. fileReader.readAsArrayBuffer(file);
  732. fileReader.onload = (ev) => {
  733. if (ev.target !== null && ev.target.result !== null) {
  734. const result = ev.target.result as ArrayBuffer;
  735. // 从 buffer中加载数据解析
  736. workbook.xlsx
  737. .load(result)
  738. .then((workbook) => {
  739. // 解析后的数据
  740. const data = [];
  741. // 获取第一个worksheet内容
  742. const worksheet = workbook.getWorksheet(1);
  743. if (worksheet) {
  744. // 获取第一行的标题
  745. const fields: any[] = [];
  746. worksheet.getRow(1).eachCell((cell) => {
  747. fields.push(cell.value);
  748. });
  749. // 遍历工作表的每一行(从第二行开始,因为第一行通常是标题行)
  750. for (let rowNumber = 2; rowNumber <= worksheet.rowCount; rowNumber++) {
  751. const rowData: IObject = {};
  752. const row = worksheet.getRow(rowNumber);
  753. // 遍历当前行的每个单元格
  754. row.eachCell((cell, colNumber) => {
  755. // 获取标题对应的键,并将当前单元格的值存储到相应的属性名中
  756. rowData[fields[colNumber - 1]] = cell.value;
  757. });
  758. // 将当前行的数据对象添加到数组中
  759. data.push(rowData);
  760. }
  761. }
  762. if (data.length === 0) {
  763. ElMessage.error("未解析到数据");
  764. return;
  765. }
  766. importsAction(data).then(() => {
  767. ElMessage.success("导入数据成功");
  768. handleCloseImportModal();
  769. handleRefresh(true);
  770. });
  771. })
  772. .catch((error) => console.log(error));
  773. } else {
  774. ElMessage.error("读取文件失败");
  775. }
  776. };
  777. }
  778. // 操作栏
  779. function handleToolbar(name: string) {
  780. switch (name) {
  781. case "refresh":
  782. handleRefresh();
  783. break;
  784. case "exports":
  785. handleOpenExportsModal();
  786. break;
  787. case "imports":
  788. handleOpenImportModal();
  789. break;
  790. case "search":
  791. emit("searchClick");
  792. break;
  793. case "add":
  794. emit("addClick");
  795. break;
  796. case "delete":
  797. handleDelete();
  798. break;
  799. case "import":
  800. handleOpenImportModal(true);
  801. break;
  802. case "export":
  803. emit("exportClick");
  804. break;
  805. default:
  806. emit("toolbarClick", name);
  807. break;
  808. }
  809. }
  810. // 操作列
  811. function handleOperate(data: IOperateData) {
  812. switch (data.name) {
  813. case "delete":
  814. if (props.contentConfig?.deleteAction) {
  815. handleDelete(data.row[pk]);
  816. } else {
  817. emit("operateClick", data);
  818. }
  819. break;
  820. default:
  821. emit("operateClick", data);
  822. break;
  823. }
  824. }
  825. // 属性修改
  826. function handleModify(field: string, value: boolean | string | number, row: Record<string, any>) {
  827. if (props.contentConfig.modifyAction) {
  828. props.contentConfig.modifyAction({
  829. [pk]: row[pk],
  830. field,
  831. value,
  832. });
  833. } else {
  834. ElMessage.error("未配置modifyAction");
  835. }
  836. }
  837. // 分页切换
  838. function handleSizeChange(value: number) {
  839. pagination.pageSize = value;
  840. handleRefresh();
  841. }
  842. function handleCurrentChange(value: number) {
  843. pagination.currentPage = value;
  844. handleRefresh();
  845. }
  846. // 远程数据筛选
  847. let filterParams: IObject = {};
  848. function handleFilterChange(newFilters: any) {
  849. const filters: IObject = {};
  850. for (const key in newFilters) {
  851. const col = cols.value.find((col) => {
  852. return col.columnKey === key || col["column-key"] === key;
  853. });
  854. if (col && col.filterJoin !== undefined) {
  855. filters[key] = newFilters[key].join(col.filterJoin);
  856. } else {
  857. filters[key] = newFilters[key];
  858. }
  859. }
  860. filterParams = { ...filterParams, ...filters };
  861. emit("filterChange", filterParams);
  862. }
  863. // 获取筛选条件
  864. function getFilterParams() {
  865. return filterParams;
  866. }
  867. // 获取分页数据
  868. let lastFormData = {};
  869. function getIndexActionErrorMessage(err: unknown): string {
  870. if (err && typeof err === "object" && "response" in err) {
  871. const d = (err as { response?: { data?: { msg?: string; message?: string } } }).response?.data;
  872. if (d?.msg) return String(d.msg);
  873. if (d?.message) return String(d.message);
  874. }
  875. if (err instanceof Error && err.message) return err.message;
  876. return "数据加载失败";
  877. }
  878. function fetchPageData(formData: IObject = {}, isRestart = false) {
  879. loading.value = true;
  880. // 上一次搜索条件
  881. lastFormData = formData;
  882. // 重置页码
  883. if (isRestart) {
  884. pagination.currentPage = 1;
  885. }
  886. props.contentConfig
  887. .indexAction(
  888. showPagination
  889. ? {
  890. [request.page_no]: pagination.currentPage,
  891. [request.page_size]: pagination.pageSize,
  892. ...formData,
  893. }
  894. : formData
  895. )
  896. .then((data) => {
  897. if (showPagination) {
  898. if (props.contentConfig.parseData) {
  899. data = props.contentConfig.parseData(data);
  900. }
  901. pagination.total = data.total;
  902. pageData.value = data.list;
  903. } else {
  904. pageData.value = data;
  905. }
  906. })
  907. .catch((err: unknown) => {
  908. console.error(err);
  909. ElMessage.error(getIndexActionErrorMessage(err));
  910. })
  911. .finally(() => {
  912. loading.value = false;
  913. });
  914. }
  915. if (props.contentConfig.initialFetch !== false) {
  916. fetchPageData();
  917. }
  918. // 导出Excel
  919. function exportPageData(formData: IObject = {}) {
  920. if (props.contentConfig.exportAction) {
  921. props.contentConfig.exportAction(formData).then((response) => {
  922. const fileData = response.data;
  923. const fileName = decodeURI(
  924. response.headers["content-disposition"].split(";")[1].split("=")[1]
  925. );
  926. saveXlsx(fileData, fileName);
  927. });
  928. } else {
  929. ElMessage.error("未配置exportAction");
  930. }
  931. }
  932. // 浏览器保存文件
  933. function saveXlsx(fileData: any, fileName: string) {
  934. const fileType =
  935. "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=utf-8";
  936. const blob = new Blob([fileData], { type: fileType });
  937. const downloadUrl = window.URL.createObjectURL(blob);
  938. const downloadLink = document.createElement("a");
  939. downloadLink.href = downloadUrl;
  940. downloadLink.download = fileName;
  941. document.body.appendChild(downloadLink);
  942. downloadLink.click();
  943. document.body.removeChild(downloadLink);
  944. window.URL.revokeObjectURL(downloadUrl);
  945. }
  946. // 暴露的属性和方法
  947. defineExpose({
  948. fetchPageData,
  949. exportPageData,
  950. getFilterParams,
  951. getSelectionData,
  952. handleRefresh,
  953. handleToolbar,
  954. handleDelete,
  955. pageData,
  956. pagination,
  957. tableRef,
  958. });
  959. </script>
  960. <style lang="scss" scoped>
  961. :deep(.curd-embed-dialog) {
  962. padding-right: 0;
  963. }
  964. </style>