Compare commits

...

5 Commits

Author SHA1 Message Date
malei
3470b124e4 chore: guard admin production builds 2026-08-01 23:30:32 +08:00
malei
7e924fa79d feat: configure booking capacity 2026-08-01 22:56:31 +08:00
malei
1dce8e94d0 feat: show event-backed report funnel 2026-08-01 22:29:20 +08:00
malei
9d59d72124 feat: anchor customer view on store master 2026-08-01 21:56:07 +08:00
malei
daa8744db4 fix: prefer canonical service staff field 2026-08-01 20:31:53 +08:00
10 changed files with 209 additions and 31 deletions

2
.env.production Normal file
View File

@ -0,0 +1,2 @@
# 安全占位符:正式构建必须用 .env.production.local 覆盖,并先运行发布门禁。
VITE_PUBLIC_H5_ORIGIN=https://report.petstore.invalid

View File

@ -19,9 +19,20 @@ npm run dev
## 构建
```bash
npm run preflight:release
npm run build
```
仓库内 `.env.production` 使用 `.invalid` 安全占位符,默认门禁会失败。正式发布必须使用权限受控的域名文件:
```bash
PETSTORE_ADMIN_ENV_FILE=/secure/path/petstore-admin.env npm run preflight:release
cp /secure/path/petstore-admin.env .env.production.local
npm run build
```
`VITE_PUBLIC_H5_ORIGIN` 必须是本项目公开报告页的显式 HTTPS 源,不能是 localhost、`.invalid` 或已归属其他产品的 `www.s-good.com`。真实配置不得提交,发布后删除 `.env.production.local`
## Owner
- Store Admin FE → `admin/**`

View File

@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"preflight:release": "node scripts/release-preflight.cjs"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",

View File

@ -0,0 +1,56 @@
const fs = require('node:fs')
const path = require('node:path')
const envFile = process.env.PETSTORE_ADMIN_ENV_FILE || '.env.production'
const absoluteEnvFile = path.resolve(process.cwd(), envFile)
function fail(message) {
console.error(`[release-preflight] FAIL: ${message}`)
process.exit(1)
}
if (!fs.existsSync(absoluteEnvFile)) {
fail('environment file does not exist')
}
const values = {}
for (const rawLine of fs.readFileSync(absoluteEnvFile, 'utf8').split(/\r?\n/)) {
const line = rawLine.trim()
if (!line || line.startsWith('#')) continue
const separator = line.indexOf('=')
if (separator < 1) continue
const key = line.slice(0, separator).trim().replace(/^export\s+/, '')
const value = line.slice(separator + 1).trim().replace(/^(['"])(.*)\1$/, '$2')
values[key] = value
}
const name = 'VITE_PUBLIC_H5_ORIGIN'
const value = values[name]
if (!value) fail(`${name} is required`)
let url
try {
url = new URL(value)
} catch {
fail(`${name} must be a valid URL`)
}
const hostname = url.hostname.toLowerCase()
const isLocal = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'
const isPlaceholder = hostname.endsWith('.invalid')
|| hostname.endsWith('.test')
|| hostname.endsWith('.example')
|| ['example.com', 'example.net', 'example.org'].some(
(reserved) => hostname === reserved || hostname.endsWith(`.${reserved}`),
)
const hasExtraParts = Boolean(url.username || url.password || url.search || url.hash)
|| (url.pathname !== '' && url.pathname !== '/')
if (url.protocol !== 'https:' || isLocal || isPlaceholder || hasExtraParts) {
fail(`${name} must be an explicit production HTTPS origin`)
}
if (hostname === 'www.s-good.com') {
fail(`${name} uses a domain reserved by another product`)
}
console.log(`[release-preflight] ${name}: PASS`)
console.log('[release-preflight] admin release configuration PASS')

View File

@ -33,6 +33,9 @@ export const getLeads = (status = 'pending') =>
export const getServiceCustomers = (params: Record<string, unknown> = {}) =>
apiGet('/admin/service-customers', params)
export const getWorkbenchReportFunnel = (date?: string) =>
apiGet('/admin/workbench/report-funnel', date ? { date } : {})
export const getAdminStore = () => apiGet<Record<string, unknown>>('/admin/store')
export const updateStore = (data: Record<string, unknown>) => apiPut('/store/update', data)
@ -40,7 +43,7 @@ export const updateStore = (data: Record<string, unknown>) => apiPut('/store/upd
export const getScheduleDay = (date: string) =>
apiGet('/schedule/day', { date })
export const createScheduleBlock = (data: { slotStart: string; blockType: string; note?: string }) =>
export const createScheduleBlock = (data: { slotStart: string; durationMinutes: number; blockType: string; note?: string }) =>
apiPost('/schedule/block', data)
export const deleteScheduleBlock = (id: number) =>
@ -59,11 +62,11 @@ export const deleteStaff = (staffId: number) =>
export const getServiceTypeList = (storeId?: number | null) =>
apiGet('/service-type/list', storeId != null ? { storeId } : {})
export const createServiceType = (name: string) =>
apiPost('/service-type/create', { name })
export const createServiceType = (name: string, durationMinutes: number) =>
apiPost('/service-type/create', { name, durationMinutes })
export const updateServiceType = (id: number, name: string) =>
apiPut('/service-type/update', { id, name })
export const updateServiceType = (id: number, name: string, durationMinutes: number) =>
apiPut('/service-type/update', { id, name, durationMinutes })
export const deleteServiceType = (id: number) =>
apiDelete('/service-type/delete', { id })

View File

@ -26,7 +26,9 @@
</div>
<el-table :data="rows" v-loading="loading" stripe>
<el-table-column prop="appointmentTime" label="预约时间" min-width="160" />
<el-table-column label="预约时间" min-width="210">
<template #default="{ row }">{{ appointmentRange(row) }}</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">{{ statusLabel(row.status) }}</template>
</el-table-column>
@ -62,7 +64,7 @@
<el-drawer v-model="drawer" title="预约详情" size="420px">
<template v-if="current">
<el-descriptions :column="1" border>
<el-descriptions-item label="时间">{{ current.appointmentTime }}</el-descriptions-item>
<el-descriptions-item label="时间">{{ appointmentRange(current) }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ statusLabel(current.status) }}</el-descriptions-item>
<el-descriptions-item label="宠主">{{ current.customerName }} {{ current.customerPhoneMasked }}</el-descriptions-item>
<el-descriptions-item label="宠物">{{ current.petName }} / {{ current.petType }}</el-descriptions-item>
@ -101,10 +103,15 @@ import {
type Appt = {
id: number
appointmentTime?: string
endTime?: string
durationMinutes?: number
status?: string
petName?: string
petType?: string
serviceType?: string
customerUserId?: number
createdByUserId?: number
assignedStaffId?: number
assignedUserId?: number
staffName?: string
customerName?: string
@ -125,6 +132,13 @@ const dateFilter = ref((route.query.date as string) || '')
const dateRange = ref<[string, string] | ''>('')
const staffId = ref<number | undefined>()
const missingReport = ref(route.query.missingReport === '1')
function appointmentRange(row: Appt): string {
const start = String(row.appointmentTime || '').replace('T', ' ').slice(0, 16)
const end = String(row.endTime || '').slice(11, 16)
if (!start) return '—'
return end ? `${start}${end}` : start
}
const staffOptions = ref<Array<{ id: number; name: string }>>([])
const drawer = ref(false)
const current = ref<Appt | null>(null)
@ -176,7 +190,7 @@ async function load() {
let list = Array.isArray(res.data) ? (res.data as Appt[]) : []
list = list.filter((a) => inDateRange(a.appointmentTime))
if (staffId.value != null) {
list = list.filter((a) => a.assignedUserId === staffId.value)
list = list.filter((a) => (a.assignedStaffId ?? a.assignedUserId) === staffId.value)
}
if (missingReport.value) {
list = list.filter((a) => a.status === 'doing' && !a.hasReport)

View File

@ -13,7 +13,7 @@
<el-input v-model="q" clearable placeholder="姓名 / 手机 / 宠物名" style="width: 220px" />
<el-button type="primary" @click="load">查询</el-button>
</div>
<el-table :data="rows" v-loading="loading" stripe>
<el-table :data="rows" v-loading="loading" row-key="storeCustomerId" stripe>
<el-table-column prop="displayName" label="客户" width="120" />
<el-table-column prop="phoneMasked" label="手机" width="140" />
<el-table-column label="宠物" min-width="160">
@ -23,7 +23,9 @@
</el-table-column>
<el-table-column prop="lastVisitAt" label="最近到店" min-width="160" />
<el-table-column prop="leadStatus" label="留资状态" width="100" />
<el-table-column prop="source" label="来源" width="140" />
<el-table-column label="来源" width="140">
<template #default="{ row }">{{ formatSource(row.source) }}</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
@ -38,7 +40,9 @@
<el-descriptions :column="1" border>
<el-descriptions-item label="姓名">{{ current.displayName }}</el-descriptions-item>
<el-descriptions-item label="手机">{{ current.phoneMasked }}</el-descriptions-item>
<el-descriptions-item label="来源">{{ current.source }}</el-descriptions-item>
<el-descriptions-item label="首次来源">{{ formatOriginSource(current.originSource) }}</el-descriptions-item>
<el-descriptions-item label="首次接触">{{ current.firstContactAt || '—' }}</el-descriptions-item>
<el-descriptions-item label="最近接触">{{ current.lastContactAt || '—' }}</el-descriptions-item>
<el-descriptions-item label="最近到店">{{ current.lastVisitAt || '—' }}</el-descriptions-item>
<el-descriptions-item label="留资">{{ current.leadStatus || '—' }}</el-descriptions-item>
<el-descriptions-item label="宠物">
@ -60,6 +64,7 @@ import { useRouter } from 'vue-router'
import { getServiceCustomers } from '@/api'
type CustomerRow = {
storeCustomerId: number
userId?: number | null
displayName?: string
phoneMasked?: string
@ -69,6 +74,9 @@ type CustomerRow = {
leadStatus?: string
hasPendingLead?: boolean
source?: string
originSource?: string
firstContactAt?: string
lastContactAt?: string
}
const router = useRouter()
@ -116,6 +124,21 @@ function goLead() {
router.push({ path: '/leads', query: { due: 'today' } })
}
function formatSource(value?: string) {
if (value === 'appointment+lead') return '预约 + 报告留资'
if (value === 'appointment') return '预约'
if (value === 'lead') return '报告留资'
return '—'
}
function formatOriginSource(value?: string) {
if (value === 'customer_booking') return '宠主自助预约'
if (value === 'assisted_booking') return '门店代客预约'
if (value === 'report_lead') return '报告留资'
if (value === 'appointment') return '历史预约'
return '—'
}
onMounted(load)
</script>

View File

@ -7,11 +7,12 @@
<el-button @click="openCreate">新增占用</el-button>
</div>
<el-table :data="rows" v-loading="loading" stripe>
<el-table-column prop="time" label="时段" width="100" />
<el-table-column prop="time" label="容量档" width="100" />
<el-table-column label="类型" width="120">
<template #default="{ row }">{{ kindLabel(row.kind, row.blockType) }}</template>
</el-table-column>
<el-table-column prop="title" label="内容" min-width="240" />
<el-table-column prop="capacityText" label="已用/总容量" width="130" />
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button
@ -35,15 +36,25 @@
<el-form-item label="时段">
<el-select v-model="createForm.slotStart" filterable style="width: 100%">
<el-option
v-for="opt in emptySlots"
v-for="opt in candidateSlots"
:key="opt.slotStart"
:label="opt.time"
:value="opt.slotStart"
/>
</el-select>
</el-form-item>
<el-form-item label="占用时长">
<el-input-number
v-model="createForm.durationMinutes"
:min="30"
:max="480"
:step="30"
@change="onBlockTypeChange"
/>
<span style="margin-left: 8px">分钟</span>
</el-form-item>
<el-form-item label="类型">
<el-radio-group v-model="createForm.blockType">
<el-radio-group v-model="createForm.blockType" @change="onBlockTypeChange">
<el-radio value="walk_in">到店占用</el-radio>
<el-radio value="blocked">暂停预约</el-radio>
</el-radio-group>
@ -73,6 +84,10 @@ type Row = {
blockId?: number
title: string
past?: boolean
usedCapacity: number
totalCapacity: number
remainingCapacity: number
capacityText: string
}
const loading = ref(false)
@ -83,12 +98,21 @@ const dialogVisible = ref(false)
const createForm = reactive({
slotStart: '',
blockType: 'blocked',
durationMinutes: 30,
note: '',
})
const emptySlots = computed(() =>
rows.value.filter((r) => r.kind === 'empty' && !r.past),
)
const candidateSlots = computed(() => {
const bucketCount = Math.max(1, Number(createForm.durationMinutes || 30) / 30)
return rows.value.filter((_row, index) => {
const covered = rows.value.slice(index, index + bucketCount)
if (covered.length !== bucketCount || covered.some((r) => r.past)) return false
if (createForm.blockType === 'blocked') {
return covered.every((r) => r.usedCapacity === 0 && r.kind === 'empty')
}
return covered.every((r) => r.remainingCapacity > 0)
})
})
function kindLabel(kind: string, blockType?: string) {
if (kind === 'appointment') return '预约'
@ -111,6 +135,9 @@ async function load() {
const kind = String(r.kind || 'empty')
const appt = r.appointment as Record<string, unknown> | null | undefined
const block = r.block as Record<string, unknown> | null | undefined
const usedCapacity = Number(r.usedCapacity || 0)
const totalCapacity = Number(r.totalCapacity || 1)
const remainingCapacity = Number(r.remainingCapacity || 0)
let title = '—'
let blockType: string | undefined
let blockId: number | undefined
@ -131,6 +158,10 @@ async function load() {
blockId,
title,
past: Boolean(r.past),
usedCapacity,
totalCapacity,
remainingCapacity,
capacityText: kind === 'block' && blockType === 'blocked' ? '暂停' : `${usedCapacity}/${totalCapacity}`,
}
})
} finally {
@ -139,15 +170,23 @@ async function load() {
}
function openCreate() {
createForm.slotStart = emptySlots.value[0]?.slotStart || ''
createForm.blockType = 'blocked'
createForm.durationMinutes = 30
createForm.slotStart = candidateSlots.value[0]?.slotStart || ''
createForm.note = ''
dialogVisible.value = true
}
function onBlockTypeChange() {
if (!candidateSlots.value.some((r) => r.slotStart === createForm.slotStart)) {
createForm.slotStart = candidateSlots.value[0]?.slotStart || ''
}
}
function quickBlock(row: Row) {
createForm.slotStart = row.slotStart
createForm.blockType = 'blocked'
createForm.durationMinutes = 30
createForm.note = ''
dialogVisible.value = true
}
@ -161,6 +200,7 @@ async function submitCreate() {
try {
const res = await createScheduleBlock({
slotStart: createForm.slotStart,
durationMinutes: createForm.durationMinutes,
blockType: createForm.blockType,
note: createForm.note || undefined,
})

View File

@ -34,6 +34,10 @@
:disabled="!isBoss"
/>
</el-form-item>
<el-form-item label="并发接待数">
<el-input-number v-model="form.bookingCapacity" :min="1" :max="10" :disabled="!isBoss" />
<span style="margin-left: 10px; color: var(--el-text-color-secondary)">同一时段最多同时服务的顾客数</span>
</el-form-item>
<el-form-item label="邀请码">
<div class="invite-row">
<el-input v-model="form.inviteCode" readonly />
@ -50,6 +54,8 @@
<el-tab-pane label="服务类型" name="service">
<div class="filter-row" v-if="isBoss">
<el-input v-model="newServiceName" placeholder="新服务类型名称" style="width: 220px" />
<el-input-number v-model="newServiceDuration" :min="30" :max="480" :step="30" />
<span>分钟</span>
<el-button type="primary" @click="addService">新增</el-button>
</div>
<el-table :data="serviceTypes" v-loading="loadingMeta" stripe>
@ -57,14 +63,17 @@
<el-table-column label="范围" width="120">
<template #default="{ row }">{{ row.storeId == null ? '系统默认' : '本店' }}</template>
</el-table-column>
<el-table-column label="预计时长" width="140">
<template #default="{ row }">{{ row.durationMinutes }} 分钟</template>
</el-table-column>
<el-table-column v-if="isBoss" label="操作" width="160">
<template #default="{ row }">
<el-button
v-if="row.storeId != null"
link
type="primary"
@click="renameService(row)"
>改名</el-button>
@click="editService(row)"
>编辑</el-button>
<el-button
v-if="row.storeId != null"
link
@ -134,11 +143,13 @@ const form = reactive({
inviteCode: '',
bookingDayStart: '09:00',
bookingLastSlotStart: '21:30',
bookingCapacity: 1,
})
const serviceTypes = ref<Array<Record<string, unknown>>>([])
const staff = ref<Array<Record<string, unknown>>>([])
const newServiceName = ref('')
const newServiceDuration = ref(60)
const newStaffName = ref('')
const newStaffPhone = ref('')
@ -165,6 +176,7 @@ async function loadStore() {
form.inviteCode = String(d.inviteCode || '')
form.bookingDayStart = normTime(d.bookingDayStart)
form.bookingLastSlotStart = normTime(d.bookingLastSlotStart)
form.bookingCapacity = Number(d.bookingCapacity || 1)
const token = getToken()
const u = getUser()
if (token && u) {
@ -204,6 +216,7 @@ async function saveStore() {
intro: form.intro,
bookingDayStart: form.bookingDayStart,
bookingLastSlotStart: form.bookingLastSlotStart,
bookingCapacity: form.bookingCapacity,
})
if (res.code !== 200) {
ElMessage.error(res.message || '保存失败')
@ -225,19 +238,26 @@ async function copyInvite() {
async function addService() {
const name = newServiceName.value.trim()
if (!name) return
const res = await createServiceType(name)
const duration = Number(newServiceDuration.value)
const res = await createServiceType(name, duration)
if (res.code !== 200) {
ElMessage.error(res.message || '新增失败')
return
}
newServiceName.value = ''
newServiceDuration.value = 60
ElMessage.success('已新增')
await loadMeta()
}
async function renameService(row: Record<string, unknown>) {
async function editService(row: Record<string, unknown>) {
const { value } = await ElMessageBox.prompt('新名称', '改名', { inputValue: String(row.name || '') })
const res = await updateServiceType(Number(row.id), value)
const durationPrompt = await ElMessageBox.prompt('请输入 30480 分钟,且为 30 的倍数', '预计服务时长', {
inputValue: String(row.durationMinutes || 60),
inputPattern: /^(30|60|90|120|150|180|210|240|270|300|330|360|390|420|450|480)$/,
inputErrorMessage: '请输入 30480 的 30 分钟倍数',
})
const res = await updateServiceType(Number(row.id), value, Number(durationPrompt.value))
if (res.code !== 200) {
ElMessage.error(res.message || '改名失败')
return

View File

@ -57,7 +57,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { getAppointmentList, getLeads, getReportList } from '@/api'
import { getAppointmentList, getLeads, getReportList, getWorkbenchReportFunnel } from '@/api'
import { isHighlightProcessingStale } from '@/utils/publicLinks'
const SUPPRESS_KEY = 'petstore_admin_todo_suppress_date'
@ -69,7 +69,9 @@ const pendingLead = ref(0)
const highlightFailed = ref(0)
const pendingReport = ref(0)
const todayReports = ref(0)
const openedReports = ref(0)
const todayLeads = ref(0)
const rebookCount = ref<number | null>(null)
const todoDialog = ref(false)
const suppressToday = ref(false)
@ -82,10 +84,11 @@ function isSameDay(iso?: string | null) {
}
onMounted(async () => {
const [apptRes, reportRes, leadRes] = await Promise.all([
const [apptRes, reportRes, leadRes, funnelRes] = await Promise.all([
getAppointmentList({ page: 1, pageSize: 200 }),
getReportList({ page: 1, pageSize: 200 }),
getLeads('pending'),
getWorkbenchReportFunnel(todayStr),
])
const appts = Array.isArray(apptRes.data) ? apptRes.data as Array<Record<string, unknown>> : []
const reports = Array.isArray(reportRes.data) ? reportRes.data as Array<Record<string, unknown>> : []
@ -98,8 +101,13 @@ onMounted(async () => {
const d = l.remindDate as string | undefined
return !d || d <= todayStr
}).length
todayLeads.value = leads.filter((l) => isSameDay(l.createTime as string)).length
todayReports.value = reports.filter((r) => isSameDay(r.createTime as string)).length
const eventFunnel = (funnelRes.data || {}) as Record<string, unknown>
todayReports.value = Number(eventFunnel.reportSubmittedCount || 0)
openedReports.value = Number(eventFunnel.reportOpenedCount || 0)
todayLeads.value = Number(eventFunnel.leadSubmittedCount || 0)
rebookCount.value = eventFunnel.rebookTrackingReady === true
? Number(eventFunnel.rebookCreatedCount || 0)
: null
highlightFailed.value = reports.filter(
(r) =>
r.highlightVideoStatus === 'failed' ||
@ -127,10 +135,10 @@ const todos = computed(() => [
])
const funnel = computed(() => [
{ label: '今日已报告', value: todayReports.value },
{ label: '已打开', value: '—' },
{ label: '今日已提交报告', value: todayReports.value },
{ label: '已打开', value: openedReports.value },
{ label: '已留资', value: todayLeads.value },
{ label: '报告转预约', value: '—' },
{ label: '报告转预约', value: rebookCount.value ?? '—' },
])
function go(to: { path: string; query?: Record<string, string | undefined> }) {