From 33fb1213ba7943e76ae5caecceecb90e1cf40f07 Mon Sep 17 00:00:00 2001 From: lion <120344285@qq.com> Date: Tue, 30 Jun 2026 16:05:43 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A0=94=E5=AD=A6=E7=BA=BF=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/datetime.ts | 15 + src/utils/exportActivityListXlsx.ts | 11 +- src/views/activities/ActivityList.vue | 54 +- src/views/study-tours/StudyTourList.vue | 1196 ++++++++++++++++++----- 4 files changed, 986 insertions(+), 290 deletions(-) diff --git a/src/utils/datetime.ts b/src/utils/datetime.ts index e841af3..03aa979 100644 --- a/src/utils/datetime.ts +++ b/src/utils/datetime.ts @@ -1,5 +1,20 @@ const TZ_SH = 'Asia/Shanghai' +/** 日历日 YYYY-MM-DD:纯日期字符串原样返回,ISO 时间戳按东八区换算 */ +export function ymdFromDateValue(v?: string | null): string { + if (v == null || v === '') return '' + const s = String(v).trim() + if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s + const d = new Date(s) + if (Number.isNaN(d.getTime())) return s.length >= 10 ? s.slice(0, 10) : s + return new Intl.DateTimeFormat('en-CA', { + timeZone: TZ_SH, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(d) +} + /** ISO 时间 → 东八区「YYYY-MM-DD HH:mm:ss」(与后台导出一致) */ export function formatDateTimeZh(iso?: string | null): string { if (iso == null || iso === '') return '-' diff --git a/src/utils/exportActivityListXlsx.ts b/src/utils/exportActivityListXlsx.ts index 36148e9..1529162 100644 --- a/src/utils/exportActivityListXlsx.ts +++ b/src/utils/exportActivityListXlsx.ts @@ -1,4 +1,5 @@ import * as XLSX from 'xlsx' +import { ymdFromDateValue } from './datetime' function str(v: unknown): string { if (v == null || v === '') return '' @@ -107,8 +108,8 @@ const BASE_COL_SPEC: { zh: string; format: (row: Record) => str { zh: '审核状态', format: (r) => auditStatusLabel(r.audit_status) }, { zh: '上架', format: (r) => yn(r.is_active) }, { zh: '热门', format: (r) => yn(r.is_hot) }, - { zh: '活动开始', format: (r) => fmtDateTime(r.start_at) }, - { zh: '活动结束', format: (r) => fmtDateTime(r.end_at) }, + { zh: '开始日期', format: (r) => ymdFromDateValue(r.start_at as string | null | undefined) }, + { zh: '结束日期', format: (r) => ymdFromDateValue(r.end_at as string | null | undefined) }, { zh: '联系人', format: (r) => str(r.contact_name) }, { zh: '联系电话', format: (r) => str(r.contact_phone) }, { zh: '详情正文', format: (r) => stripHtml(r.detail_html) }, @@ -165,7 +166,11 @@ function fmtSessionCell(key: string, d: Record): string | numbe const raw = d[key] if (key === 'day_quota' || key === 'booked_count') return num(raw) if ( - key === 'activity_date' || + key === 'activity_date' + ) { + return ymdFromDateValue(raw as string | null | undefined) + } + if ( key === 'session_start_at' || key === 'session_end_at' || key === 'booking_opens_at' || diff --git a/src/views/activities/ActivityList.vue b/src/views/activities/ActivityList.vue index dff8b3b..e37bf9f 100644 --- a/src/views/activities/ActivityList.vue +++ b/src/views/activities/ActivityList.vue @@ -10,6 +10,7 @@ import { useUnsavedChangesGuard } from '../../composables/useUnsavedChangesGuard import { adminUploadImageTooLargeMessage, ADMIN_IMAGE_RECOMMEND_LABEL } from '../../utils/adminMediaLimits' import { listTableRowIndex } from '../../utils/listTableRowIndex' import { downloadActivityListXlsx } from '../../utils/exportActivityListXlsx' +import { ymdFromDateValue } from '../../utils/datetime' import { reverseMapGeocode, searchMapPlaces } from '../../utils/mapGeo' import { bindTiandituMapClick, @@ -35,6 +36,7 @@ type Venue = { open_time?: string } type CurrentUser = { id?: number; role: string; venues: Venue[]; full_admin_access?: boolean } +type DictOption = { item_value: string; item_label: string } type Activity = { id: number venue_id: number @@ -83,6 +85,7 @@ type Activity = { offline_reservation_method?: string | null booking_method_note?: string | null ticket_fee_note?: string | null + age_group?: string | null external_url?: string | null /** 线上:已报名人数;H5/统计用 */ registered_count?: number @@ -96,6 +99,7 @@ type Activity = { const rows = ref([]) const venues = ref([]) +const ageGroupOptions = ref([]) /** 活动举办场馆等引用场景:待审/退回时展示已通过快照中的名称 */ function venueReferenceDisplayName(v: Pick | null | undefined): string { @@ -300,19 +304,6 @@ const DEFAULT_CENTER = { lat: 31.299379, lng: 120.585315 } const route = useRoute() -/** 接口 ISO8601 → 东八区日历日 YYYY-MM-DD(与后端 APP_TIMEZONE 一致)。勿对 ISO 用 slice(0,10),UTC 日界会导致与后端差一天。 */ -function formatYmdInShanghai(iso: string | undefined | null): string { - if (!iso) return '' - const d = new Date(String(iso)) - if (Number.isNaN(d.getTime())) return String(iso).slice(0, 10) - return new Intl.DateTimeFormat('en-CA', { - timeZone: 'Asia/Shanghai', - year: 'numeric', - month: '2-digit', - day: '2-digit', - }).format(d) -} - function parseShanghaiLocalToMs(datetimeYmdHmss: string): number { let s = String(datetimeYmdHmss || '').trim().replace(' ', 'T') if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(s)) s += ':00' @@ -336,8 +327,8 @@ function reservationTypeSupportsSessionSettings(rt?: string | null): boolean { } function formatActivityTableDateRange(record: Activity): string { - const s = record.start_at ? formatYmdInShanghai(String(record.start_at)) : '' - const e = record.end_at ? formatYmdInShanghai(String(record.end_at)) : '' + const s = record.start_at ? ymdFromDateValue(String(record.start_at)) : '' + const e = record.end_at ? ymdFromDateValue(String(record.end_at)) : '' if (!s && !e) return '-' if (s && e) { if (s === e) return s @@ -396,6 +387,7 @@ const form = reactive({ is_hot: false, booking_method_note: '', fee_note: '', + age_group: undefined as string | undefined, location: '', check_in_meeting_point: '', lat: undefined as number | undefined, @@ -1559,6 +1551,15 @@ function formatActivityStatsCell(record: Activity) { return '浏览 ' + statNum(record.view_count) } +async function loadAgeGroupOptions() { + try { + const { data } = await http.get('/dict-items', { params: { dict_type: 'age_groups', active_only: 1 } }) + ageGroupOptions.value = Array.isArray(data) ? data : [] + } catch { + ageGroupOptions.value = [] + } +} + async function loadAll() { loading.value = true try { @@ -1567,6 +1568,7 @@ async function loadAll() { params: buildActivitiesListParams(pagination.current, pagination.pageSize), }), http.get('/venues'), + loadAgeGroupOptions(), ]) rows.value = aRes.data.data pagination.total = aRes.data.total @@ -1598,6 +1600,7 @@ function openCreate() { form.reservation_type = 'online' form.booking_method_note = '平台预约' form.fee_note = '免费' + form.age_group = undefined form.location = '' form.check_in_meeting_point = '' form.lat = undefined @@ -1657,6 +1660,7 @@ function openEdit(row: Activity) { (['online', 'none'].includes(reservationKindEffective(form.reservation_type)) ? '免费' : '') + form.age_group = row.age_group || undefined form.location = row.location || '' form.check_in_meeting_point = row.check_in_meeting_point || '' form.lat = parseCoord(row.lat) @@ -1665,8 +1669,8 @@ function openEdit(row: Activity) { form.title = row.title form.contact_name = row.contact_name ?? '' form.contact_phone = row.contact_phone ?? '' - form.start_at = row.start_at ? formatYmdInShanghai(row.start_at) : '' - form.end_at = row.end_at ? formatYmdInShanghai(row.end_at) : '' + form.start_at = row.start_at ? ymdFromDateValue(row.start_at) : '' + form.end_at = row.end_at ? ymdFromDateValue(row.end_at) : '' form.detail_html = row.detail_html || '' form.cover_image = row.cover_image || '' form.gallery_media = Array.isArray(row.gallery_media) ? [...row.gallery_media] : [] @@ -1893,6 +1897,7 @@ async function executeUnifiedActivitySave(): Promise { tags: form.tags, reservation_notice: null, open_time: null, + age_group: form.age_group || null, } if (isUnifiedNature) { payload.offline_reservation_method = null @@ -2570,6 +2575,21 @@ async function removeActivity(row: Activity) { {{ formErrors.fee_note }} + + + {{ opt.item_label }} + + -import { computed, onMounted, reactive, ref } from 'vue' +import { computed, nextTick, onMounted, reactive, ref } from 'vue' import { Message } from '@arco-design/web-vue' import { http } from '../../api/http' import RichEditorField from '../../components/RichEditorField.vue' +import { useModalDirtyGuard } from '../../composables/useModalDirtyGuard' 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 = 900 +const STUDY_TOUR_LIST_SCROLL_X = 1100 -type Venue = { - id: number - name: string -} +type DictOption = { id: number; item_value: string; item_label: string } -type CurrentUser = { - role: string -} +type Venue = { id: number; name: string } + +type VenueItemSystem = { type: 'system'; venue_id: number } +type VenueItemCustom = { type: 'custom'; name: string; address?: string } +type VenueItem = VenueItemSystem | VenueItemCustom + +type RoutePlanItem = { time: string; activity: string; location: string } +type RoutePlan = { date_label: string; items: RoutePlanItem[] } + +type Course = { sort: number; name: string; content: string } type StudyTour = { id: number name: string tags?: string[] venue_ids?: number[] + venue_items?: VenueItem[] + org_name?: string + seasons?: string[] + suitable_count?: string + grade_levels?: string[] + duration?: string + contact_person?: string + contact_phones?: string cover_image?: string intro_html?: string + route_plans?: RoutePlan[] + courses?: Course[] + fee_html?: string + implementation_html?: string sort: number is_on_shelf: boolean } @@ -36,39 +53,127 @@ const isCreate = ref(true) const editId = ref(null) const rows = ref([]) const venues = ref([]) -const currentUser = ref(null) +const seasonOptions = ref([]) +const gradeOptions = ref([]) +const currentUser = ref<{ role: string } | null>(null) const filterKeyword = ref('') const filterVenueId = ref(undefined) const filterOnShelf = ref(undefined) -/** 与活动列表一致:本地分页 + 显示总条数 */ +const filterSeason = ref(undefined) +const filterGradeLevel = ref(undefined) +const filterOrgName = ref('') const listPagination = reactive({ current: 1, pageSize: 10 }) -const modalBodyStyle = { maxHeight: '70vh', overflow: 'auto' } +const MODAL_BODY_HEIGHT = '72vh' +const modalBodyStyle = { height: MODAL_BODY_HEIGHT, overflow: 'hidden', padding: '12px 20px 16px' } const editorRenderKey = ref(0) +const activeTab = ref('basic') const mediaPreviewVisible = ref(false) const mediaPreviewType = ref<'image' | 'video'>('image') const mediaPreviewUrl = ref('') +const tagInput = ref('') +const systemVenuePickId = ref(undefined) +const customVenueName = ref('') +const customVenueAddress = ref('') +const formBaseline = ref('') +const importDocInput = ref(null) +const importingDoc = ref(false) +const importedFromDoc = ref(false) const form = reactive({ name: '', tags: [] as string[], - venue_ids: [] as number[], + venue_items: [] as VenueItem[], + org_name: '', + seasons: [] as string[], + suitable_count: '', + grade_levels: [] as string[], + duration: '', + contact_person: '', + contact_phones: '', cover_image: '', intro_html: '', + route_plans: [] as RoutePlan[], + courses: [] as Course[], + fee_html: '', + implementation_html: '', sort: 0, is_on_shelf: true, }) -const tagInput = ref('') -const selectedVenueRows = computed(() => { - const map = new Map(venues.value.map((item) => [item.id, item])) - return form.venue_ids.map((id) => map.get(id)).filter(Boolean) as Venue[] +const seasonLabelMap = computed(() => new Map(seasonOptions.value.map((o) => [o.item_value, o.item_label]))) +const modalTitle = computed(() => { + if (!isCreate.value) return '编辑线路' + return importedFromDoc.value ? '新增线路(已从申报表导入)' : '新增线路' }) +function formSignature() { + return JSON.stringify({ + name: form.name, + tags: form.tags, + venue_items: form.venue_items, + org_name: form.org_name, + seasons: form.seasons, + suitable_count: form.suitable_count, + grade_levels: form.grade_levels, + duration: form.duration, + contact_person: form.contact_person, + contact_phones: form.contact_phones, + cover_image: form.cover_image, + intro_html: form.intro_html, + route_plans: form.route_plans, + courses: form.courses, + fee_html: form.fee_html, + implementation_html: form.implementation_html, + sort: form.sort, + is_on_shelf: form.is_on_shelf, + tagInput: tagInput.value, + customVenueName: customVenueName.value, + customVenueAddress: customVenueAddress.value, + }) +} + +function captureFormBaseline() { + formBaseline.value = formSignature() +} + +function hasUnsavedChanges() { + return visible.value && formSignature() !== formBaseline.value +} + +const confirmDiscardChanges = useModalDirtyGuard(hasUnsavedChanges, '研学线路有未保存改动,确认关闭吗?') + +function venueItemsFromRow(row: StudyTour): VenueItem[] { + if (Array.isArray(row.venue_items) && row.venue_items.length) { + return row.venue_items.map((item) => { + if (item.type === 'custom') { + return { + type: 'custom' as const, + name: item.name || '', + address: item.address || '', + } + } + return { type: 'system' as const, venue_id: Number(item.venue_id) || 0 } + }).filter((item) => (item.type === 'system' ? item.venue_id > 0 : !!item.name.trim())) + } + return (row.venue_ids || []).map((id) => ({ type: 'system' as const, venue_id: id })) +} + +function venueItemLabel(item: VenueItem, index: number): string { + if (item.type === 'system') { + const v = venues.value.find((x) => x.id === item.venue_id) + return v ? `${index + 1}. ${v.name}` : `${index + 1}. 场馆#${item.venue_id}` + } + return `${index + 1}. ${item.name}(自定义)` +} + +function formatDictJoin(values: string[] | undefined, map: Map) { + const list = (values || []).map((v) => map.get(v) || v).filter(Boolean) + return list.length ? list.join('、') : '-' +} + function normalizeMediaUrl(rawUrl?: string, rawPath?: string) { const urlText = String(rawUrl || '').trim() - if (urlText) { - return resolvePublicMediaUrl(urlText) - } + if (urlText) return resolvePublicMediaUrl(urlText) const pathText = String(rawPath || '').trim() if (!pathText) return '' return resolvePublicMediaUrl(pathText) @@ -81,10 +186,6 @@ async function uploadFile(file: File) { return normalizeMediaUrl(data?.url, data?.path) } -async function uploadEditorFile(file: File) { - return uploadFile(file) -} - function resetEditors() { editorRenderKey.value += 1 } @@ -103,8 +204,7 @@ function pickUploadFile(payload: any): File | null { continue } if (typeof cur === 'object') { - const maybeKeys = ['file', 'raw', 'originFile', 'originFileObj', 'fileItem', 'item', 'data'] - for (const key of maybeKeys) { + for (const key of ['file', 'raw', 'originFile', 'originFileObj', 'fileItem', 'item', 'data']) { if (cur[key]) queue.push(cur[key]) } for (const value of Object.values(cur)) { @@ -115,7 +215,6 @@ function pickUploadFile(payload: any): File | null { return null } -/** Quill toolbar handlers: this.quill injected by Quill runtime */ function quillImageHandler(this: { quill: any }) { const quill = this.quill const input = document.createElement('input') @@ -130,7 +229,7 @@ function quillImageHandler(this: { quill: any }) { return } try { - const url = await uploadEditorFile(file) + const url = await uploadFile(file) const range = quill.getSelection(true) const index = range?.index ?? Math.max(0, quill.getLength() - 1) quill.insertEmbed(index, 'image', url, 'user') @@ -152,7 +251,7 @@ function quillVideoHandler(this: { quill: any }) { const file = input.files?.[0] if (!file) return try { - const url = await uploadEditorFile(file) + const url = await uploadFile(file) const range = quill.getSelection(true) const index = range?.index ?? Math.max(0, quill.getLength() - 1) quill.insertEmbed(index, 'video', url, 'user') @@ -165,76 +264,287 @@ function quillVideoHandler(this: { quill: any }) { input.click() } -const introEditorOptions = { - modules: { - toolbar: { - container: [ - [{ header: [1, 2, 3, false] }], - ['bold', 'italic', 'underline', 'strike'], - [{ color: [] }, { background: [] }], - [{ list: 'ordered' }, { list: 'bullet' }], - [{ align: [] }], - ['link', 'image', 'video'], - ['clean'], - ], - handlers: { - image: quillImageHandler, - video: quillVideoHandler, +function buildEditorOptions(placeholder: string) { + return { + modules: { + toolbar: { + container: [ + [{ header: [1, 2, 3, false] }], + ['bold', 'italic', 'underline', 'strike'], + [{ color: [] }, { background: [] }], + [{ list: 'ordered' }, { list: 'bullet' }], + [{ align: [] }], + ['link', 'image', 'video'], + ['clean'], + ], + handlers: { image: quillImageHandler, video: quillVideoHandler }, }, }, - }, - placeholder: '请输入线路简介', + placeholder, + } } -function formatTagsJoin(tags?: string[]) { - return (tags || []).length ? (tags || []).join('、') : '-' +const introEditorOptions = buildEditorOptions('请输入线路简介') +const feeEditorOptions = buildEditorOptions('请输入线路收费标准') +const implEditorOptions = buildEditorOptions('请输入线路计划实施情况') + +function resetForm() { + form.name = '' + form.tags = [] + form.venue_items = [] + form.org_name = '' + form.seasons = [] + form.suitable_count = '' + form.grade_levels = [] + form.duration = '' + form.contact_person = '' + form.contact_phones = '' + form.cover_image = '' + form.intro_html = '' + form.route_plans = [] + form.courses = [] + form.fee_html = '' + form.implementation_html = '' + form.sort = 0 + form.is_on_shelf = true + tagInput.value = '' + systemVenuePickId.value = undefined + customVenueName.value = '' + customVenueAddress.value = '' + activeTab.value = 'basic' } -function openMediaPreview(type: 'image' | 'video', url: string) { - if (!url) return - mediaPreviewType.value = type - mediaPreviewUrl.value = resolvePublicMediaUrl(url) - mediaPreviewVisible.value = true +function fillFormFromRow(row: StudyTour) { + form.name = row.name + form.tags = Array.isArray(row.tags) ? [...row.tags] : [] + form.venue_items = venueItemsFromRow(row) + form.org_name = row.org_name || '' + form.seasons = Array.isArray(row.seasons) ? [...row.seasons] : [] + form.suitable_count = row.suitable_count || '' + form.grade_levels = Array.isArray(row.grade_levels) ? [...row.grade_levels] : [] + form.duration = row.duration || '' + form.contact_person = row.contact_person || '' + form.contact_phones = row.contact_phones || '' + form.cover_image = row.cover_image || '' + form.intro_html = row.intro_html || '' + form.route_plans = Array.isArray(row.route_plans) + ? row.route_plans.map((g) => ({ + date_label: g.date_label || '', + items: (g.items || []).map((it) => ({ + time: it.time || '', + activity: it.activity || '', + location: it.location || '', + })), + })) + : [] + form.courses = Array.isArray(row.courses) + ? row.courses.map((c, i) => ({ + sort: c.sort ?? i + 1, + name: c.name || '', + content: c.content || '', + })) + : [] + form.fee_html = row.fee_html || '' + form.implementation_html = row.implementation_html || '' + form.sort = row.sort ?? 0 + form.is_on_shelf = row.is_on_shelf !== false } -async function onCoverSelect(fileItem: any) { - try { - const file = pickUploadFile(fileItem) - if (!file) { - Message.warning('未识别到上传文件') - return false +function addSystemVenue() { + const id = systemVenuePickId.value + if (!id) { + Message.warning('请选择系统场馆') + return + } + if (form.venue_items.some((item) => item.type === 'system' && item.venue_id === id)) { + Message.warning('该场馆已添加') + return + } + form.venue_items.push({ type: 'system', venue_id: id }) + systemVenuePickId.value = undefined +} + +function addCustomVenue() { + const name = customVenueName.value.trim() + if (!name) { + Message.warning('请填写自定义场馆名称') + return + } + const item: VenueItemCustom = { type: 'custom', name } + const addr = customVenueAddress.value.trim() + if (addr) item.address = addr + form.venue_items.push(item) + customVenueName.value = '' + customVenueAddress.value = '' +} + +function moveVenueItemUp(index: number) { + if (index <= 0) return + const list = [...form.venue_items] + ;[list[index - 1], list[index]] = [list[index], list[index - 1]] + form.venue_items = list +} + +function moveVenueItemDown(index: number) { + if (index >= form.venue_items.length - 1) return + const list = [...form.venue_items] + ;[list[index + 1], list[index]] = [list[index], list[index + 1]] + form.venue_items = list +} + +function removeVenueItem(index: number) { + form.venue_items.splice(index, 1) +} + +function addRoutePlanGroup() { + form.route_plans.push({ date_label: '', items: [{ time: '', activity: '', location: '' }] }) +} + +function removeRoutePlanGroup(gIndex: number) { + form.route_plans.splice(gIndex, 1) +} + +function duplicateRoutePlanGroup(gIndex: number) { + const src = form.route_plans[gIndex] + if (!src) return + form.route_plans.splice(gIndex + 1, 0, { + date_label: src.date_label, + items: src.items.map((it) => ({ + time: it.time, + activity: it.activity, + location: it.location, + })), + }) +} + +function addRoutePlanItem(gIndex: number) { + form.route_plans[gIndex]?.items.push({ time: '', activity: '', location: '' }) +} + +function removeRoutePlanItem(gIndex: number, iIndex: number) { + form.route_plans[gIndex]?.items.splice(iIndex, 1) +} + +function moveRoutePlanGroupUp(gIndex: number) { + if (gIndex <= 0) return + const list = [...form.route_plans] + ;[list[gIndex - 1], list[gIndex]] = [list[gIndex], list[gIndex - 1]] + form.route_plans = list +} + +function moveRoutePlanGroupDown(gIndex: number) { + if (gIndex >= form.route_plans.length - 1) return + const list = [...form.route_plans] + ;[list[gIndex + 1], list[gIndex]] = [list[gIndex], list[gIndex + 1]] + form.route_plans = list +} + +function moveRoutePlanItemUp(gIndex: number, iIndex: number) { + const items = form.route_plans[gIndex]?.items + if (!items || iIndex <= 0) return + ;[items[iIndex - 1], items[iIndex]] = [items[iIndex], items[iIndex - 1]] +} + +function moveRoutePlanItemDown(gIndex: number, iIndex: number) { + const items = form.route_plans[gIndex]?.items + if (!items || iIndex >= items.length - 1) return + ;[items[iIndex + 1], items[iIndex]] = [items[iIndex], items[iIndex + 1]] +} + +function isRouteItemEmpty(item: RoutePlanItem) { + return !item.time.trim() && !item.activity.trim() && !item.location.trim() +} + +function validateRoutePlans(): string | null { + for (let gi = 0; gi < form.route_plans.length; gi++) { + const group = form.route_plans[gi] + if (!group.date_label.trim()) { + return `线路规划第 ${gi + 1} 个日期分组未填写日期/分组名称` } - const sizeMsg = adminUploadImageTooLargeMessage(file) - if (sizeMsg) { - Message.warning(sizeMsg) - return false + for (let ii = 0; ii < group.items.length; ii++) { + if (isRouteItemEmpty(group.items[ii])) { + const label = group.date_label.trim() || `第 ${gi + 1} 组` + return `线路规划「${label}」第 ${ii + 1} 行行程的时间、行程安排、地点均未填写` + } } - form.cover_image = await uploadFile(file) - Message.success('封面上传成功') - } catch (error: any) { - Message.error(error?.response?.data?.message ?? '封面上传失败') } - return false + return null } -function onCoverChange(...args: any[]) { - void onCoverSelect(args) +function validateCourses(): string | null { + for (let i = 0; i < form.courses.length; i++) { + const course = form.courses[i] + if (!course.name.trim() && !course.content.trim()) { + return `研学课程第 ${i + 1} 项未填写课程名称与课程内容` + } + } + return null } -function removeCover() { - form.cover_image = '' + +function addCourse() { + form.courses.push({ sort: form.courses.length + 1, name: '', content: '' }) } -function onImageLoadError(ev?: Event) { - const el = ev?.target as HTMLImageElement | undefined - const src = (el?.getAttribute?.('src') || el?.src || '').trim() - if (!src || src === 'about:blank') return - Message.error('图片地址无法访问,请检查后端 storage 访问配置') +function removeCourse(index: number) { + form.courses.splice(index, 1) + form.courses.forEach((c, i) => { + c.sort = i + 1 + }) +} + +function moveCourseUp(index: number) { + if (index <= 0) return + const list = [...form.courses] + ;[list[index - 1], list[index]] = [list[index], list[index - 1]] + list.forEach((c, i) => { + c.sort = i + 1 + }) + form.courses = list +} + +function moveCourseDown(index: number) { + if (index >= form.courses.length - 1) return + const list = [...form.courses] + ;[list[index + 1], list[index]] = [list[index], list[index + 1]] + list.forEach((c, i) => { + c.sort = i + 1 + }) + form.courses = list +} + +function addTag() { + const value = tagInput.value.trim() + if (!value) { + Message.warning('请输入标签内容') + return + } + if (form.tags.includes(value)) { + Message.warning('标签已存在') + return + } + form.tags = [...form.tags, value] + tagInput.value = '' +} + +function removeTag(index: number) { + const list = [...form.tags] + list.splice(index, 1) + form.tags = list } function isVenueAdmin() { return currentUser.value?.role === 'venue_admin' } +async function loadDicts() { + const [seasonRes, gradeRes] = await Promise.all([ + http.get('/dict-items', { params: { dict_type: 'study_tour_season', active_only: 1 } }), + http.get('/dict-items', { params: { dict_type: 'study_tour_grade_level', active_only: 1 } }), + ]) + seasonOptions.value = seasonRes.data || [] + gradeOptions.value = gradeRes.data || [] +} + async function loadMe() { const { data } = await http.get('/me') currentUser.value = data @@ -248,6 +558,10 @@ async function loadData(keepCurrentPage = false) { if (kw) params.keyword = kw if (filterVenueId.value != null && filterVenueId.value > 0) params.venue_id = filterVenueId.value if (filterOnShelf.value === '0' || filterOnShelf.value === '1') params.is_on_shelf = filterOnShelf.value + if (filterSeason.value) params.season = filterSeason.value + if (filterGradeLevel.value) params.grade_level = filterGradeLevel.value + const org = filterOrgName.value.trim() + if (org) params.org_name = org const [tourRes, venueRes] = await Promise.all([ http.get('/study-tours', { params }), @@ -255,9 +569,7 @@ async function loadData(keepCurrentPage = false) { ]) rows.value = tourRes.data venues.value = venueRes.data - if (!keepCurrentPage) { - listPagination.current = 1 - } + if (!keepCurrentPage) listPagination.current = 1 } catch (error: any) { Message.error(error?.response?.data?.message ?? '加载研学线路失败') } finally { @@ -282,104 +594,190 @@ async function toggleShelf(row: StudyTour) { function openCreate() { isCreate.value = true editId.value = null - form.name = '' - form.tags = [] - form.venue_ids = [] - form.cover_image = '' - form.intro_html = '' - form.sort = 0 - form.is_on_shelf = true + importedFromDoc.value = false + resetForm() resetEditors() + captureFormBaseline() visible.value = true + void nextTick(() => { + captureFormBaseline() + }) } function openEdit(row: StudyTour) { isCreate.value = false editId.value = row.id - form.name = row.name - form.tags = Array.isArray(row.tags) ? [...row.tags] : [] - form.venue_ids = Array.isArray(row.venue_ids) ? [...row.venue_ids] : [] - form.cover_image = row.cover_image ?? '' - form.intro_html = row.intro_html || '' - form.sort = row.sort ?? 0 - form.is_on_shelf = row.is_on_shelf !== false + importedFromDoc.value = false + resetForm() + fillFormFromRow(row) resetEditors() + captureFormBaseline() visible.value = true + void nextTick(() => { + captureFormBaseline() + }) } -function onVenueSelectChange(ids: number[]) { - const keep = new Set(ids) - const ordered = form.venue_ids.filter((id) => keep.has(id)) - const orderedSet = new Set(ordered) - for (const id of ids) { - if (!orderedSet.has(id)) ordered.push(id) +function triggerImportDoc() { + importDocInput.value?.click() +} + +async function onImportDocSelected(event: Event) { + const input = event.target as HTMLInputElement + const file = input.files?.[0] + input.value = '' + if (!file) return + + const ext = file.name.split('.').pop()?.toLowerCase() + if (ext !== 'doc' && ext !== 'docx') { + Message.warning('请选择 .doc 或 .docx 申报表') + return + } + if (file.size > 10 * 1024 * 1024) { + Message.warning('申报表文件不能超过 10MB') + return + } + + importingDoc.value = true + try { + const fd = new FormData() + fd.append('file', file) + const { data } = await http.post('/study-tours/parse-doc', fd, { timeout: 120000 }) + const parsed = data?.parsed + if (!parsed || typeof parsed !== 'object') { + Message.error('申报表解析结果为空') + return + } + + isCreate.value = true + editId.value = null + importedFromDoc.value = true + resetForm() + fillFormFromRow(parsed as StudyTour) + resetEditors() + activeTab.value = 'basic' + visible.value = true + captureFormBaseline() + void nextTick(() => { + captureFormBaseline() + }) + + const warnings: string[] = Array.isArray(data?.warnings) ? data.warnings.filter(Boolean) : [] + if (warnings.length) { + Message.warning(`已从申报表导入,请核对后保存。${warnings.join(';')}`) + } else { + Message.success('已从申报表导入,请核对后保存') + } + } catch (error: any) { + Message.error(error?.response?.data?.message ?? '申报表导入失败') + } finally { + importingDoc.value = false } - form.venue_ids = ordered } -function moveVenueUp(index: number) { - if (index <= 0) return - const list = [...form.venue_ids] - const temp = list[index - 1] - list[index - 1] = list[index] - list[index] = temp - form.venue_ids = list +async function onCoverSelect(fileItem: any) { + try { + const file = pickUploadFile(fileItem) + if (!file) { + Message.warning('未识别到上传文件') + return false + } + const sizeMsg = adminUploadImageTooLargeMessage(file) + if (sizeMsg) { + Message.warning(sizeMsg) + return false + } + form.cover_image = await uploadFile(file) + Message.success('封面上传成功') + } catch (error: any) { + Message.error(error?.response?.data?.message ?? '封面上传失败') + } + return false } -function moveVenueDown(index: number) { - if (index >= form.venue_ids.length - 1) return - const list = [...form.venue_ids] - const temp = list[index + 1] - list[index + 1] = list[index] - list[index] = temp - form.venue_ids = list +function removeCover() { + form.cover_image = '' } -function removeVenue(id: number) { - form.venue_ids = form.venue_ids.filter((item) => item !== id) +function openMediaPreview(type: 'image' | 'video', url: string) { + if (!url) return + mediaPreviewType.value = type + mediaPreviewUrl.value = resolvePublicMediaUrl(url) + mediaPreviewVisible.value = true } -function addTag() { - const value = tagInput.value.trim() - if (!value) { - Message.warning('请输入标签内容') - return - } - if (form.tags.includes(value)) { - Message.warning('标签已存在') - return - } - form.tags = [...form.tags, value] - tagInput.value = '' +function onImageLoadError() { + Message.error('图片地址无法访问,请检查后端 storage 访问配置') } -function removeTag(index: number) { - const list = [...form.tags] - list.splice(index, 1) - form.tags = list +function buildPayload() { + return { + name: form.name.trim(), + tags: form.tags.map((item) => item.trim()).filter(Boolean), + venue_items: form.venue_items.map((item) => { + if (item.type === 'system') return { type: 'system', venue_id: item.venue_id } + const row: VenueItemCustom = { type: 'custom', name: item.name.trim() } + if (item.address?.trim()) row.address = item.address.trim() + return row + }), + org_name: form.org_name.trim(), + seasons: [...form.seasons], + suitable_count: form.suitable_count.trim(), + grade_levels: [...form.grade_levels], + duration: form.duration.trim(), + contact_person: form.contact_person.trim(), + contact_phones: form.contact_phones.trim(), + cover_image: form.cover_image || '', + intro_html: form.intro_html || '', + route_plans: form.route_plans.map((g) => ({ + date_label: g.date_label.trim(), + items: g.items.map((it) => ({ + time: it.time.trim(), + activity: it.activity.trim(), + location: it.location.trim(), + })), + })), + courses: form.courses.map((c, i) => ({ + sort: c.sort || i + 1, + name: c.name.trim(), + content: c.content.trim(), + })), + fee_html: form.fee_html || '', + implementation_html: form.implementation_html || '', + sort: form.sort ?? 0, + is_on_shelf: form.is_on_shelf, + } } async function submit() { if (!form.name.trim()) { Message.warning('请填写线路名称') + activeTab.value = 'basic' + return false + } + if (!form.venue_items.length) { + Message.warning('请至少添加一个场馆(系统或自定义)') + activeTab.value = 'basic' return false } - if (!form.venue_ids.length) { - Message.warning('请至少选择一个场馆') + + const routeErr = validateRoutePlans() + if (routeErr) { + Message.warning(routeErr) + activeTab.value = 'route' + return false + } + + const courseErr = validateCourses() + if (courseErr) { + Message.warning(courseErr) + activeTab.value = 'courses' return false } saving.value = true try { - const payload = { - name: form.name.trim(), - tags: form.tags.map((item) => item.trim()).filter(Boolean), - venue_ids: [...form.venue_ids], - cover_image: form.cover_image || '', - intro_html: form.intro_html || '', - sort: form.sort ?? 0, - is_on_shelf: form.is_on_shelf, - } + const payload = buildPayload() if (isCreate.value) { await http.post('/study-tours', payload) Message.success('新增线路成功') @@ -388,6 +786,7 @@ async function submit() { Message.success('更新线路成功') } visible.value = false + captureFormBaseline() await loadData() return true } catch (error: any) { @@ -408,8 +807,14 @@ async function removeRow(row: StudyTour) { } } +function venueCount(row: StudyTour) { + if (Array.isArray(row.venue_items) && row.venue_items.length) return row.venue_items.length + return (row.venue_ids || []).length +} + onMounted(async () => { await loadMe().catch(() => undefined) + await loadDicts().catch(() => undefined) await loadData() }) @@ -419,28 +824,50 @@ onMounted(async () => { + {{ item.name }} - + + {{ item.item_label }} + + + {{ item.item_label }} + + 上架 下架 查询 + + 从申报表导入 新建线路 @@ -450,21 +877,17 @@ onMounted(async () => { :data="rows" :loading="loading" row-key="id" - :pagination="{ - current: listPagination.current, - pageSize: listPagination.pageSize, - total: rows.length, - showTotal: true, - }" + :pagination="{ current: listPagination.current, pageSize: listPagination.pageSize, total: rows.length, showTotal: true }" @page-change="onListPageChange" >