index.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523
  1. <template>
  2. <fs-page class="borrow-page">
  3. <!-- <fs-page> -->
  4. <fs-crud ref="crudRef" v-bind="crudBinding" />
  5. <BorrowTypeSelect v-model:visible="showBorrowTypeDialog" @select="onBorrowTypeSelected" />
  6. <CommonBorrow
  7. v-if="showCommonBorrowDialog"
  8. v-model:visible="showCommonBorrowDialog"
  9. :modelValue="borrowForm"
  10. @submit="handleBorrowSubmit"
  11. @refresh="crudExpose.doRefresh()"
  12. />
  13. <ClassroomBorrow
  14. v-if="showClassroomBorrowDialog"
  15. v-model:visible="showClassroomBorrowDialog"
  16. :modelValue="borrowForm"
  17. :lastFormSnapshot="lastFormSnapshot"
  18. @update:lastFormSnapshot="val => lastFormSnapshot = val"
  19. @submit="handleBorrowSubmit"
  20. />
  21. <SpecialBorrow v-if="showSpecialBorrowDialog" v-model:visible="showSpecialBorrowDialog" :modelValue="borrowForm" @submit="handleBorrowSubmit" />
  22. <CollectEquipmentDialog v-model:visible="showCollectDialog" :modelValue="borrowForm" @submit="handleCollectSubmit" />
  23. <SettlementDialog v-model:visible="showSettlementDialog" :modelValue="borrowForm" />
  24. </fs-page>
  25. </template>
  26. <script lang="ts" setup name="borrow">
  27. import { ref, onMounted, onActivated, onBeforeUnmount, nextTick, provide } from 'vue';
  28. import { useFs } from '@fast-crud/fast-crud';
  29. import { createCrudOptions } from './crud';
  30. // import { GetPermission } from './api';
  31. // import { handleColumnPermission } from '/@/utils/columnPermission';
  32. import BorrowTypeSelect from './component/BorrowTypeSelect/index.vue';
  33. import CommonBorrow from './component/CommonBorrow/index.vue';
  34. import ClassroomBorrow from './component/ClassroomBorrow/index.vue';
  35. import SpecialBorrow from './component/SpecialBorrow/index.vue';
  36. import CollectEquipmentDialog from './component/CollectEquipment/index.vue';
  37. import SettlementDialog from './component/CollectEquipment/SettlementDialog.vue';
  38. import * as api from './api';
  39. import { ElMessage } from 'element-plus';
  40. import { useRoute, onBeforeRouteUpdate } from 'vue-router';
  41. import type { LocationQuery } from 'vue-router';
  42. const route = useRoute();
  43. function firstQueryString(v: unknown): string {
  44. if (v == null) return '';
  45. if (Array.isArray(v)) {
  46. const x = v[0];
  47. return x != null ? String(x) : '';
  48. }
  49. return String(v);
  50. }
  51. const BORROW_LIST_DEBUG = true;
  52. /** 路由 query → 借用列表搜索表单(与 crud 列 status / status__in 一致;禁止重复 status 键) */
  53. function buildBorrowListSearchFormFromRoute(q: LocationQuery) {
  54. const raw = firstQueryString(
  55. (q as Record<string, unknown>).status__in ?? q.status_in ?? q.status
  56. ).trim();
  57. if (BORROW_LIST_DEBUG) {
  58. // eslint-disable-next-line no-console
  59. console.log('[borrow/list] 路由 query', { ...q }, '→ 原始 status 链:', raw);
  60. }
  61. if (raw === '') {
  62. const empty = { status: undefined, status__in: undefined };
  63. if (BORROW_LIST_DEBUG) {
  64. // eslint-disable-next-line no-console
  65. console.log('[borrow/list] 无 status 条件 → doSearch form', empty);
  66. }
  67. return empty;
  68. }
  69. /* 多状态:逗号分隔,对应表单列 status__in */
  70. if (raw.includes(',')) {
  71. const f = { status: undefined, status__in: raw };
  72. if (BORROW_LIST_DEBUG) {
  73. // eslint-disable-next-line no-console
  74. console.log('[borrow/list] 多状态(含逗号)→ form.status__in =', f.status__in);
  75. }
  76. return f;
  77. }
  78. const num = Number(raw);
  79. if (Number.isFinite(num) && num > 0) {
  80. const f = { status: num, status__in: undefined };
  81. if (BORROW_LIST_DEBUG) {
  82. // eslint-disable-next-line no-console
  83. console.log('[borrow/list] 单状态(数字)→ form.status =', f.status);
  84. }
  85. return f;
  86. }
  87. const f = { status: raw, status__in: undefined };
  88. if (BORROW_LIST_DEBUG) {
  89. // eslint-disable-next-line no-console
  90. console.log('[borrow/list] 单状态(非纯数字)→ form.status =', f.status);
  91. }
  92. return f;
  93. }
  94. const { crudBinding, crudRef, crudExpose } = useFs({ createCrudOptions });
  95. const showBorrowTypeDialog = ref(false);
  96. const showCommonBorrowDialog = ref(false);
  97. const showClassroomBorrowDialog = ref(false);
  98. const showSpecialBorrowDialog = ref(false);
  99. const showCollectDialog = ref(false);
  100. const borrowForm = ref({});
  101. const showSettlementDialog = ref(false);
  102. /** keep-alive 下首次进入会先后触发 onMounted 与 onActivated,避免重复 doSearch */
  103. const skipNextActivatedBorrowSearch = ref(false);
  104. /** 与 onActivated 协调:避免 keep-alive 首次 onMounted + onActivated 连续请求两次 */
  105. function applyRouteSearchToCrud() {
  106. skipNextActivatedBorrowSearch.value = true;
  107. crudExpose.doSearch({
  108. form: buildBorrowListSearchFormFromRoute(route.query),
  109. });
  110. }
  111. const lastFormSnapshot = ref({});
  112. // provide('lastFormSnapshot', lastFormSnapshot);
  113. /** 在响应中递归查找「疑似借用单」主键;expectedBorrowType 为 1 课堂 / 2 特殊 等,与列表性质一致,避免误取 user.id */
  114. function findBorrowApplicationIdInResponseTree(
  115. res: any,
  116. expectedBorrowType: number
  117. ): string | number | null {
  118. const s = new WeakSet<object>();
  119. const want = Number(expectedBorrowType);
  120. function walk(n: any, depth: number): string | number | null {
  121. if (n == null || depth > 8) return null;
  122. if (typeof n !== 'object') return null;
  123. if (s.has(n as object) && !Array.isArray(n)) return null;
  124. s.add(n as object);
  125. if (Array.isArray(n)) {
  126. for (const item of n) {
  127. const f = walk(item, depth + 1);
  128. if (f != null) return f;
  129. }
  130. return null;
  131. }
  132. const o = n as any;
  133. if (o.id != null) {
  134. const hasNo = o.application_no != null && String(o.application_no).trim() !== '';
  135. const hasBt = o.borrow_type != null;
  136. const hasPurpose = o.purpose != null && String(o.purpose).trim() !== '';
  137. const nbt = Number(o.borrow_type);
  138. if (
  139. hasNo ||
  140. (hasBt &&
  141. hasPurpose &&
  142. !Number.isNaN(nbt) &&
  143. nbt === want)
  144. ) {
  145. return o.id;
  146. }
  147. }
  148. for (const v of Object.values(n)) {
  149. if (v && typeof v === 'object') {
  150. const f = walk(v, depth + 1);
  151. if (f != null) return f;
  152. }
  153. }
  154. return null;
  155. }
  156. return walk(res, 0);
  157. }
  158. /** 从「新建借用单」POST 标准返回中尽量解析出新建单 id(兼容 data 为对象/数字/数组及常见字段名) */
  159. function parseNewBorrowApplicationId(res: any): string | number | null {
  160. if (!res) return null;
  161. if (res.id != null) return res.id;
  162. const d = res.data;
  163. if (d == null) return null;
  164. if (typeof d === 'number') return d;
  165. if (typeof d === 'string' && d.trim() !== '' && !Number.isNaN(Number(d))) {
  166. return Number(d);
  167. }
  168. if (Array.isArray(d) && d.length > 0) {
  169. const r0 = d[0] as any;
  170. if (r0?.id != null) return r0.id;
  171. }
  172. if (typeof d === 'object') {
  173. const id =
  174. d.id ?? d.pk ?? d.application_id ?? d.applicationId ?? d.bid;
  175. if (id != null) return id;
  176. if (Array.isArray((d as any).results) && (d as any).results[0]?.id != null) {
  177. return (d as any).results[0].id;
  178. }
  179. const inner = d.data;
  180. if (inner && typeof inner === 'object' && (inner as any).id != null) return (inner as any).id;
  181. }
  182. return null;
  183. }
  184. function listResponseRecords(lr: any): any[] {
  185. const d = lr?.data;
  186. if (!d) return [];
  187. if (Array.isArray(d.items)) return d.items;
  188. if (Array.isArray(d.results)) return d.results;
  189. if (Array.isArray(d.records)) return d.records;
  190. if (Array.isArray(d)) return d;
  191. return [];
  192. }
  193. /** 用关键字搜索 + 单号 / 用途 等在列表中匹配新建单;borrowType 与借用性质(1 课堂 2 特殊…)一致 */
  194. async function searchBorrowIdByList(params: {
  195. search?: string;
  196. formPurpose?: string;
  197. formExpectedStart?: string;
  198. borrowType: number;
  199. }): Promise<string | number | null> {
  200. const { search, formPurpose, formExpectedStart, borrowType } = params;
  201. const base = {
  202. page: 1,
  203. limit: 30,
  204. borrow_type: Number(borrowType),
  205. ordering: '-create_datetime',
  206. } as Record<string, any>;
  207. try {
  208. if (search && String(search).trim() !== '') {
  209. const lr: any = await api.GetList({
  210. ...base,
  211. search: String(search).trim(),
  212. } as any);
  213. const rows = listResponseRecords(lr);
  214. if (search) {
  215. const t = String(search).trim();
  216. const byNo = rows.find(
  217. (r: any) => r && String(r.application_no) === t
  218. );
  219. if (byNo?.id != null) return byNo.id;
  220. }
  221. }
  222. // 无单号时:取同性质借用最近一页,用「用途」+ 预计开始日(若有)弱匹配
  223. const lr2: any = await api.GetList(base as any);
  224. const rows = listResponseRecords(lr2);
  225. if (!rows.length) return null;
  226. if (formPurpose) {
  227. const p = String(formPurpose).trim();
  228. const m = rows.find((r: any) => r && String(r.purpose) === p);
  229. if (m?.id != null) return m.id;
  230. }
  231. if (formExpectedStart) {
  232. const day = String(formExpectedStart).slice(0, 10);
  233. const m = rows.find(
  234. (r: any) =>
  235. r &&
  236. r.expected_start_time &&
  237. String(r.expected_start_time).slice(0, 10) === day
  238. );
  239. if (m?.id != null) return m.id;
  240. }
  241. // 新建立即查询时,多并发下非 100% 可靠;与后端约定在 POST 中回 id 可彻底避免
  242. return rows[0]?.id ?? null;
  243. } catch {
  244. return null;
  245. }
  246. }
  247. /**
  248. * 从 POST 返回 + 表单快照解析新建单主键,供直接打开「领取」(课堂/特殊等,由 borrow_type 区分)
  249. */
  250. async function resolveNewBorrowApplicationIdAfterAdd(
  251. res: any,
  252. submitted: Record<string, any>
  253. ): Promise<string | number | null> {
  254. const borrowType = Number(submitted?.borrow_type);
  255. let id = parseNewBorrowApplicationId(res);
  256. if (id != null) return id;
  257. if (!Number.isNaN(borrowType)) {
  258. id = findBorrowApplicationIdInResponseTree(res, borrowType);
  259. if (id != null) return id;
  260. }
  261. const d = res?.data;
  262. const appNoFromRes =
  263. d && typeof d === 'object' ? d.application_no ?? d.applicationNo : null;
  264. if (appNoFromRes && String(appNoFromRes).trim() !== '' && !Number.isNaN(borrowType)) {
  265. id = await searchBorrowIdByList({
  266. search: String(appNoFromRes).trim(),
  267. borrowType,
  268. });
  269. if (id != null) return id;
  270. }
  271. if (submitted?.application_no && !Number.isNaN(borrowType)) {
  272. id = await searchBorrowIdByList({
  273. search: String(submitted.application_no),
  274. borrowType,
  275. });
  276. if (id != null) return id;
  277. }
  278. return await searchBorrowIdByList({
  279. formPurpose: submitted?.purpose,
  280. formExpectedStart: submitted?.expected_start_time,
  281. borrowType: Number.isNaN(borrowType) ? 1 : borrowType,
  282. });
  283. }
  284. function onBorrowTypeSelected(type: number, mode: 'view' | 'edit' | 'add' |'collect',record?: any) {
  285. showBorrowTypeDialog.value = false;
  286. // 必须先同步写入 borrowForm,再打开子弹窗;若在 nextTick 中赋值,子组件挂载时仍拿到上一次的查看/编辑数据,会误拉详情并残留在表单中
  287. borrowForm.value = { ...(record || {}), borrow_type: type, mode };
  288. if (type === 1) {
  289. if (mode === 'view') {
  290. showCollectDialog.value = true;
  291. } else {
  292. showClassroomBorrowDialog.value = true;
  293. }
  294. } else if (type === 2) {
  295. if (mode === 'view') {
  296. showCollectDialog.value = true;
  297. } else {
  298. showSpecialBorrowDialog.value = true;
  299. }
  300. } else if (type === 0) {
  301. if (mode === 'view') {
  302. showCollectDialog.value = true;
  303. } else {
  304. showCommonBorrowDialog.value = true;
  305. }
  306. }
  307. }
  308. async function handleBorrowSubmit(formData: any) {
  309. try {
  310. if (formData.mode === 'edit') {
  311. await api.UpdateObj(formData);
  312. ElMessage.success('编辑成功');
  313. } else {
  314. const res: any = await api.AddObj(formData);
  315. ElMessage.success('添加成功');
  316. // 课堂(1) / 特殊(2) 借用:新增成功后直接打开「领取」弹窗,并进入设备清单
  317. const addBorrowType = Number(formData.borrow_type);
  318. if (addBorrowType === 1 || addBorrowType === 2) {
  319. const newId = await resolveNewBorrowApplicationIdAfterAdd(res, formData);
  320. if (newId != null) {
  321. // 先写入 modelValue 再打开弹窗,领取页首帧即 mode=collect,避免先显示「借用单信息」再切页
  322. borrowForm.value = {
  323. id: newId,
  324. borrow_type: addBorrowType,
  325. mode: 'collect',
  326. collectInitialTab: 'second',
  327. };
  328. showCollectDialog.value = true;
  329. } else {
  330. console.warn(
  331. '[borrow] 新建借用后仍无法确定单主键,请让 POST /application/ 在 data 中返回 id;可暂从列表点击「领取」',
  332. res
  333. );
  334. ElMessage.info('已新建成功,请从下方列表中点击该单的「领取」');
  335. }
  336. }
  337. }
  338. // 刷新列表
  339. crudRef.value && crudRef.value.doRefresh && crudRef.value.doRefresh();
  340. crudExpose.doRefresh();
  341. } catch (e) {
  342. // 错误处理
  343. console.error(formData.mode === 'edit' ? '编辑失败' : '提交失败', e);
  344. ElMessage.error(formData.mode === 'edit' ? '编辑失败' : '提交失败');
  345. }
  346. }
  347. async function handleCollectSubmit(formData: any) {
  348. try {
  349. crudExpose.doRefresh();
  350. } catch (e) {
  351. console.error('提交失败', e);
  352. }
  353. }
  354. async function handleCollect(e: CustomEvent) {
  355. const row = e.detail;
  356. console.log("row:::",row);
  357. borrowForm.value = { ...row, mode: 'collect' };
  358. showCollectDialog.value = true;
  359. console.log("showCollectDialog:::",showCollectDialog.value);
  360. }
  361. async function handleReturn(e: CustomEvent) {
  362. const row = e.detail;
  363. console.log("row:::",row);
  364. borrowForm.value = { ...row, mode: 'return' };
  365. showCollectDialog.value = true;
  366. console.log("showReturnDialog:::",showCollectDialog.value);
  367. }
  368. async function handleView(e: CustomEvent) {
  369. const row = e.detail;
  370. onBorrowTypeSelected(row.borrow_type, 'view',row);
  371. }
  372. async function handleEdit(e: CustomEvent) {
  373. const row = e.detail;
  374. onBorrowTypeSelected(row.borrow_type, 'edit',row);
  375. }
  376. async function handleRemove(e: CustomEvent) {
  377. const row = e.detail;
  378. onBorrowTypeSelected(row.borrow_type);
  379. }
  380. async function handleSettlement(e: CustomEvent) {
  381. const row = e.detail;
  382. console.log("row:::",row);
  383. try {
  384. // 获取申请详情,避免直接使用行内简略数据
  385. const res: any = await api.GetApplicationDetail(row.id);
  386. const detail = res?.data || res; // 兼容后端返回结构
  387. showSettlementDialog.value = true;
  388. nextTick(() => {
  389. borrowForm.value = { ...(detail || {}), mode: 'settlement' };
  390. });
  391. } catch (error) {
  392. console.error('获取结算详情失败:', error);
  393. ElMessage.error('获取结算详情失败');
  394. }
  395. }
  396. async function handleRenewDevice(e: CustomEvent) {
  397. const res: any = await api.GetApplicationDetail(e.detail.id);
  398. if(res.code==2000){
  399. showSettlementDialog.value = true;
  400. nextTick(() => {
  401. borrowForm.value = { ...res.data, mode: 'renew' };
  402. });
  403. }else{
  404. ElMessage.error('获取续借详情失败');
  405. }
  406. }
  407. let observer: MutationObserver | null = null;
  408. function bindBorrowActionbarButton(): boolean {
  409. const fsActionbar = document.querySelector('.borrow-page .fs-actionbar');
  410. if (!fsActionbar) return false;
  411. function insertButton() {
  412. if (fsActionbar && !fsActionbar.querySelector('.my-create-btn')) {
  413. const button = document.createElement('button');
  414. button.className = 'el-button el-button--primary el-button--default my-create-btn';
  415. button.innerHTML = '创建借用单';
  416. button.onclick = () => {
  417. showBorrowTypeDialog.value = true;
  418. };
  419. fsActionbar.append(button);
  420. }
  421. }
  422. insertButton();
  423. if (observer) {
  424. observer.disconnect();
  425. observer = null;
  426. }
  427. observer = new MutationObserver((_m, obs) => {
  428. insertButton();
  429. obs.disconnect();
  430. });
  431. observer.observe(fsActionbar, { childList: true });
  432. return true;
  433. }
  434. onMounted(() => {
  435. window.addEventListener('borrow-view', handleView);
  436. window.addEventListener('borrow-edit', handleEdit);
  437. window.addEventListener('borrow-remove', handleRemove);
  438. window.addEventListener('borrow-collect', handleCollect);
  439. window.addEventListener('borrow-return', handleReturn);
  440. window.addEventListener('borrow-settlement', handleSettlement);
  441. window.addEventListener('renew-device', handleRenewDevice);
  442. /* 必须执行:此前若未找到 .fs-actionbar 会提前 return,导致从未 doSearch,URL 带参也不生效 */
  443. nextTick(() => {
  444. applyRouteSearchToCrud();
  445. });
  446. if (!bindBorrowActionbarButton()) {
  447. nextTick(() => {
  448. if (!bindBorrowActionbarButton()) {
  449. window.setTimeout(() => bindBorrowActionbarButton(), 100);
  450. }
  451. });
  452. }
  453. });
  454. /** 同页仅 query 变化时(如 ?status=);勿 watch 全局 fullPath,keep-alive 下会误触发 */
  455. onBeforeRouteUpdate((to) => {
  456. nextTick(() => {
  457. crudExpose.doSearch({
  458. form: buildBorrowListSearchFormFromRoute(to.query),
  459. });
  460. });
  461. });
  462. onActivated(() => {
  463. if (skipNextActivatedBorrowSearch.value) {
  464. skipNextActivatedBorrowSearch.value = false;
  465. return;
  466. }
  467. crudExpose.doSearch({
  468. form: buildBorrowListSearchFormFromRoute(route.query),
  469. });
  470. });
  471. onBeforeUnmount(() => {
  472. if (observer) {
  473. observer.disconnect();
  474. observer = null;
  475. }
  476. // window.removeEventListener('borrow-add', () => {
  477. // showBorrowTypeDialog.value = false
  478. // });
  479. window.removeEventListener('borrow-view', handleView);
  480. window.removeEventListener('borrow-edit', handleEdit);
  481. window.removeEventListener('borrow-remove', handleRemove);
  482. window.removeEventListener('borrow-collect', handleCollect);
  483. window.removeEventListener('borrow-return', handleReturn);
  484. window.removeEventListener('borrow-settlement', handleSettlement);
  485. window.removeEventListener('renew-device', handleRenewDevice);
  486. });
  487. </script>
  488. <style lang="scss" scoped></style>