Compare commits
5 Commits
phase-a-rc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3470b124e4 | ||
|
|
7e924fa79d | ||
|
|
1dce8e94d0 | ||
|
|
9d59d72124 | ||
|
|
daa8744db4 |
2
.env.production
Normal file
2
.env.production
Normal file
@ -0,0 +1,2 @@
|
||||
# 安全占位符:正式构建必须用 .env.production.local 覆盖,并先运行发布门禁。
|
||||
VITE_PUBLIC_H5_ORIGIN=https://report.petstore.invalid
|
||||
11
README.md
11
README.md
@ -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/**`
|
||||
|
||||
@ -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",
|
||||
|
||||
56
scripts/release-preflight.cjs
Normal file
56
scripts/release-preflight.cjs
Normal 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')
|
||||
@ -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 })
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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('请输入 30~480 分钟,且为 30 的倍数', '预计服务时长', {
|
||||
inputValue: String(row.durationMinutes || 60),
|
||||
inputPattern: /^(30|60|90|120|150|180|210|240|270|300|330|360|390|420|450|480)$/,
|
||||
inputErrorMessage: '请输入 30~480 的 30 分钟倍数',
|
||||
})
|
||||
const res = await updateServiceType(Number(row.id), value, Number(durationPrompt.value))
|
||||
if (res.code !== 200) {
|
||||
ElMessage.error(res.message || '改名失败')
|
||||
return
|
||||
|
||||
@ -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> }) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user