| src/api/mes/pro/qrExport/index.ts | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/views/mes/pro/bagCode/index.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/views/mes/pro/qrexport/qr-export-progress.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/views/mes/pro/qrexport/use-qr-export.ts | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 |
src/api/mes/pro/qrExport/index.ts
¶Ô±ÈÐÂÎļþ @@ -0,0 +1,85 @@ import { requestClient } from '#/api/request'; export namespace MesProQrExportApi { /** ä¸å¡ç±»å */ export type BizType = | 'BAG_BUSINESS' | 'BAG_LABEL' | 'BATCH_BUSINESS' | 'BATCH_LABEL' | 'PALLET_BUSINESS' | 'PALLET_LABEL'; /** å¯¼åºæ¹å¼ï¼SYNC åæ¥ç´æ¥ä¸è½½ï¼ç§åºï¼/ ASYNC åå°ä»»å¡ */ export type ExportMode = 'ASYNC' | 'SYNC'; /** 导åºä»»å¡ç¶æï¼10 æéä¸ 20 çæä¸ 30 å¾ ä¸è½½ 40 已忶 50 已失败 60 å·²ä¸è½½ */ export type TaskStatus = 10 | 20 | 30 | 40 | 50 | 60; /** 导åºåæ° */ export interface ExportParams { bizType: BizType; batchId?: number; uuid?: string; } /** å¯¼åºæ°é䏿¹å¼ */ export interface ExportCount { mode: ExportMode; syncThreshold: number; totalCount: number; } /** 导åºä»»å¡ */ export interface ExportTask { id: number; bizType: string; taskName: string; status: TaskStatus; statusName: string; totalCount: number; finishedCount: number; progress: number; fileSize?: number; errorMsg?: string; downloadable: boolean; createTime?: string; finishTime?: string; } } /** ç»è®¡å¯¼åºå¼ æ°å¹¶ç»åºå¯¼åºæ¹å¼ */ export function getQrExportCount(params: MesProQrExportApi.ExportParams) { return requestClient.get<MesProQrExportApi.ExportCount>( '/mes/pro/qr-export/count', { params }, ); } /** æäº¤åå°å¯¼åºä»»å¡ */ export function submitQrExportTask(data: MesProQrExportApi.ExportParams) { return requestClient.post<MesProQrExportApi.ExportTask>( '/mes/pro/qr-export/task', data, ); } /** æ¥è¯¢å¯¼åºä»»å¡è¿åº¦ */ export function getQrExportTask(id: number) { return requestClient.get<MesProQrExportApi.ExportTask>( '/mes/pro/qr-export/task/get', { params: { id } }, ); } /** 忶坼åºä»»å¡ */ export function cancelQrExportTask(id: number) { return requestClient.post<boolean>('/mes/pro/qr-export/task/cancel', null, { params: { id }, }); } /** 导åºä»»å¡äº§ç©ä¸è½½å°åï¼ä¸å¸¦é´æå¤´ï¼ä¾æµå¼ä¸è½½èªè¡éå tokenï¼ */ export function getQrExportDownloadPath(id: number) { return `/mes/pro/qr-export/task/download?id=${id}`; } src/views/mes/pro/bagCode/index.vue
@@ -30,6 +30,8 @@ import ReplaceModal from "./modules/replace-form.vue"; import QrTraceModal from "../components/qr-trace-modal.vue"; import BusinessQrModal from "../components/business-qr.vue"; import QrExportProgress from "../qrexport/qr-export-progress.vue"; import { useQrExport } from "../qrexport/use-qr-export"; defineOptions({ name: "MesProBagCode" }); @@ -91,6 +93,8 @@ gridApi.query(); } const { start: startQrExport } = useQrExport(); /** çææ°æ®å */ function handleGenerate() { generateFormModalApi.open(); @@ -131,8 +135,24 @@ message.warning("è¯·å æçæç»ææ¹æ¬¡çéåå导åºäºç»´ç "); return; } const data = await (kind === 'business' ? exportBagBusinessQr : exportBagQr)(params); downloadFileFromBlobPart({ fileName: kind === 'business' ? 'è¢ç ä¸å¡äºç»´ç .zip' : 'è¢ç 追溯äºç»´ç .zip', source: data }); // æ°éå°å忥ç§åºï¼è¶ è¿åç线èªå¨è½¬åå°ä»»å¡ï¼ç±è¿åº¦æµ®å±å±ç¤ºçå®è¿åº¦å¹¶æ¯æåæ¶/éè const bizType = kind === "business" ? "BAG_BUSINESS" : "BAG_LABEL"; const fileName = kind === "business" ? "è¢ç ä¸å¡äºç»´ç " : "è¢ç 追溯äºç»´ç "; await startQrExport( { bizType, ...params }, { fileName, syncDownload: async () => { const data = await (kind === "business" ? exportBagBusinessQr : exportBagQr)(params); downloadFileFromBlobPart({ fileName: `${fileName}.zip`, source: data, }); }, }, ); } /** 导åºè¢ç Excelï¼äº¤ä»å°å·ï¼ï¼å¯¼åºåè¢ç ç¶æå为已å°å·ï¼éå·æ°å表 */ @@ -238,11 +258,14 @@ function handleBindPallet() { const records = getSelectedRecords(); if (!records) return; if (records.some((record) => !record.batchId)) { message.warning("请å å½å ¥çäº§æ¹æ¬¡ï¼åç»å®æç"); // å·²å½å ¥æ¹æ¬¡çè¢ç åªè½ç»å°æ¬æ¹æ¬¡çæçï¼æªå½å ¥æ¹æ¬¡çè¢ç å¯ç»ä»»ææçï¼éå°æ£å¼æçæ¶å端ä¼å è¡¥å½æ¹æ¬¡ï¼ã // ä¸¤è æ··éæ¶æ¹æ¬¡å½å±ä¸æç¡®ï¼è¦æ±å弿ä½ã const boundRecords = records.filter((record) => record.batchId); if (boundRecords.length > 0 && boundRecords.length < records.length) { message.warning("æéè¢ç 䏿¢æå·²å½å ¥æ¹æ¬¡ã乿æªå½å ¥æ¹æ¬¡çè®°å½ï¼è¯·åå¼ç»å®"); return; } const batchIds = new Set(records.map((record) => record.batchId)); const batchIds = new Set(boundRecords.map((record) => record.batchId)); if (batchIds.size > 1) { message.warning("è¯·éæ©å䏿¹æ¬¡ä¸çè¢ç è¿è¡ç»å®"); return; @@ -264,6 +287,7 @@ .setData({ batchId: first.batchId, batchCode: first.batchCode, itemId: first.itemId, ids: records.map((record) => record.id!), }) .open(); @@ -347,6 +371,7 @@ <ReplaceFormModal @success="handleReplaceSuccess" /> <QrTraceQueryModal /> <BusinessQrViewModal /> <QrExportProgress /> <Grid table-title="è¢ç å表ï¼ä¸è¢ä¸ç ï¼"> <template #table-title><span class="text-[1rem] font-bold" style="white-space:nowrap">è¢ç å表ï¼ä¸è¢ä¸ç ï¼</span></template> <template #toolbar-tools> src/views/mes/pro/qrexport/qr-export-progress.vue
¶Ô±ÈÐÂÎļþ @@ -0,0 +1,189 @@ <script lang="ts" setup> import { computed } from 'vue'; import { Button, Progress } from 'ant-design-vue'; import { useQrExport } from './use-qr-export'; defineOptions({ name: 'QrExportProgress' }); const { state, cancel, minimize, expand, close } = useQrExport(); /** 颿¿æ¯å¦å±ç¤ºï¼idle 表示没æä»»ä½ä»»å¡ï¼ */ const visible = computed(() => state.phase !== 'idle'); /** è¿è¡ä¸çé¶æ®µæå è®¸åæ¶ */ const cancelable = computed(() => ['counting', 'downloading', 'generating'].includes(state.phase), ); const percent = computed(() => { switch (state.phase) { case 'counting': { return 0; } case 'done': { return 100; } case 'downloading': { if (state.downloadTotal > 0) { return Math.min( 100, Math.round((state.downloadLoaded / state.downloadTotal) * 100), ); } return 0; } case 'generating': { return state.progress; } default: { return state.progress; } } }); const progressStatus = computed(() => { if (state.phase === 'failed') return 'exception'; if (state.phase === 'done') return 'success'; return 'active'; }); /** çæé¶æ®µä¸ä¸è½½é¶æ®µé½ä¸å¯é¢ç¥æ¶é¿ï¼ç¨ä¸ç¡®å®æè¿åº¦æ¡ */ const indeterminate = computed( () => state.phase === 'counting' || state.phase === 'sync', ); const title = computed(() => { switch (state.phase) { case 'canceled': { return '导åºå·²åæ¶'; } case 'counting': { return 'æ£å¨ç»è®¡æ°é'; } case 'done': { return '导åºå®æ'; } case 'downloading': { return 'æ£å¨ä¸è½½'; } case 'failed': { return '导åºå¤±è´¥'; } case 'generating': { return 'æ£å¨çæäºç»´ç '; } case 'sync': { return 'æ£å¨å¯¼åº'; } default: { return 'äºç»´ç 导åº'; } } }); const detail = computed(() => { if (state.phase === 'failed') { return state.errorMsg || '导åºå¤±è´¥'; } if (state.phase === 'downloading') { if (state.downloadTotal > 0) { return `${formatSize(state.downloadLoaded)} / ${formatSize(state.downloadTotal)}`; } return `å·²ä¸è½½ ${formatSize(state.downloadLoaded)}`; } return state.message; }); /** æ¶èµ·åæµ®æ ä¸ä»è¦è½çå°è¿åº¦ï¼é¿å ç¨æ·ä»¥ä¸ºä»»å¡å·²ç»åäº */ const pillText = computed(() => state.phase === 'generating' ? `${percent.value}%` : title.value, ); function formatSize(bytes: number) { if (!bytes) return '0 B'; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; if (bytes < 1024 * 1024 * 1024) { return `${(bytes / 1024 / 1024).toFixed(1)} MB`; } return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`; } </script> <template> <!-- æ¶èµ·æï¼å°æµ®æ ï¼ç¹å»å¯éæ°å±å¼ --> <div v-if="visible && state.minimized" class="fixed right-6 bottom-6 z-[2000] flex cursor-pointer items-center gap-2 rounded-full bg-white px-4 py-2 shadow-lg ring-1 ring-gray-200 hover:shadow-xl dark:bg-gray-800 dark:ring-gray-700" @click="expand" > <span class="inline-block h-2 w-2 rounded-full" :class="{ 'bg-blue-500': state.phase === 'generating' || state.phase === 'downloading', 'bg-green-500': state.phase === 'done', 'bg-red-500': state.phase === 'failed', 'bg-gray-400': state.phase === 'canceled', }" ></span> <span class="text-sm text-gray-700 dark:text-gray-200"> äºç»´ç å¯¼åº {{ pillText }} </span> </div> <!-- å±å¼æï¼è¿åº¦é¢æ¿ --> <div v-else-if="visible" class="fixed right-6 bottom-6 z-[2000] w-80 rounded-lg bg-white p-4 shadow-lg ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700" > <div class="mb-2 flex items-center justify-between"> <span class="text-sm font-medium text-gray-800 dark:text-gray-100"> {{ title }} </span> <div class="flex items-center gap-1"> <!-- è¿è¡ä¸æä¾ãéèãï¼ä»»å¡è½¬å ¥åå°ç»§ç»ï¼ä¸ä¼è¢«ä¸æ --> <Button v-if="state.phase !== 'done' && state.phase !== 'failed' && state.phase !== 'canceled'" type="link" size="small" @click="minimize" > éè </Button> <Button v-if="cancelable" type="link" size="small" danger @click="cancel" > åæ¶ </Button> <Button v-if="!cancelable" type="link" size="small" @click="close" > å ³é </Button> </div> </div> <Progress :percent="percent" :status="progressStatus" :show-info="!indeterminate" size="small" /> <div class="mt-2 truncate text-xs text-gray-500 dark:text-gray-400"> {{ state.taskName }} </div> <div class="mt-1 text-xs text-gray-500 dark:text-gray-400"> {{ detail }} </div> </div> </template> src/views/mes/pro/qrexport/use-qr-export.ts
¶Ô±ÈÐÂÎļþ @@ -0,0 +1,387 @@ import type { MesProQrExportApi } from '#/api/mes/pro/qrExport'; import { reactive } from 'vue'; import { useAppConfig } from '@vben/hooks'; import { useAccessStore } from '@vben/stores'; import { downloadFileFromBlobPart } from '@vben/utils'; import { message } from 'ant-design-vue'; import { cancelQrExportTask, getQrExportCount, getQrExportDownloadPath, getQrExportTask, submitQrExportTask, } from '#/api/mes/pro/qrExport'; /** * 导åºé¶æ®µ * <p> * counting ç»è®¡å¼ æ° / sync å°æ¹é忥ä¸è½½ä¸ / generating åå°çæä¸ / * downloading ä¸è½½äº§ç©ä¸ / done 宿 / canceled 已忶 / failed 失败 */ export type QrExportPhase = | 'canceled' | 'counting' | 'done' | 'downloading' | 'failed' | 'generating' | 'idle' | 'sync'; export interface QrExportState { bizType?: MesProQrExportApi.BizType; /** å·²ä¸è½½åè */ downloadLoaded: number; /** äº§ç©æ»åèï¼åèª Content-Lengthï¼åä¸å°æ¶ä¸º 0 表示æªç¥ï¼ */ downloadTotal: number; errorMsg: string; /** å·²çæå¼ æ° */ finished: number; message: string; /** æ¶èµ·ä¸ºæµ®æ ï¼ä»»å¡ç»§ç»å¨åå°è·ï¼ */ minimized: boolean; phase: QrExportPhase; /** è¿åº¦ç¾åæ¯ï¼0~100ï¼ */ progress: number; taskId?: number; taskName: string; /** æ ç¾æ»å¼ æ° */ total: number; } const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD); /** * 导åºç¶ææå°æ¨¡å级åä¾ï¼è¿åº¦é¢æ¿è¢«æ¶èµ·ãæé¡µé¢ç»ä»¶è¢«éæ¯ï¼ç¨æ·åèµ°åååï¼æ¶ï¼ * 轮询ä¸ä¸è½½é½å¿ 须继ç»ï¼ä¸è½å 为ç»ä»¶å¸è½½è䏿ã */ const state = reactive<QrExportState>(createInitialState()); let pollTimer: null | ReturnType<typeof setInterval> = null; let downloadAbort: null | (() => void) = null; /** * ç®æ æä»¶å¥æ * <p> * 大æ¹é导åºç产ç©å¯è¾¾æ°ç¾ MBï¼è¥æ´å 读è¿å ååè½çï¼æ ç¾é¡µææè¢«æçãå æ¤ä¼å å¨ç¹å»å¯¼åº * ï¼ç¨æ·æå¿ä»å¨æææå ï¼æ¶åå°ä¿å奿ï¼äº§ç©è¾¹ä¸è½½è¾¹åçï¼å ååªå ä¸ä¸ªåçã * æµè§å¨ä¸æ¯ææç¨æ·æ¾å¼éæ©æ¶éåå åä¸è½½ã */ let saveHandle: any = null; /** å°è¯åå¾ä¿å奿ï¼ä¸æ¯æãè¢«åæ¶æ¶è¿å nullï¼ä¸è§ä¸ºéè¯¯ï¼ */ async function tryPickSaveFile(suggestedName: string): Promise<any> { const picker = (window as any).showSaveFilePicker; if (typeof picker !== 'function') { return null; } try { return await picker({ suggestedName, types: [ { description: 'ZIP å缩å ', accept: { 'application/zip': ['.zip'] } }, ], }); } catch { return null; } } function createInitialState(): QrExportState { return { downloadLoaded: 0, downloadTotal: 0, errorMsg: '', finished: 0, message: '', minimized: false, phase: 'idle', progress: 0, taskName: '', total: 0, }; } function resetState() { Object.assign(state, createInitialState()); } function stopPolling() { if (pollTimer !== null) { clearInterval(pollTimer); pollTimer = null; } } /** æ¯å¦å¤äºè¿è¡ä¸ï¼é¢æ¿éè¦å±ç¤ºï¼ */ function isActive() { return [ 'counting', 'generating', 'downloading', 'sync', ].includes(state.phase); } /** ç±å¤é¨é¡µé¢æ³¨å ¥çãå°æ¹é忥ä¸è½½ãå®ç°ï¼é¿å æ¬æ¨¡åä¾èµå ·ä½ä¸å¡é¡µ */ export interface StartOptions { fileName?: string; /** å°æ¹éï¼ä¸è¶ è¿åççº¿ï¼æ¶å¦ä½åæ¥ä¸è½½ï¼ç±è°ç¨é¡µæä¾ */ syncDownload?: () => Promise<void>; } /** * åèµ·å¯¼åº * <p> * å é®å端è¦å¼ æ°ï¼ä¸è¶ è¿åççº¿ç´æ¥åæ¥ä¸è½½ï¼ç§åºã䏿éï¼ï¼è¶ è¿å转åå°ä»»å¡å¹¶å±ç¤ºè¿åº¦ã */ async function start( params: MesProQrExportApi.ExportParams, options: StartOptions = {}, ) { if (isActive()) { message.warning('å·²æå¯¼åºä»»å¡æ£å¨è¿è¡ï¼è¯·çå¾ å®ææåæ¶ååè¯'); return; } resetState(); state.bizType = params.bizType; state.taskName = options.fileName ?? 'äºç»´ç '; state.phase = 'counting'; state.message = 'æ£å¨ç»è®¡å¾ å¯¼åºæ°éâ¦'; try { const count = await getQrExportCount(params); state.total = count.totalCount; if (count.totalCount === 0) { state.phase = 'failed'; state.errorMsg = '没æå¯å¯¼åºçäºç»´ç '; state.message = '没æå¯å¯¼åºçäºç»´ç '; return; } if (count.mode === 'SYNC') { await startSync(params, count, options); return; } // 大æ¹éï¼è¶ç¨æ·æå¿ä»ææå åä¿å奿ï¼äº§ç©å¯ç´æ¥æµå¼è½ç saveHandle = await tryPickSaveFile(`${state.taskName}.zip`); await startAsync(params, count); } catch (error: any) { failWith(error); } } /** å°æ¹éï¼æ²¿ç¨åæåæ¥ä¸è½½æ¥å£ */ async function startSync( _params: MesProQrExportApi.ExportParams, count: MesProQrExportApi.ExportCount, options: StartOptions, ) { state.phase = 'sync'; state.message = `å ± ${count.totalCount} å¼ ï¼æ£å¨çæâ¦`; if (!options.syncDownload) { state.phase = 'failed'; state.errorMsg = '缺å°åæ¥ä¸è½½å®ç°'; return; } await options.syncDownload(); state.phase = 'done'; state.progress = 100; state.finished = count.totalCount; state.message = `å·²å¯¼åº ${count.totalCount} å¼ `; } /** 大æ¹éï¼æäº¤åå°ä»»å¡å¹¶è½®è¯¢è¿åº¦ */ async function startAsync( params: MesProQrExportApi.ExportParams, count: MesProQrExportApi.ExportCount, ) { state.phase = 'generating'; state.message = `å ± ${count.totalCount} å¼ ï¼æ£å¨åå°çæâ¦`; const task = await submitQrExportTask(params); state.taskId = task.id; state.taskName = task.taskName ?? state.taskName; state.total = task.totalCount ?? count.totalCount; state.finished = task.finishedCount ?? 0; state.progress = task.progress ?? 0; pollTask(); } function pollTask() { stopPolling(); pollTimer = setInterval(() => { void refreshTask(); }, 1000); } async function refreshTask() { if (!state.taskId) return; try { const task = await getQrExportTask(state.taskId); state.total = task.totalCount ?? 0; state.finished = task.finishedCount ?? 0; state.progress = task.progress ?? 0; state.taskName = task.taskName ?? state.taskName; if (task.status === 30) { // å¾ ä¸è½½ stopPolling(); state.progress = 100; await downloadProduct(task); return; } if (task.status === 40) { stopPolling(); state.phase = 'canceled'; state.message = '导åºå·²åæ¶'; return; } if (task.status === 50) { stopPolling(); state.phase = 'failed'; state.errorMsg = task.errorMsg ?? '导åºå¤±è´¥'; state.message = '导åºå¤±è´¥'; return; } state.phase = 'generating'; state.message = `æ£å¨çæï¼${state.finished} / ${state.total}ï¼â¦`; } catch (error: any) { stopPolling(); failWith(error); } } /** ä¸è½½äº§ç©ï¼ä¼å æµå¼åå ¥ç£çï¼é¿å æ°ç¾ MB çå 容æ´åé©»çå å */ async function downloadProduct(task: MesProQrExportApi.ExportTask) { state.phase = 'downloading'; state.message = 'æ£å¨ä¸è½½â¦'; state.downloadLoaded = 0; state.downloadTotal = task.fileSize ?? 0; const fileName = `${task.taskName ?? 'äºç»´ç 导åº'}.zip`; const url = `${apiURL}${getQrExportDownloadPath(task.id)}`; const token = useAccessStore().accessToken; const controller = new AbortController(); let canceled = false; let writable: any = null; downloadAbort = () => { canceled = true; controller.abort(); }; try { const response = await fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : {}, signal: controller.signal, }); if (!response.ok || !response.body) { throw new Error(`ä¸è½½å¤±è´¥ï¼HTTP ${response.status}ï¼`); } const contentLength = Number(response.headers.get('content-length') ?? 0); if (contentLength > 0) { state.downloadTotal = contentLength; } const reader = response.body.getReader(); downloadAbort = () => { canceled = true; void reader.cancel(); }; const useDisk = Boolean(saveHandle); if (useDisk) { writable = await saveHandle.createWritable(); } // è¾¹æ¶è¾¹åï¼è¿åº¦æ¥èªçå®åèæ°ï¼è½åçå°±åªå ä¸ä¸ªåççå å const chunks: Uint8Array[] = []; let loaded = 0; for (;;) { const { done, value } = await reader.read(); if (done) break; if (value) { if (useDisk) { await writable.write(value); } else { chunks.push(value); } loaded += value.length; state.downloadLoaded = loaded; } } if (canceled) { if (useDisk) await writable.abort(); return; } if (useDisk) { await writable.close(); } else { const blob = new Blob(chunks as BlobPart[], { type: 'application/zip' }); downloadFileFromBlobPart({ fileName, source: blob }); } state.phase = 'done'; state.message = `å·²å¯¼åº ${state.total} å¼ `; } catch (error: any) { if (canceled) return; failWith(error); } finally { downloadAbort = null; saveHandle = null; } } /** åæ¶ï¼çæä¸åæ¶ä»»å¡ï¼ä¸è½½ä¸ä¸æä¸è½½ */ async function cancel() { if (state.phase === 'downloading') { downloadAbort?.(); state.phase = 'canceled'; state.message = '已忶ä¸è½½ï¼æå¡ç«¯æä»¶ä¼è¢«èªå¨æ¸ çï¼'; return; } if (state.taskId && ['counting', 'generating'].includes(state.phase)) { try { await cancelQrExportTask(state.taskId); } catch { // åæ¶å¤±è´¥ä¸é»æç颿¶å°¾ï¼æå¡ç«¯ä»»å¡çè¶ æ¶åæ¶æºå¶ä¼å åº } stopPolling(); state.phase = 'canceled'; state.message = '已忶坼åº'; } } /** æ¶èµ·ä¸ºæµ®æ ï¼ä»»å¡ç»§ç»å¨åå°è· */ function minimize() { state.minimized = true; } /** 仿µ®æ å±å¼é¢æ¿ */ function expand() { state.minimized = false; } /** å ³é颿¿å¹¶æ¸ ç©ºç¶æï¼è¿è¡ä¸éå åæ¶ï¼ */ function close() { stopPolling(); downloadAbort?.(); downloadAbort = null; saveHandle = null; resetState(); } function failWith(error: any) { state.phase = 'failed'; state.errorMsg = error?.message ?? error?.msg ?? '导åºå¤±è´¥'; state.message = '导åºå¤±è´¥'; } export function useQrExport() { return { state, isActive, start, cancel, minimize, expand, close, }; }