修复采购审核竞态并整理管理页面展示
This commit is contained in:
@@ -2,8 +2,11 @@ import { flushPromises, mount, shallowMount } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import ElementPlus from 'element-plus'
|
||||
import LoadingListDetailDrawer from './LoadingListDetailDrawer.vue'
|
||||
import { printLoadingList } from '../printLoadingList'
|
||||
import type { LoadingDetail } from '../api/platformLoadingLists'
|
||||
|
||||
vi.mock('../printLoadingList', () => ({ printLoadingList: vi.fn(async () => true) }))
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('shared manifest presentation', () => {
|
||||
@@ -33,7 +36,7 @@ describe('shared manifest presentation', () => {
|
||||
})
|
||||
|
||||
it.each([undefined, '返回货柜概览'])('keeps print in the common header and emits return for %s', async (backLabel) => {
|
||||
const print = vi.spyOn(window, 'print').mockImplementation(() => {})
|
||||
const print = vi.mocked(printLoadingList)
|
||||
const wrapper = shallowMount(LoadingListDetailDrawer, {
|
||||
props: { mode: 'confirmed', dense: true, backLabel, canEdit: false, loading: false, error: null,
|
||||
detail: { summary: { planId: '1', warehouseId: '11', loadingListNo: 'LP-TEST', status: 'COMPLETED' }, items: [], histories: [] } as unknown as LoadingDetail },
|
||||
@@ -44,7 +47,7 @@ describe('shared manifest presentation', () => {
|
||||
const button = wrapper.get('.detail-page-header__actions button')
|
||||
expect(button.text()).toBe('打印装柜清单')
|
||||
await button.trigger('click'); await flushPromises()
|
||||
expect(print).toHaveBeenCalledTimes(1)
|
||||
expect(print).toHaveBeenCalledExactlyOnceWith(wrapper.element)
|
||||
expect(wrapper.get('.detail-page-header__back').text()).toBe(backLabel || '返回列表')
|
||||
await wrapper.get('.detail-page-header__back').trigger('click')
|
||||
expect(wrapper.emitted('back')).toHaveLength(1)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import '@/styles/adminDensity.css'
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ArrowLeft, Check, ChevronRight, Pencil, Printer } from '@lucide/vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
@@ -30,6 +31,15 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ back: []; retry: []; edit: []; editRemarks: [item: LoadingItem]; confirmPreload: [] }>()
|
||||
const printRoot = ref<HTMLElement>()
|
||||
|
||||
async function printDetail() {
|
||||
if (props.mode !== 'confirmed' || props.loading || props.error || !props.detail) return false
|
||||
try { return await printLoadingList(printRoot.value) }
|
||||
catch (cause) { ElMessage.error(cause instanceof Error ? cause.message : '装柜清单打印失败,请重试'); return false }
|
||||
}
|
||||
|
||||
defineExpose({ print: printDetail })
|
||||
|
||||
const actionColumn: DataTableColumn = { key: 'actions', label: '操作', fixed: 'left', width: 132, slotName: 'actions', hideable: false, movable: false }
|
||||
const baseItemColumns: DataTableColumn[] = [
|
||||
@@ -169,7 +179,7 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-loading="loading" class="loading-detail-content admin-workbench" :class="{ 'admin-density loading-density': mode === 'preload' || dense, 'workbench-panel is-compact is-page-surface': !embedded }" :aria-label="title" :aria-busy="loading">
|
||||
<section ref="printRoot" v-loading="loading" class="loading-detail-content admin-workbench" :class="{ 'admin-density loading-density': mode === 'preload' || dense, 'workbench-panel is-compact is-page-surface': !embedded }" :aria-label="title" :aria-busy="loading">
|
||||
<section class="detail-overview" aria-label="清单概览">
|
||||
<header class="detail-page-header" :class="{ 'detail-page-header--embedded': embedded }">
|
||||
<el-button link type="primary" class="detail-page-header__back" :class="{ 'density-back': mode === 'preload' || dense }" @click="emit('back')"><ArrowLeft :size="16" />{{ backLabel || '返回列表' }}</el-button>
|
||||
@@ -177,7 +187,7 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
<h1>{{ title }}</h1>
|
||||
</div>
|
||||
<div v-if="detail && summary" class="detail-page-header__actions">
|
||||
<el-button v-if="mode === 'confirmed'" :disabled="loading" @click="printLoadingList"><Printer :size="16" />打印装柜清单</el-button>
|
||||
<el-button v-if="mode === 'confirmed'" :disabled="loading || Boolean(error)" @click="printDetail"><Printer :size="16" />打印装柜清单</el-button>
|
||||
<PalletLabelPrintDialog :dense="mode === 'preload' || dense" v-if="summary.warehouseId" :plan-id="summary.planId" :warehouse-id="summary.warehouseId" :disabled="!summary.containerNo || summary.status === 'EDITING'" />
|
||||
<template v-if="canEditItemRemark">
|
||||
<el-button v-if="canEditTransport" @click="emit('edit')"><Pencil :size="16" />{{ summary.containerNo ? '纠正集装箱号 / 航次' : '补录集装箱号 / 航次' }}</el-button>
|
||||
@@ -185,7 +195,7 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
<section v-if="detail && summary" class="detail-summary" aria-label="清单基本信息">
|
||||
<section v-if="detail && summary" class="detail-summary" aria-label="清单基本信息" data-loading-list-print-summary>
|
||||
<div><span>柜次号</span><strong>{{ summary.cabinetNo || '—' }}</strong></div>
|
||||
<div><span>集装箱号</span><strong>{{ summary.containerNo || '待补录' }}</strong></div>
|
||||
<div><span>仓库</span><strong>{{ summary.warehouseName || '—' }}</strong></div>
|
||||
@@ -213,7 +223,7 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
</div>
|
||||
|
||||
<section class="detail-section">
|
||||
<div class="workbench-table"><DataTable
|
||||
<div class="workbench-table" data-loading-list-print-items><DataTable
|
||||
:data="detail.items"
|
||||
:columns="itemColumns"
|
||||
stripe
|
||||
|
||||
@@ -3,7 +3,7 @@ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { reactive } from 'vue'
|
||||
import ConfirmedLoadingListDetailView from './ConfirmedLoadingListDetailView.vue'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ get: vi.fn(), push: vi.fn(), replace: vi.fn(), back: vi.fn(), route: { params: { planId: '1' }, query: { warehouseId: '11' } as Record<string, string> } }))
|
||||
const mocks = vi.hoisted(() => ({ get: vi.fn(), print: vi.fn(), push: vi.fn(), replace: vi.fn(), back: vi.fn(), route: { params: { planId: '1' }, query: { warehouseId: '11' } as Record<string, string> } }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => mocks.route, useRouter: () => mocks }))
|
||||
vi.mock('../api/platformLoadingLists', () => ({ getConfirmedLoadingList: mocks.get, getPreloadPlan: vi.fn() }))
|
||||
vi.mock('@/shared/api/http', () => ({ getApiErrorDetails: (cause: Error) => ({ message: cause.message }) }))
|
||||
@@ -11,7 +11,7 @@ vi.mock('@/shared/api/http', () => ({ getApiErrorDetails: (cause: Error) => ({ m
|
||||
function render() {
|
||||
return shallowMount(ConfirmedLoadingListDetailView, { global: { stubs: {
|
||||
AdminPage: { template: '<main><slot /></main>' },
|
||||
LoadingListDetailDrawer: { name: 'LoadingListDetailDrawer', props: ['detail', 'error', 'loading'], template: '<section />' },
|
||||
LoadingListDetailDrawer: { name: 'LoadingListDetailDrawer', props: ['detail', 'error', 'loading'], methods: { print: () => mocks.print() }, template: '<section />' },
|
||||
} } })
|
||||
}
|
||||
const completed = (planId = '1') => ({ summary: { status: 'COMPLETED', planId, loadingListNo: `LIST-${planId}` }, items: [], histories: [] })
|
||||
@@ -21,7 +21,7 @@ describe('confirmed loading list detail page', () => {
|
||||
vi.clearAllMocks()
|
||||
mocks.route = reactive({ params: { planId: '1' }, query: { warehouseId: '11' } })
|
||||
mocks.get.mockResolvedValue(completed())
|
||||
vi.spyOn(window, 'print').mockImplementation(() => {})
|
||||
mocks.print.mockResolvedValue(true)
|
||||
})
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
it('loads read-only detail on the dedicated page and returns to the list', async () => {
|
||||
@@ -29,7 +29,7 @@ describe('confirmed loading list detail page', () => {
|
||||
expect(mocks.get).toHaveBeenCalledWith('1', '11')
|
||||
const detail = wrapper.findComponent({ name: 'LoadingListDetailDrawer' })
|
||||
expect(detail.props('detail')).toEqual(completed())
|
||||
expect(window.print).not.toHaveBeenCalled()
|
||||
expect(mocks.print).not.toHaveBeenCalled()
|
||||
vi.spyOn(window.history, 'length', 'get').mockReturnValue(1)
|
||||
detail.vm.$emit('back')
|
||||
expect(mocks.push).toHaveBeenCalledWith({ name: 'loading-lists' })
|
||||
@@ -38,7 +38,7 @@ describe('confirmed loading list detail page', () => {
|
||||
it('prints only after detail has loaded and removes the print request', async () => {
|
||||
mocks.route.query.print = '1'
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(window.print).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.print).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.findComponent({ name: 'LoadingListDetailDrawer' }).props('loading')).toBe(false)
|
||||
expect(mocks.replace).toHaveBeenCalledWith({ name: 'loading-list-detail', params: { planId: '1' }, query: { warehouseId: '11' } })
|
||||
wrapper.unmount()
|
||||
@@ -48,7 +48,7 @@ describe('confirmed loading list detail page', () => {
|
||||
mocks.get.mockRejectedValueOnce(new Error('清单不可用'))
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'LoadingListDetailDrawer' }).props('error').message).toBe('清单不可用')
|
||||
expect(window.print).not.toHaveBeenCalled()
|
||||
expect(mocks.print).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('ignores a stale response after switching manifests', async () => {
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import LoadingListDetailDrawer from '../components/LoadingListDetailDrawer.vue'
|
||||
import { useLoadingListDetail } from '../useLoadingListDetail'
|
||||
import { printLoadingList } from '../printLoadingList'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { detail, loading, error, load } = useLoadingListDetail()
|
||||
const detailView = ref<InstanceType<typeof LoadingListDetailDrawer>>()
|
||||
const planId = computed(() => String(route.params.planId || ''))
|
||||
const warehouseId = computed(() => typeof route.query.warehouseId === 'string' ? route.query.warehouseId : '')
|
||||
|
||||
async function loadDetail() {
|
||||
const result = await load('confirmed', planId.value, warehouseId.value)
|
||||
if (!result || detail.value !== result || route.query.print !== '1') return
|
||||
await printLoadingList()
|
||||
await nextTick()
|
||||
if (!await detailView.value?.print() || detail.value !== result) return
|
||||
const { print: _print, ...query } = route.query
|
||||
await router.replace({ name: 'loading-list-detail', params: { planId: planId.value }, query })
|
||||
}
|
||||
@@ -34,7 +35,7 @@ watch([planId, warehouseId], () => void loadDetail())
|
||||
<template>
|
||||
<AdminPage class="admin-workbench admin-density">
|
||||
<div class="confirmed-loading-detail-page">
|
||||
<LoadingListDetailDrawer dense
|
||||
<LoadingListDetailDrawer ref="detailView" dense
|
||||
mode="confirmed"
|
||||
:loading="loading"
|
||||
:detail="detail"
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { nextTick } from 'vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { printLoadingList } from './printLoadingList'
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks(); document.body.replaceChildren() })
|
||||
|
||||
function manifest() {
|
||||
document.body.innerHTML = `<aside>平台导航</aside><nav>后台页签</nav><section id="manifest" aria-label="装柜清单 · LIST-TEST">
|
||||
<header><button>返回货柜概览</button><button>打印装柜清单</button></header>
|
||||
<section class="detail-summary" data-loading-list-print-summary><div><span>柜次号</span><strong>CB-TEST</strong></div><div><span>集装箱号</span><strong>CONTAINER-TEST</strong></div></section>
|
||||
<div data-loading-list-print-items><button>字段设置</button><div style="width:3000px;overflow:hidden"><table>
|
||||
<thead><tr><th>入仓码</th><th>SKU</th><th>备注</th></tr></thead>
|
||||
<tbody><tr><td style="width:200px;white-space:nowrap">IN-FIRST</td><td>SKU & <盒></td><td>第一行备注</td></tr>
|
||||
<tr><td>IN-LAST</td><td>SKU-LAST</td><td>最后一列完整备注</td></tr></tbody>
|
||||
</table></div></div><section>操作历史:创建计划、调整货物</section></section>`
|
||||
return document.getElementById('manifest')!
|
||||
}
|
||||
|
||||
function interceptPrint(onPrint: (frame: HTMLIFrameElement) => void) {
|
||||
let frame: HTMLIFrameElement | undefined
|
||||
const append = document.body.append.bind(document.body)
|
||||
vi.spyOn(document.body, 'append').mockImplementation((...nodes) => {
|
||||
append(...nodes)
|
||||
for (const node of nodes) {
|
||||
if (!(node instanceof HTMLIFrameElement)) continue
|
||||
frame = node
|
||||
vi.spyOn(node.contentWindow!, 'focus').mockImplementation(() => {})
|
||||
vi.spyOn(node.contentWindow!, 'print').mockImplementation(() => onPrint(node))
|
||||
}
|
||||
})
|
||||
return async () => {
|
||||
await nextTick()
|
||||
if (!frame) throw new Error('Print frame was not created')
|
||||
frame.dispatchEvent(new Event('load'))
|
||||
return frame
|
||||
}
|
||||
}
|
||||
|
||||
describe('loading list printing', () => {
|
||||
it('prints the manifest summary and every cargo row and column in an isolated document', async () => {
|
||||
const root = manifest(), original = root.outerHTML
|
||||
const hostPrint = vi.spyOn(window, 'print').mockImplementation(() => {})
|
||||
let snapshot: Document | undefined
|
||||
const load = interceptPrint(frame => {
|
||||
snapshot = frame.contentDocument!
|
||||
expect(snapshot.title).toBe('装柜清单 · LIST-TEST')
|
||||
expect(snapshot.querySelector('h1')?.textContent).toBe('装柜清单 · LIST-TEST')
|
||||
expect(snapshot.body.textContent).toContain('CB-TEST')
|
||||
expect(snapshot.body.textContent).toContain('CONTAINER-TEST')
|
||||
expect(snapshot.querySelectorAll('tbody tr')).toHaveLength(2)
|
||||
expect(snapshot.querySelectorAll('thead th')).toHaveLength(3)
|
||||
expect(snapshot.body.textContent).toContain('最后一列完整备注')
|
||||
expect(snapshot.body.textContent).toContain('SKU & <盒>')
|
||||
expect(snapshot.querySelector('td')?.getAttribute('style')).toBeNull()
|
||||
expect(snapshot.body.textContent).not.toMatch(/平台导航|后台页签|返回货柜概览|字段设置|操作历史/)
|
||||
expect(snapshot.querySelectorAll('button')).toHaveLength(0)
|
||||
frame.contentWindow!.dispatchEvent(new Event('afterprint'))
|
||||
})
|
||||
const pending = printLoadingList(root)
|
||||
const frame = await load()
|
||||
await expect(pending).resolves.toBe(true)
|
||||
expect(snapshot).toBeDefined()
|
||||
expect(hostPrint).not.toHaveBeenCalled()
|
||||
expect(frame.isConnected).toBe(false)
|
||||
expect(root.outerHTML).toBe(original)
|
||||
})
|
||||
|
||||
it('keeps the print document until the dialog closes and prevents duplicate print jobs', async () => {
|
||||
const root = manifest(), print = vi.fn()
|
||||
const load = interceptPrint(print)
|
||||
const pending = printLoadingList(root)
|
||||
const frame = await load()
|
||||
expect(frame.isConnected).toBe(true)
|
||||
expect(print).toHaveBeenCalledOnce()
|
||||
await expect(printLoadingList(root)).resolves.toBe(false)
|
||||
expect(document.querySelectorAll('iframe')).toHaveLength(1)
|
||||
frame.contentWindow!.dispatchEvent(new Event('afterprint'))
|
||||
await expect(pending).resolves.toBe(true)
|
||||
expect(frame.isConnected).toBe(false)
|
||||
expect(document.querySelector('aside')?.textContent).toBe('平台导航')
|
||||
})
|
||||
|
||||
it('never falls back to printing the entire page when the manifest is missing or incomplete', async () => {
|
||||
const hostPrint = vi.spyOn(window, 'print').mockImplementation(() => {})
|
||||
await expect(printLoadingList(null)).rejects.toThrow('清单尚未加载完成')
|
||||
await expect(printLoadingList(document.createElement('section'))).rejects.toThrow('清单尚未加载完成')
|
||||
expect(hostPrint).not.toHaveBeenCalled()
|
||||
expect(document.querySelector('iframe')).toBeNull()
|
||||
})
|
||||
|
||||
it('removes the isolated document after a print failure', async () => {
|
||||
const root = manifest(), original = root.outerHTML
|
||||
const load = interceptPrint(() => { throw new Error('printer unavailable') })
|
||||
const pending = printLoadingList(root)
|
||||
const rejected = expect(pending).rejects.toThrow('printer unavailable')
|
||||
const frame = await load()
|
||||
await rejected
|
||||
expect(frame.isConnected).toBe(false)
|
||||
expect(root.outerHTML).toBe(original)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,82 @@
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
export async function printLoadingList() {
|
||||
await nextTick()
|
||||
window.print()
|
||||
const printStyles = `
|
||||
@page { size: A4 landscape; margin: 10mm; }
|
||||
body { margin: 0; color: #111; font: 10pt/1.4 Arial, "Microsoft YaHei", sans-serif; }
|
||||
h1 { margin: 0 0 12px; font-size: 16pt; }
|
||||
.detail-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin-bottom: 16px; border-top: 1px solid #aaa; border-left: 1px solid #aaa; }
|
||||
.detail-summary > div { display: flex; gap: 8px; padding: 6px; border-right: 1px solid #aaa; border-bottom: 1px solid #aaa; }
|
||||
.detail-summary > div > span:first-child { flex-shrink: 0; color: #555; }
|
||||
.detail-summary strong { min-width: 0; overflow-wrap: anywhere; }
|
||||
table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 8pt; }
|
||||
caption { margin-bottom: 6px; font-size: 11pt; font-weight: bold; text-align: left; }
|
||||
th, td { padding: 4px; border: 1px solid #aaa; vertical-align: top; text-align: left; overflow-wrap: anywhere; }
|
||||
th { background: #eee; }
|
||||
.cell { white-space: normal; overflow-wrap: anywhere; }
|
||||
small { display: block; }
|
||||
thead { display: table-header-group; }
|
||||
tr { break-inside: avoid; }
|
||||
`
|
||||
|
||||
let printing = false
|
||||
|
||||
function printDocumentHtml(root: HTMLElement) {
|
||||
const summary = root.querySelector('[data-loading-list-print-summary]')
|
||||
const items = root.querySelector('[data-loading-list-print-items]')
|
||||
const head = items?.querySelector('thead')
|
||||
const body = items?.querySelector('tbody')
|
||||
if (!summary || !head || !body) throw new Error('清单尚未加载完成,请稍后重试')
|
||||
|
||||
const title = root.getAttribute('aria-label') || '装柜清单'
|
||||
const page = document.implementation.createHTMLDocument(title)
|
||||
page.documentElement.lang = 'zh-CN'
|
||||
const style = page.createElement('style')
|
||||
style.textContent = printStyles
|
||||
page.head.append(style)
|
||||
const heading = page.createElement('h1')
|
||||
heading.textContent = title
|
||||
const table = page.createElement('table')
|
||||
const caption = page.createElement('caption')
|
||||
caption.textContent = '装柜货物明细'
|
||||
table.append(caption, head.cloneNode(true), body.cloneNode(true))
|
||||
page.body.append(heading, summary.cloneNode(true), table)
|
||||
page.body.querySelectorAll('button, input, svg, [hidden], [aria-hidden="true"]').forEach(node => node.remove())
|
||||
page.body.querySelectorAll<HTMLElement>('[style]').forEach(node => {
|
||||
if (node.style.display === 'none') node.remove()
|
||||
else node.removeAttribute('style')
|
||||
})
|
||||
return `<!doctype html>${page.documentElement.outerHTML}`
|
||||
}
|
||||
|
||||
export async function printLoadingList(root: HTMLElement | null | undefined): Promise<boolean> {
|
||||
if (printing) return false
|
||||
if (!root) throw new Error('清单尚未加载完成,请稍后重试')
|
||||
printing = true
|
||||
let frame: HTMLIFrameElement | undefined
|
||||
try {
|
||||
await nextTick()
|
||||
const html = printDocumentHtml(root)
|
||||
frame = document.createElement('iframe')
|
||||
frame.title = '装柜清单打印'
|
||||
frame.setAttribute('aria-hidden', 'true')
|
||||
frame.style.cssText = 'position:fixed;left:-10000px;top:0;width:297mm;height:210mm;border:0'
|
||||
document.body.append(frame)
|
||||
const printDocument = frame.contentDocument
|
||||
if (!printDocument) throw new Error('无法创建打印页面,请重试')
|
||||
const printFrame = frame
|
||||
return await new Promise<boolean>((resolve, reject) => {
|
||||
printFrame.addEventListener('load', () => {
|
||||
const target = printFrame.contentWindow
|
||||
if (!target) { reject(new Error('无法创建打印页面,请重试')); return }
|
||||
target.addEventListener('afterprint', () => resolve(true), { once: true })
|
||||
try { target.focus(); target.print() } catch (cause) { reject(cause) }
|
||||
}, { once: true })
|
||||
printDocument.open()
|
||||
printDocument.write(html)
|
||||
printDocument.close()
|
||||
})
|
||||
} finally {
|
||||
frame?.remove()
|
||||
printing = false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user