研学线路

master
lion 2 days ago
parent 520152477f
commit 33fb1213ba

@ -1,5 +1,20 @@
const TZ_SH = 'Asia/Shanghai' 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」与后台导出一致 */ /** ISO 时间 → 东八区「YYYY-MM-DD HH:mm:ss」与后台导出一致 */
export function formatDateTimeZh(iso?: string | null): string { export function formatDateTimeZh(iso?: string | null): string {
if (iso == null || iso === '') return '-' if (iso == null || iso === '') return '-'

@ -1,4 +1,5 @@
import * as XLSX from 'xlsx' import * as XLSX from 'xlsx'
import { ymdFromDateValue } from './datetime'
function str(v: unknown): string { function str(v: unknown): string {
if (v == null || v === '') return '' if (v == null || v === '') return ''
@ -107,8 +108,8 @@ const BASE_COL_SPEC: { zh: string; format: (row: Record<string, unknown>) => str
{ zh: '审核状态', format: (r) => auditStatusLabel(r.audit_status) }, { zh: '审核状态', format: (r) => auditStatusLabel(r.audit_status) },
{ zh: '上架', format: (r) => yn(r.is_active) }, { zh: '上架', format: (r) => yn(r.is_active) },
{ zh: '热门', format: (r) => yn(r.is_hot) }, { zh: '热门', format: (r) => yn(r.is_hot) },
{ zh: '活动开始', format: (r) => fmtDateTime(r.start_at) }, { zh: '开始日期', format: (r) => ymdFromDateValue(r.start_at as string | null | undefined) },
{ zh: '活动结束', format: (r) => fmtDateTime(r.end_at) }, { zh: '结束日期', format: (r) => ymdFromDateValue(r.end_at as string | null | undefined) },
{ zh: '联系人', format: (r) => str(r.contact_name) }, { zh: '联系人', format: (r) => str(r.contact_name) },
{ zh: '联系电话', format: (r) => str(r.contact_phone) }, { zh: '联系电话', format: (r) => str(r.contact_phone) },
{ zh: '详情正文', format: (r) => stripHtml(r.detail_html) }, { zh: '详情正文', format: (r) => stripHtml(r.detail_html) },
@ -165,7 +166,11 @@ function fmtSessionCell(key: string, d: Record<string, unknown>): string | numbe
const raw = d[key] const raw = d[key]
if (key === 'day_quota' || key === 'booked_count') return num(raw) if (key === 'day_quota' || key === 'booked_count') return num(raw)
if ( if (
key === 'activity_date' || key === 'activity_date'
) {
return ymdFromDateValue(raw as string | null | undefined)
}
if (
key === 'session_start_at' || key === 'session_start_at' ||
key === 'session_end_at' || key === 'session_end_at' ||
key === 'booking_opens_at' || key === 'booking_opens_at' ||

@ -10,6 +10,7 @@ import { useUnsavedChangesGuard } from '../../composables/useUnsavedChangesGuard
import { adminUploadImageTooLargeMessage, ADMIN_IMAGE_RECOMMEND_LABEL } from '../../utils/adminMediaLimits' import { adminUploadImageTooLargeMessage, ADMIN_IMAGE_RECOMMEND_LABEL } from '../../utils/adminMediaLimits'
import { listTableRowIndex } from '../../utils/listTableRowIndex' import { listTableRowIndex } from '../../utils/listTableRowIndex'
import { downloadActivityListXlsx } from '../../utils/exportActivityListXlsx' import { downloadActivityListXlsx } from '../../utils/exportActivityListXlsx'
import { ymdFromDateValue } from '../../utils/datetime'
import { reverseMapGeocode, searchMapPlaces } from '../../utils/mapGeo' import { reverseMapGeocode, searchMapPlaces } from '../../utils/mapGeo'
import { import {
bindTiandituMapClick, bindTiandituMapClick,
@ -35,6 +36,7 @@ type Venue = {
open_time?: string open_time?: string
} }
type CurrentUser = { id?: number; role: string; venues: Venue[]; full_admin_access?: boolean } type CurrentUser = { id?: number; role: string; venues: Venue[]; full_admin_access?: boolean }
type DictOption = { item_value: string; item_label: string }
type Activity = { type Activity = {
id: number id: number
venue_id: number venue_id: number
@ -83,6 +85,7 @@ type Activity = {
offline_reservation_method?: string | null offline_reservation_method?: string | null
booking_method_note?: string | null booking_method_note?: string | null
ticket_fee_note?: string | null ticket_fee_note?: string | null
age_group?: string | null
external_url?: string | null external_url?: string | null
/** 线上已报名人数H5/统计用 */ /** 线上已报名人数H5/统计用 */
registered_count?: number registered_count?: number
@ -96,6 +99,7 @@ type Activity = {
const rows = ref<Activity[]>([]) const rows = ref<Activity[]>([])
const venues = ref<Venue[]>([]) const venues = ref<Venue[]>([])
const ageGroupOptions = ref<DictOption[]>([])
/** 活动举办场馆等引用场景:待审/退回时展示已通过快照中的名称 */ /** 活动举办场馆等引用场景:待审/退回时展示已通过快照中的名称 */
function venueReferenceDisplayName(v: Pick<Venue, 'name' | 'audit_status' | 'last_approved_snapshot'> | null | undefined): string { function venueReferenceDisplayName(v: Pick<Venue, 'name' | 'audit_status' | 'last_approved_snapshot'> | null | undefined): string {
@ -300,19 +304,6 @@ const DEFAULT_CENTER = { lat: 31.299379, lng: 120.585315 }
const route = useRoute() 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 { function parseShanghaiLocalToMs(datetimeYmdHmss: string): number {
let s = String(datetimeYmdHmss || '').trim().replace(' ', 'T') let s = String(datetimeYmdHmss || '').trim().replace(' ', 'T')
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(s)) s += ':00' 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 { function formatActivityTableDateRange(record: Activity): string {
const s = record.start_at ? formatYmdInShanghai(String(record.start_at)) : '' const s = record.start_at ? ymdFromDateValue(String(record.start_at)) : ''
const e = record.end_at ? formatYmdInShanghai(String(record.end_at)) : '' const e = record.end_at ? ymdFromDateValue(String(record.end_at)) : ''
if (!s && !e) return '-' if (!s && !e) return '-'
if (s && e) { if (s && e) {
if (s === e) return s if (s === e) return s
@ -396,6 +387,7 @@ const form = reactive({
is_hot: false, is_hot: false,
booking_method_note: '', booking_method_note: '',
fee_note: '', fee_note: '',
age_group: undefined as string | undefined,
location: '', location: '',
check_in_meeting_point: '', check_in_meeting_point: '',
lat: undefined as number | undefined, lat: undefined as number | undefined,
@ -1559,6 +1551,15 @@ function formatActivityStatsCell(record: Activity) {
return '浏览 ' + statNum(record.view_count) 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() { async function loadAll() {
loading.value = true loading.value = true
try { try {
@ -1567,6 +1568,7 @@ async function loadAll() {
params: buildActivitiesListParams(pagination.current, pagination.pageSize), params: buildActivitiesListParams(pagination.current, pagination.pageSize),
}), }),
http.get('/venues'), http.get('/venues'),
loadAgeGroupOptions(),
]) ])
rows.value = aRes.data.data rows.value = aRes.data.data
pagination.total = aRes.data.total pagination.total = aRes.data.total
@ -1598,6 +1600,7 @@ function openCreate() {
form.reservation_type = 'online' form.reservation_type = 'online'
form.booking_method_note = '平台预约' form.booking_method_note = '平台预约'
form.fee_note = '免费' form.fee_note = '免费'
form.age_group = undefined
form.location = '' form.location = ''
form.check_in_meeting_point = '' form.check_in_meeting_point = ''
form.lat = undefined form.lat = undefined
@ -1657,6 +1660,7 @@ function openEdit(row: Activity) {
(['online', 'none'].includes(reservationKindEffective(form.reservation_type)) (['online', 'none'].includes(reservationKindEffective(form.reservation_type))
? '免费' ? '免费'
: '') : '')
form.age_group = row.age_group || undefined
form.location = row.location || '' form.location = row.location || ''
form.check_in_meeting_point = row.check_in_meeting_point || '' form.check_in_meeting_point = row.check_in_meeting_point || ''
form.lat = parseCoord(row.lat) form.lat = parseCoord(row.lat)
@ -1665,8 +1669,8 @@ function openEdit(row: Activity) {
form.title = row.title form.title = row.title
form.contact_name = row.contact_name ?? '' form.contact_name = row.contact_name ?? ''
form.contact_phone = row.contact_phone ?? '' form.contact_phone = row.contact_phone ?? ''
form.start_at = row.start_at ? formatYmdInShanghai(row.start_at) : '' form.start_at = row.start_at ? ymdFromDateValue(row.start_at) : ''
form.end_at = row.end_at ? formatYmdInShanghai(row.end_at) : '' form.end_at = row.end_at ? ymdFromDateValue(row.end_at) : ''
form.detail_html = row.detail_html || '' form.detail_html = row.detail_html || ''
form.cover_image = row.cover_image || '' form.cover_image = row.cover_image || ''
form.gallery_media = Array.isArray(row.gallery_media) ? [...row.gallery_media] : [] form.gallery_media = Array.isArray(row.gallery_media) ? [...row.gallery_media] : []
@ -1893,6 +1897,7 @@ async function executeUnifiedActivitySave(): Promise<void> {
tags: form.tags, tags: form.tags,
reservation_notice: null, reservation_notice: null,
open_time: null, open_time: null,
age_group: form.age_group || null,
} }
if (isUnifiedNature) { if (isUnifiedNature) {
payload.offline_reservation_method = null payload.offline_reservation_method = null
@ -2570,6 +2575,21 @@ async function removeActivity(row: Activity) {
<span style="color: #f53f3f;">{{ formErrors.fee_note }}</span> <span style="color: #f53f3f;">{{ formErrors.fee_note }}</span>
</template> </template>
</a-form-item> </a-form-item>
<a-form-item label="适合年龄段">
<a-select
v-model="form.age_group"
allow-clear
allow-search
placeholder="请选择适合年龄段(选填)"
style="width: 100%"
>
<a-option
v-for="opt in ageGroupOptions"
:key="opt.item_value"
:value="opt.item_value"
>{{ opt.item_label }}</a-option>
</a-select>
</a-form-item>
<a-row :gutter="16" class="admin-modal-form__full"> <a-row :gutter="16" class="admin-modal-form__full">
<a-col :xs="24" :md="10"> <a-col :xs="24" :md="10">
<a-form-item <a-form-item

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save