|
|
|
|
@ -8,7 +8,8 @@ import { listTableRowIndex } from '../../utils/listTableRowIndex'
|
|
|
|
|
import { resolvePublicMediaUrl } from '../../utils/mediaUrl'
|
|
|
|
|
import { adminUploadImageTooLargeMessage, ADMIN_IMAGE_RECOMMEND_LABEL } from '../../utils/adminMediaLimits'
|
|
|
|
|
|
|
|
|
|
const STUDY_TOUR_LIST_SCROLL_X = 1180
|
|
|
|
|
const STUDY_TOUR_LIST_SCROLL_X = 1280
|
|
|
|
|
const STUDY_TOUR_EXPORT_MAX = 50
|
|
|
|
|
|
|
|
|
|
type DictOption = { id: number; item_value: string; item_label: string }
|
|
|
|
|
|
|
|
|
|
@ -78,6 +79,8 @@ const formBaseline = ref('')
|
|
|
|
|
const importDocInput = ref<HTMLInputElement | null>(null)
|
|
|
|
|
const importingDoc = ref(false)
|
|
|
|
|
const importedFromDoc = ref(false)
|
|
|
|
|
const selectedKeys = ref<number[]>([])
|
|
|
|
|
const exporting = ref(false)
|
|
|
|
|
|
|
|
|
|
const form = reactive({
|
|
|
|
|
name: '',
|
|
|
|
|
@ -576,6 +579,8 @@ async function loadData(keepCurrentPage = false) {
|
|
|
|
|
])
|
|
|
|
|
rows.value = tourRes.data
|
|
|
|
|
venues.value = venueRes.data
|
|
|
|
|
const idSet = new Set(rows.value.map((r) => r.id))
|
|
|
|
|
selectedKeys.value = selectedKeys.value.filter((id) => idSet.has(id))
|
|
|
|
|
if (!keepCurrentPage) listPagination.current = 1
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
Message.error(error?.response?.data?.message ?? '加载研学线路失败')
|
|
|
|
|
@ -588,6 +593,99 @@ function onListPageChange(page: number) {
|
|
|
|
|
listPagination.current = page
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onSelectionChange(keys: (string | number)[]) {
|
|
|
|
|
selectedKeys.value = keys
|
|
|
|
|
.map((k) => Number(k))
|
|
|
|
|
.filter((id) => Number.isFinite(id) && id > 0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function filenameFromContentDisposition(header: string | undefined, fallback: string) {
|
|
|
|
|
if (!header) return fallback
|
|
|
|
|
const star = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(header)
|
|
|
|
|
if (star?.[1]) {
|
|
|
|
|
try {
|
|
|
|
|
const raw = decodeURIComponent(star[1].trim().replace(/^"+|"+$/g, ''))
|
|
|
|
|
if (raw) return raw
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const plain = /filename="?([^";]+)/i.exec(header)
|
|
|
|
|
const raw = (plain?.[1] || '').trim().replace(/^"+|"+$/g, '')
|
|
|
|
|
return raw || fallback
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function messageFromBlobError(error: any): Promise<string> {
|
|
|
|
|
const data = error?.response?.data
|
|
|
|
|
if (data instanceof Blob) {
|
|
|
|
|
try {
|
|
|
|
|
const text = await data.text()
|
|
|
|
|
const j = JSON.parse(text) as { message?: string }
|
|
|
|
|
if (j?.message) return j.message
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return error?.response?.data?.message ?? error?.message ?? '导出失败'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function exportStudyTours(ids: number[]) {
|
|
|
|
|
const uniqueIds = [...new Set(ids.filter((id) => Number.isFinite(id) && id > 0))]
|
|
|
|
|
if (!uniqueIds.length) {
|
|
|
|
|
Message.warning('请先勾选要导出的线路')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (uniqueIds.length > STUDY_TOUR_EXPORT_MAX) {
|
|
|
|
|
Message.warning(`单次最多导出 ${STUDY_TOUR_EXPORT_MAX} 条线路`)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (exporting.value) return
|
|
|
|
|
exporting.value = true
|
|
|
|
|
try {
|
|
|
|
|
const res = await http.post(
|
|
|
|
|
'/study-tours/export',
|
|
|
|
|
{ ids: uniqueIds },
|
|
|
|
|
{ responseType: 'blob', timeout: 120000 },
|
|
|
|
|
)
|
|
|
|
|
const blob = res.data as Blob
|
|
|
|
|
if (blob.type && (blob.type.includes('json') || blob.type.includes('text/html'))) {
|
|
|
|
|
const text = await blob.text()
|
|
|
|
|
let msg = '导出失败'
|
|
|
|
|
try {
|
|
|
|
|
const j = JSON.parse(text) as { message?: string }
|
|
|
|
|
if (j?.message) msg = j.message
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
Message.error(msg)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
const fallback = uniqueIds.length === 1
|
|
|
|
|
? '研学线路申报表.docx'
|
|
|
|
|
: `研学线路申报表-${new Date().toISOString().slice(0, 10)}.zip`
|
|
|
|
|
const filename = filenameFromContentDisposition(res.headers['content-disposition'] as string | undefined, fallback)
|
|
|
|
|
const url = URL.createObjectURL(blob)
|
|
|
|
|
const a = document.createElement('a')
|
|
|
|
|
a.href = url
|
|
|
|
|
a.download = filename
|
|
|
|
|
a.click()
|
|
|
|
|
URL.revokeObjectURL(url)
|
|
|
|
|
Message.success(uniqueIds.length === 1 ? '已导出 Word' : `已导出 ${uniqueIds.length} 条线路`)
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
Message.error(await messageFromBlobError(error))
|
|
|
|
|
} finally {
|
|
|
|
|
exporting.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function exportSelected() {
|
|
|
|
|
void exportStudyTours(selectedKeys.value)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function exportOne(row: StudyTour) {
|
|
|
|
|
void exportStudyTours([row.id])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function toggleShelf(row: StudyTour) {
|
|
|
|
|
try {
|
|
|
|
|
await http.put(`/study-tours/${row.id}`, { is_on_shelf: !row.is_on_shelf })
|
|
|
|
|
@ -893,6 +991,11 @@ onMounted(async () => {
|
|
|
|
|
@change="onImportDocSelected"
|
|
|
|
|
/>
|
|
|
|
|
<a-button :loading="importingDoc" @click="triggerImportDoc">从申报表导入</a-button>
|
|
|
|
|
<a-button
|
|
|
|
|
:loading="exporting"
|
|
|
|
|
:disabled="!selectedKeys.length"
|
|
|
|
|
@click="exportSelected"
|
|
|
|
|
>导出选中{{ selectedKeys.length ? `(${selectedKeys.length})` : '' }}</a-button>
|
|
|
|
|
<a-button type="primary" @click="openCreate">新建线路</a-button>
|
|
|
|
|
</a-space>
|
|
|
|
|
|
|
|
|
|
@ -902,7 +1005,9 @@ onMounted(async () => {
|
|
|
|
|
:data="rows"
|
|
|
|
|
:loading="loading"
|
|
|
|
|
row-key="id"
|
|
|
|
|
:row-selection="{ type: 'checkbox', selectedRowKeys: selectedKeys, showCheckedAll: true, onlyCurrent: false }"
|
|
|
|
|
:pagination="{ current: listPagination.current, pageSize: listPagination.pageSize, total: rows.length, showTotal: true }"
|
|
|
|
|
@selection-change="onSelectionChange"
|
|
|
|
|
@page-change="onListPageChange"
|
|
|
|
|
>
|
|
|
|
|
<template #columns>
|
|
|
|
|
@ -941,10 +1046,11 @@ onMounted(async () => {
|
|
|
|
|
<a-tag :color="record.is_on_shelf ? 'green' : 'gray'">{{ record.is_on_shelf ? '上架' : '下架' }}</a-tag>
|
|
|
|
|
</template>
|
|
|
|
|
</a-table-column>
|
|
|
|
|
<a-table-column title="操作" :width="200" fixed="right" align="center">
|
|
|
|
|
<a-table-column title="操作" :width="240" fixed="right" align="center">
|
|
|
|
|
<template #cell="{ record }">
|
|
|
|
|
<a-space wrap :size="4">
|
|
|
|
|
<a-button type="text" @click="openEdit(record)">编辑</a-button>
|
|
|
|
|
<a-button type="text" :loading="exporting" @click="exportOne(record)">导出</a-button>
|
|
|
|
|
<a-button type="text" status="warning" @click="toggleShelf(record)">{{ record.is_on_shelf ? '下架' : '上架' }}</a-button>
|
|
|
|
|
<a-popconfirm content="确定删除该线路?" @ok="removeRow(record)">
|
|
|
|
|
<a-button type="text" status="danger">删除</a-button>
|
|
|
|
|
|