Backend - Service Station: expose the 「临时公告」 chain end-to-end (V042 table/service existed but had ZERO endpoints -> both admin and miniapp could never reach it): 5 operator endpoints (list/create/update/publish/archive) + 1 user-facing read endpoint. - Fix privilege escalation: create/update were guarded only by the class-level service_location.view, making V015's service_location.manage a never-used dead permission -> read-only roles could edit stations. Permissions moved to method level. - Add ServiceLocationOperatorController to OperatorEndpointPermissionCoverageTest (it was NOT scanned, which is why the escalation stayed invisible). - Add create/update validation (address NOT NULL + status CHECK only blew up on PG). - Banner: validation failures threw bare RuntimeException -> 500; now VALIDATION_ERROR -> 400. Add deleted_at IS NULL to both home queries (soft-deleted banners stayed live). - Content: operator list/detail split (admin «publish» was a dead end because it reused the published-only public API); category deactivate/reactivate; article body on edit. - Service Program: operator member list now includes pending (approval reachable); active-only public visibility (draft leak); memberCount/pendingCount aggregation; withdraw/removeMember; reuse row on re-apply (UNIQUE(program_id,user_id) crash). - MeController: add the missing GET /me/service-history route. Schema / guards - schema-h2.sql: add service_location_announcements (+ chk_announcement_status) and content_categories (+ uk_content_category_code, chk_category_status); move both into REQUIRED_IN_TEST_SCHEMA. MigrationValidationTest baseline 41 -> 42. - V11PermissionMatrixContractTest now scans ALL migrations instead of hardcoding V015+V029. - New: ServiceLocationAnnouncementPhase19IntegrationTest (24), ServiceLocationControllerPermissionContractTest (5), ServiceLocationAnnouncementConstraintParityTest (5, prod DDL <-> test schema <-> backend whitelist <-> admin labels). Frontend - admin-web: ServiceStationView gains announcement management (statuses, create/edit, publish/archive with confirm, expiry); write buttons gated by service_location.manage. - miniapp: U50 renders the 临时公告 block via new serviceStationService; page no longer talks to utils/api directly; announcement failure degrades to [] instead of erroring. - ui-tests: U37 four-group + U50 announcement contracts; /home assertion retargeted to the service layer. Verification - Negative verification: 3 injected defects (drop soft-delete filter / drop test-schema CHECK / downgrade write endpoint to view) -> 4 named tests fail precisely, no collateral failures; restored, NEG-VAL residue = 0. - Backend full regression: 602 tests, 0 failures, 0 errors, 3 skipped (baseline 420). - admin-web: vue-tsc --noEmit EXIT=0, vite build EXIT=0. Miniapp: tsc --noEmit EXIT=0. - ui-tests 14/14 PASS.
116 lines
3.3 KiB
TypeScript
116 lines
3.3 KiB
TypeScript
import { serviceStationService } from '../../services'
|
|
|
|
interface ServiceLocation {
|
|
id: string
|
|
name: string
|
|
shortName?: string
|
|
address?: string
|
|
contactPhone?: string
|
|
contactPhoneDisplay?: string
|
|
openingHoursText?: string
|
|
services?: string[]
|
|
latitude?: number | string
|
|
longitude?: number | string
|
|
}
|
|
|
|
interface ServiceAnnouncement {
|
|
id: string
|
|
title: string
|
|
content?: string
|
|
expiresAt?: string | null
|
|
}
|
|
|
|
interface ServiceStationPageData {
|
|
serviceLocation: ServiceLocation | null
|
|
announcements: ServiceAnnouncement[]
|
|
loading: boolean
|
|
loadError: boolean
|
|
}
|
|
|
|
Page({
|
|
data: {
|
|
serviceLocation: null as ServiceLocation | null,
|
|
announcements: [] as ServiceAnnouncement[],
|
|
loading: true,
|
|
loadError: false,
|
|
},
|
|
|
|
onLoad() {
|
|
this.loadStation()
|
|
},
|
|
|
|
async loadStation() {
|
|
this.setData({ loading: true, loadError: false })
|
|
try {
|
|
const station = await serviceStationService.getStation()
|
|
if (!station) {
|
|
this.setData({ serviceLocation: null, announcements: [], loading: false })
|
|
return
|
|
}
|
|
// 公告是增强信息:失败降级为空数组,不阻断站点主体信息展示
|
|
const announcements = await serviceStationService.getVisibleAnnouncements(station.id as string)
|
|
this.setData({ serviceLocation: station as ServiceLocation, announcements, loading: false })
|
|
} catch (error) {
|
|
console.error('加载服务站失败', error)
|
|
this.setData({ loading: false, loadError: true })
|
|
}
|
|
},
|
|
|
|
call() {
|
|
const station = this.data.serviceLocation
|
|
if (!station) return
|
|
const phoneNumber = station.contactPhone || station.contactPhoneDisplay || ''
|
|
const digitCount = phoneNumber.replace(/\D/g, '').length
|
|
if (!phoneNumber || phoneNumber.includes('*') || digitCount < 5) {
|
|
wx.showToast({ title: '联系电话请以预约确认为准', icon: 'none' })
|
|
return
|
|
}
|
|
wx.makePhoneCall({ phoneNumber })
|
|
},
|
|
|
|
openMap() {
|
|
const station = this.data.serviceLocation
|
|
if (!station) return
|
|
const latitude = Number(station.latitude)
|
|
const longitude = Number(station.longitude)
|
|
if (Number.isFinite(latitude) && Number.isFinite(longitude) && latitude && longitude) {
|
|
wx.openLocation({
|
|
latitude,
|
|
longitude,
|
|
name: station.name,
|
|
address: station.address || '',
|
|
})
|
|
return
|
|
}
|
|
if (station.address) {
|
|
wx.setClipboardData({ data: station.address })
|
|
return
|
|
}
|
|
wx.showToast({ title: '暂未提供地图位置', icon: 'none' })
|
|
},
|
|
|
|
book() {
|
|
const station = this.data.serviceLocation
|
|
const id = station ? station.id : ''
|
|
const query = id ? `?serviceLocationId=${encodeURIComponent(id)}` : ''
|
|
wx.navigateTo({ url: `/pages/service-appointment/service-appointment${query}` })
|
|
},
|
|
|
|
/** 公告详情:临时公告正文较长,用弹窗完整展示,避免卡片被截断 */
|
|
onShowAnnouncement(e: WechatMiniprogram.TouchEvent) {
|
|
const id = e.currentTarget.dataset.id as string
|
|
const target = (this.data.announcements as ServiceAnnouncement[]).find((item) => item.id === id)
|
|
if (!target) return
|
|
wx.showModal({
|
|
title: target.title || '临时公告',
|
|
content: target.content || target.title || '',
|
|
showCancel: false,
|
|
confirmText: '知道了',
|
|
})
|
|
},
|
|
|
|
onRetry() {
|
|
this.loadStation()
|
|
},
|
|
})
|