初始化

This commit is contained in:
leiking
2026-06-29 10:54:33 +08:00
parent 761cee968e
commit 4983006317
156 changed files with 25687 additions and 0 deletions
+190
View File
@@ -0,0 +1,190 @@
<template>
<view class="page">
<scroll-view class="msgs" scroll-y :scroll-top="scrollTop">
<view class="container">
<view v-for="m in messages" :key="m.id" class="m" :class="{ me: m.role === 'user' }">
<view class="bubble" :class="{ bme: m.role === 'user' }">
<text class="txt">{{ m.text }}</text>
</view>
</view>
<view class="chips" v-if="showChips">
<view class="chip" @tap="send('推荐爆款项目')">推荐爆款项目</view>
<view class="chip" @tap="send('我想预约今天')">我想预约今天</view>
<view class="chip" @tap="send('敏感肌适合做什么')">敏感肌适合做什么</view>
</view>
</view>
</scroll-view>
<view class="quick row">
<view class="q" @tap="send('查档期')">查档期</view>
<view class="q" @tap="send('看价格')">看价格</view>
<view class="q" @tap="send('我要预约')">我要预约</view>
</view>
<view class="bar">
<view class="voice" @tap="mockVoice">按住说话</view>
<input class="ipt" v-model="text" confirm-type="send" @confirm="send(text)" placeholder="说出你的需求,例如:想做补水/我想预约今天 14:30" />
<view class="send" @tap="send(text)">发送</view>
</view>
</view>
</template>
<script>
import { aiReply } from '@/common/aiRules'
import { projects } from '@/common/mockData'
function mid() {
return `${Date.now()}_${Math.floor(Math.random() * 10000)}`
}
export default {
data() {
return {
projectId: '',
messages: [],
text: '',
scrollTop: 0
}
},
computed: {
showChips() {
return this.messages.length < 4
}
},
onLoad(query) {
this.projectId = query.projectId || ''
const base =
'我是 AI 预约助手。你可以直接说“预约 + 时间 + 项目/需求”,我会把你带到预约/购买流程。'
const ctx = this.projectId ? projects.find((x) => x.id === this.projectId) : null
const intro = ctx ? `当前项目:${ctx.name}。你想预约哪个日期和时段?` : base
this.messages = [
{ id: mid(), role: 'assistant', text: intro }
]
this.bump()
},
methods: {
bump() {
this.$nextTick(() => {
this.scrollTop = this.scrollTop + 99999
})
},
send(raw) {
const v = (raw || '').trim()
if (!v) return
this.messages.push({ id: mid(), role: 'user', text: v })
this.text = ''
const r = aiReply(v)
this.messages.push({ id: mid(), role: 'assistant', text: r.text })
this.bump()
if (r.action?.type === 'go_booking') {
setTimeout(() => {
uni.navigateTo({ url: `/pages/booking/create?projectId=${r.action.projectId}` })
}, 450)
}
},
mockVoice() {
uni.showToast({ title: '语音输入(原型演示)', icon: 'none' })
}
}
}
</script>
<style lang="scss" scoped>
.page {
height: 100vh;
display: flex;
flex-direction: column;
}
.msgs {
flex: 1;
}
.m {
display: flex;
margin: 14rpx 0;
}
.me {
justify-content: flex-end;
}
.bubble {
max-width: 620rpx;
padding: 18rpx 18rpx;
border-radius: 22rpx;
background: #fff;
border: 1rpx solid rgba(17, 24, 39, 0.08);
box-shadow: 0 10rpx 28rpx rgba(17, 24, 39, 0.06);
}
.bme {
background: rgba(59, 130, 246, 0.12);
border-color: rgba(59, 130, 246, 0.22);
}
.txt {
font-size: 28rpx;
line-height: 1.5;
}
.quick {
padding: 12rpx 18rpx 6rpx;
background: #fff;
border-top: 1rpx solid rgba(17, 24, 39, 0.08);
gap: 12rpx;
}
.q {
padding: 12rpx 16rpx;
border-radius: 999rpx;
background: rgba(17, 24, 39, 0.06);
font-size: 26rpx;
font-weight: 800;
}
.bar {
padding: 18rpx;
background: #fff;
display: flex;
gap: 12rpx;
}
.voice {
width: 160rpx;
height: 82rpx;
border-radius: 18rpx;
background: rgba(17, 24, 39, 0.06);
font-size: 24rpx;
font-weight: 900;
color: rgba(17, 24, 39, 0.78);
display: flex;
align-items: center;
justify-content: center;
}
.ipt {
flex: 1;
height: 82rpx;
padding: 0 16rpx;
border-radius: 18rpx;
background: rgba(17, 24, 39, 0.05);
font-size: 28rpx;
}
.send {
width: 140rpx;
height: 82rpx;
border-radius: 18rpx;
background: linear-gradient(135deg, #111827 0%, #3b82f6 100%);
color: #fff;
font-weight: 900;
display: flex;
align-items: center;
justify-content: center;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin: 8rpx 0 12rpx;
}
.chip {
padding: 12rpx 16rpx;
border-radius: 999rpx;
background: rgba(17, 24, 39, 0.06);
font-size: 26rpx;
font-weight: 700;
}
</style>
+320
View File
@@ -0,0 +1,320 @@
<template>
<view class="container">
<view class="card filters">
<view class="row sc">
<view
v-for="s in tabs"
:key="s.value"
class="chip"
:class="{ on: s.value === active }"
hover-class="none"
@tap="setActive(s.value)"
>
{{ s.label }}
</view>
</view>
</view>
<view class="list">
<view v-for="o in viewList" :key="o.id" class="card item" @tap="open(o.id)">
<view class="row between">
<view class="n">{{ o.projectName }}</view>
<view
class="st"
:class="{
w: o.uiStatus === '待到店',
g: o.uiStatus === '已完成',
d: o.uiStatus === '已取消',
x: o.uiStatus === '已过期'
}"
>
{{ o.uiStatus }}
</view>
</view>
<view class="muted meta">{{ o.appointmentDate }} {{ o.appointmentSlot }} · {{ o.technicianName }}</view>
<view class="ops row" v-if="o.uiStatus === '待到店'">
<view class="op" @tap.stop="reschedule(o)">改约</view>
<view class="op danger" @tap.stop="cancel(o)">取消预约</view>
</view>
</view>
</view>
<AiFloat />
</view>
</template>
<script>
import AiFloat from '@/components/AiFloat.vue'
export default {
components: { AiFloat },
data() {
const rows = [
{
id: 'apt_demo_pending_001',
createdAt: Date.now() - 2 * 60 * 60 * 1000,
status: '待核销',
uiStatus: '待到店',
group: 'pending',
amount: 238,
projectId: 'p3',
projectName: '补水修护屏障护理',
durationMin: 80,
orderType: 'booking',
appointmentDate: '2026-06-22',
appointmentSlot: '10:30',
technicianName: '许言',
note: '想要舒缓泛红',
verifyCode: 'VCAP20260621001'
},
{
id: 'apt_demo_pending_002',
createdAt: Date.now() - 6 * 60 * 60 * 1000,
status: '待核销',
uiStatus: '待到店',
group: 'pending',
amount: 188,
projectId: 'p4',
projectName: '肩颈舒缓筋膜放松',
durationMin: 60,
orderType: 'booking',
appointmentDate: '2026-06-24',
appointmentSlot: '19:00',
technicianName: '周晴',
note: '',
verifyCode: 'VCAP20260621002'
},
{
id: 'apt_demo_done_001',
createdAt: Date.now() - 9 * 24 * 60 * 60 * 1000,
status: '已完成',
uiStatus: '已完成',
group: 'done',
amount: 168,
projectId: 'p2',
projectName: '深层清洁黑头管理',
durationMin: 75,
orderType: 'booking',
appointmentDate: '2026-06-12',
appointmentSlot: '15:00',
technicianName: '林安',
note: 'T区出油严重',
verifyCode: 'VCAP20260621003'
},
{
id: 'apt_demo_done_002',
createdAt: Date.now() - 15 * 24 * 60 * 60 * 1000,
status: '已完成',
uiStatus: '已完成',
group: 'done',
amount: 99,
projectId: 'p1',
projectName: '水氧净透体验',
durationMin: 60,
orderType: 'booking',
appointmentDate: '2026-06-06',
appointmentSlot: '11:30',
technicianName: '系统分配',
note: '',
verifyCode: 'VCAP20260621004'
},
{
id: 'apt_demo_canceled_001',
createdAt: Date.now() - 3 * 24 * 60 * 60 * 1000,
status: '已取消',
uiStatus: '已取消',
group: 'canceled',
amount: 238,
projectId: 'p3',
projectName: '补水修护屏障护理',
durationMin: 80,
orderType: 'booking',
appointmentDate: '2026-06-23',
appointmentSlot: '16:00',
technicianName: '许言',
note: '',
verifyCode: 'VCAP20260621005'
},
{
id: 'apt_demo_canceled_002',
createdAt: Date.now() - 7 * 24 * 60 * 60 * 1000,
status: '已取消',
uiStatus: '已取消',
group: 'canceled',
amount: 188,
projectId: 'p4',
projectName: '肩颈舒缓筋膜放松',
durationMin: 60,
orderType: 'booking',
appointmentDate: '2026-06-17',
appointmentSlot: '13:30',
technicianName: '周晴',
note: '',
verifyCode: 'VCAP20260621008'
},
{
id: 'apt_demo_expired_001',
createdAt: Date.now() - 26 * 60 * 60 * 1000,
status: '待核销',
uiStatus: '已过期',
group: 'expired',
amount: 188,
projectId: 'p4',
projectName: '肩颈舒缓筋膜放松',
durationMin: 60,
orderType: 'booking',
appointmentDate: '2026-06-20',
appointmentSlot: '13:30',
technicianName: '周晴',
note: '',
verifyCode: 'VCAP20260621006'
},
{
id: 'apt_demo_expired_002',
createdAt: Date.now() - 4 * 24 * 60 * 60 * 1000,
status: '待核销',
uiStatus: '已过期',
group: 'expired',
amount: 99,
projectId: 'p1',
projectName: '水氧净透体验',
durationMin: 60,
orderType: 'booking',
appointmentDate: '2026-06-19',
appointmentSlot: '18:00',
technicianName: '系统分配',
note: '',
verifyCode: 'VCAP20260621007'
}
]
return {
tabs: [
{ value: 'pending', label: '待到店' },
{ value: 'done', label: '已完成' },
{ value: 'canceled', label: '已取消' },
{ value: 'expired', label: '已过期' }
],
active: 'pending',
rows,
viewList: rows.filter((x) => x.group === 'pending')
}
},
onLoad() {
this.apply()
},
onShow() {
this.apply()
},
methods: {
apply() {
const v = this.rows.filter((x) => x.group === this.active)
this.viewList = v && v.length ? v : this.rows.slice(0, 3)
},
setActive(v) {
if (!v || v === this.active) return
this.active = v
this.apply()
},
open(id) {
const o = this.rows.find((x) => x.id === id) || this.rows[0]
const payload = encodeURIComponent(JSON.stringify(o))
uni.navigateTo({ url: `/pages/orders/detail?payload=${payload}` })
},
goProjects() {
uni.switchTab({ url: '/pages/projects/list' })
},
reschedule(o) {
uni.navigateTo({ url: `/pages/booking/create?projectId=${o.projectId}` })
},
cancel(o) {
uni.showModal({
title: '确认取消',
content: '取消后该预约将变更为已取消(原型演示)。',
success: (res) => {
if (!res.confirm) return
this.rows = this.rows.map((x) => (x.id === o.id ? { ...x, status: '已取消', uiStatus: '已取消', group: 'canceled' } : x))
this.apply()
}
})
}
}
}
</script>
<style lang="scss" scoped>
.filters {
padding: 16rpx;
}
.sc {
white-space: nowrap;
}
.chip {
padding: 14rpx 18rpx;
margin-right: 12rpx;
border-radius: 999rpx;
font-size: 26rpx;
background: rgba(17, 24, 39, 0.06);
color: #111827;
flex: 0 0 auto;
}
.on {
background: rgba(59, 130, 246, 0.14);
color: #1d4ed8;
}
.list {
margin-top: 18rpx;
}
.item {
padding: 22rpx;
margin-bottom: 18rpx;
}
.n {
font-size: 32rpx;
font-weight: 950;
max-width: 520rpx;
}
.st {
padding: 10rpx 14rpx;
border-radius: 999rpx;
font-size: 24rpx;
font-weight: 900;
}
.w {
background: rgba(59, 130, 246, 0.16);
color: #1d4ed8;
}
.g {
background: rgba(16, 185, 129, 0.16);
color: #047857;
}
.d {
background: rgba(239, 68, 68, 0.12);
color: #b91c1c;
}
.x {
background: rgba(17, 24, 39, 0.12);
color: #111827;
}
.meta {
margin-top: 12rpx;
font-size: 26rpx;
}
.ops {
margin-top: 16rpx;
gap: 12rpx;
}
.op {
padding: 12rpx 16rpx;
border-radius: 18rpx;
background: rgba(17, 24, 39, 0.06);
font-size: 26rpx;
font-weight: 800;
}
.danger {
background: rgba(239, 68, 68, 0.12);
color: #b91c1c;
}
</style>
+108
View File
@@ -0,0 +1,108 @@
<template>
<view class="container">
<view class="card hero">
<view class="brand">智约美服</view>
<view class="slogan muted">AI 一键预约24 小时接单</view>
</view>
<view class="card box">
<view class="wx row">
<view class="wx-icon">W</view>
<view class="wx-text">
<view class="t1">微信一键登录</view>
<view class="t2 muted">用于同步订单卡券与会员档案</view>
</view>
</view>
<view class="btn btn-primary login" @tap="doLogin">同意授权并登录</view>
</view>
<view class="foot muted">
<text @tap="goPrivacy">用户隐私协议</text>
<text class="dot"> · </text>
<text @tap="goTerms">服务协议</text>
</view>
</view>
</template>
<script>
const KEY = 'zy_authed_v1'
export default {
onShow() {
const ok = !!uni.getStorageSync(KEY)
if (ok) {
uni.switchTab({ url: '/pages/home/index' })
}
},
methods: {
doLogin() {
uni.setStorageSync(KEY, 1)
uni.switchTab({ url: '/pages/home/index' })
},
goPrivacy() {
uni.navigateTo({ url: '/pages/legal/privacy' })
},
goTerms() {
uni.navigateTo({ url: '/pages/legal/terms' })
}
}
}
</script>
<style lang="scss" scoped>
.hero {
padding: 40rpx 28rpx;
background: linear-gradient(135deg, rgba(17, 24, 39, 1) 0%, rgba(59, 130, 246, 1) 100%);
border: 0;
color: #fff;
}
.brand {
font-size: 54rpx;
font-weight: 950;
letter-spacing: 2rpx;
}
.slogan {
margin-top: 14rpx;
color: rgba(255, 255, 255, 0.82);
font-size: 28rpx;
}
.box {
margin-top: 22rpx;
padding: 26rpx;
}
.wx {
align-items: flex-start;
}
.wx-icon {
width: 84rpx;
height: 84rpx;
border-radius: 22rpx;
background: rgba(16, 185, 129, 0.14);
color: #059669;
font-weight: 950;
display: flex;
align-items: center;
justify-content: center;
margin-right: 18rpx;
}
.t1 {
font-size: 36rpx;
font-weight: 950;
}
.t2 {
margin-top: 10rpx;
font-size: 26rpx;
}
.login {
margin-top: 22rpx;
}
.foot {
margin-top: 24rpx;
text-align: center;
font-size: 26rpx;
}
.dot {
opacity: 0.6;
}
</style>
+425
View File
@@ -0,0 +1,425 @@
<template>
<view class="container">
<view class="card block">
<view class="row between">
<view class="title">预约服务</view>
<view class="muted">{{ project ? '已选择' : '请选择' }}</view>
</view>
<picker mode="selector" :range="projectNames" :value="projectIndex" @change="onPickProject">
<view class="pick2">
<view class="pname">{{ project ? project.name : '选择项目/套餐' }}</view>
<view class="muted psub" v-if="project">{{ project.durationMin }} 分钟 · ¥{{ project.price }}</view>
</view>
</picker>
<view class="muted meta" v-if="project">适合人群{{ project.fitFor }}</view>
<view class="muted meta" v-if="project">禁忌提醒{{ project.taboo }}</view>
</view>
<view v-if="project">
<view class="card block">
<view class="title">选择日期</view>
<scroll-view class="sc" scroll-x>
<view class="row">
<view
v-for="d in dates"
:key="d.value"
class="date"
:class="{ on: d.value === form.date }"
@tap="selectDate(d.value)"
>
<view class="d1">{{ d.label1 }}</view>
<view class="d2 muted">{{ d.label2 }}</view>
</view>
</view>
</scroll-view>
<picker mode="date" :value="form.date" @change="onPickDate">
<view class="pick">自定义日期{{ form.date }}</view>
</picker>
</view>
<view class="card block">
<view class="title">选择时段</view>
<view class="grid">
<view
v-for="s in slotRows"
:key="s.value"
class="slot"
:class="{ on: s.value === form.slot, off: s.disabled }"
@tap="pickSlot(s)"
>
{{ s.value }}
</view>
</view>
<view class="hint muted">满档逻辑在商用版由实时档期接口返回这里为原型演示</view>
</view>
<view class="card block">
<view class="title">选择技师</view>
<view class="techs">
<view class="tech" :class="{ on: form.techId === 'auto' }" @tap="form.techId = 'auto'">
<view class="row between">
<view class="t-name">系统自动分配</view>
<view class="tag">推荐</view>
</view>
<view class="muted t-sub">根据项目与档期匹配最合适的技师</view>
</view>
<view
v-for="t in techs"
:key="t.id"
class="tech"
:class="{ on: form.techId === t.id }"
@tap="form.techId = t.id"
>
<view class="row between">
<view>
<view class="t-name">{{ t.name }}</view>
<view class="muted t-sub">{{ t.title }}</view>
</view>
<view class="tags">
<text class="tag" v-for="tg in t.tags" :key="tg">{{ tg }}</text>
</view>
</view>
</view>
</view>
</view>
<view class="card block">
<view class="title">备注需求</view>
<textarea
class="ta"
placeholder="例如:敏感肌、易过敏、想做补水修护…"
v-model="form.note"
maxlength="120"
/>
</view>
<view class="space"></view>
<view class="fixbar">
<view class="btn btn-primary submit" @tap="submit">提交预约</view>
</view>
</view>
<view v-else class="card empty">
<view class="e1">请选择项目后继续</view>
<view class="muted e2">你也可以在项目详情里直接点击立即预约</view>
</view>
<AiFloat />
</view>
</template>
<script>
import AiFloat from '@/components/AiFloat.vue'
import { projects, technicians } from '@/common/mockData'
const fallbackProjects = [
{
id: 'fp1',
name: '水氧净透体验',
price: 99,
durationMin: 60,
fitFor: '初次体验、暗沉、出油',
taboo: '近期医美术后需评估',
desc: '轻盈水氧 + 净透护理,快速提升肤感与通透度。'
},
{
id: 'fp2',
name: '补水修护屏障护理',
price: 238,
durationMin: 80,
fitFor: '敏感泛红、干燥紧绷',
taboo: '过敏急性期请先咨询',
desc: '修护屏障与舒缓敏感,适合换季与长期干燥人群。'
},
{
id: 'fp3',
name: '肩颈舒缓筋膜放松',
price: 188,
durationMin: 60,
fitFor: '久坐办公、肩颈僵硬',
taboo: '急性损伤与发热期不建议',
desc: '深度放松肌群与筋膜,改善紧绷与酸胀。'
}
]
const runtimeProjects = Array.isArray(projects) && projects.length ? projects : fallbackProjects
function pad2(n) {
return n < 10 ? `0${n}` : `${n}`
}
function dayLabel(d) {
const m = d.getMonth() + 1
const dd = d.getDate()
const w = ['日', '一', '二', '三', '四', '五', '六'][d.getDay()]
return { label1: `${m}/${dd}`, label2: `${w}` }
}
function isoDate(d) {
const y = d.getFullYear()
const m = pad2(d.getMonth() + 1)
const dd = pad2(d.getDate())
return `${y}-${m}-${dd}`
}
export default {
components: { AiFloat },
data() {
return {
projectId: '',
project: runtimeProjects[0] || null,
allProjects: runtimeProjects,
projectNames: runtimeProjects.length ? runtimeProjects.map((x) => x.name) : ['暂无项目'],
projectIndex: 0,
dates: [],
slotRows: [],
techs: technicians,
form: {
date: '',
slot: '14:30',
techId: 'auto',
note: ''
}
}
},
onLoad(query) {
this.projectId = query.projectId || ''
this.allProjects = runtimeProjects
this.projectNames = this.allProjects.length ? this.allProjects.map((x) => x.name) : ['暂无项目']
this.project = this.allProjects.find((x) => x.id === this.projectId) || this.allProjects[0] || null
this.projectIndex = Math.max(
0,
this.allProjects.findIndex((x) => x.id === (this.project ? this.project.id : ''))
)
const today = new Date()
const ds = []
for (let i = 0; i < 7; i++) {
const d = new Date(today.getTime() + i * 24 * 60 * 60 * 1000)
const l = dayLabel(d)
ds.push({
value: isoDate(d),
label1: i === 0 ? '今天' : l.label1,
label2: i === 0 ? l.label1 : l.label2
})
}
this.dates = ds
if (!this.form.date) this.form.date = ds[0]?.value || ''
this.buildSlots()
},
methods: {
onPickProject(e) {
const idx = Number(e.detail.value || 0)
this.projectIndex = idx
this.project = this.allProjects[idx] || null
},
selectDate(v) {
this.form.date = v
this.buildSlots()
},
onPickDate(e) {
const v = e.detail.value
if (!v) return
this.form.date = v
this.buildSlots()
},
buildSlots() {
const base = ['10:00', '11:30', '13:00', '14:30', '16:00', '17:30', '19:00', '20:30']
const today = this.dates[0]?.value || ''
const disabledSet = new Set()
if (this.form.date === today) {
disabledSet.add('10:00')
disabledSet.add('11:30')
}
this.slotRows = base.map((x) => ({ value: x, disabled: disabledSet.has(x) }))
if (disabledSet.has(this.form.slot)) this.form.slot = '14:30'
},
pickSlot(s) {
if (s.disabled) return
this.form.slot = s.value
},
submit() {
if (!this.project) {
uni.showToast({ title: '请选择项目', icon: 'none' })
return
}
if (!this.form.date || !this.form.slot) {
uni.showToast({ title: '请选择日期和时段', icon: 'none' })
return
}
const tech =
this.form.techId === 'auto' ? null : this.techs.find((x) => x.id === this.form.techId) || null
const payload = encodeURIComponent(
JSON.stringify({
type: 'booking',
projectId: this.project.id,
date: this.form.date,
slot: this.form.slot,
techId: this.form.techId,
techName: tech?.name || '系统分配',
note: this.form.note || ''
})
)
uni.navigateTo({ url: `/pages/order/confirm?payload=${payload}` })
}
}
}
</script>
<style lang="scss" scoped>
.block {
padding: 22rpx;
margin-bottom: 18rpx;
}
.pick2 {
margin-top: 14rpx;
padding: 18rpx;
border-radius: 20rpx;
border: 1rpx solid rgba(17, 24, 39, 0.08);
background: #fff;
}
.pname {
font-size: 32rpx;
font-weight: 950;
}
.psub {
margin-top: 8rpx;
font-size: 24rpx;
}
.empty {
margin-top: 18rpx;
padding: 34rpx 26rpx;
text-align: center;
}
.e1 {
font-size: 34rpx;
font-weight: 950;
}
.e2 {
margin-top: 12rpx;
font-size: 26rpx;
}
.name {
font-size: 34rpx;
font-weight: 900;
}
.price {
font-size: 32rpx;
font-weight: 900;
}
.meta {
margin-top: 10rpx;
font-size: 26rpx;
}
.title {
font-weight: 900;
font-size: 30rpx;
margin-bottom: 14rpx;
}
.sc {
white-space: nowrap;
}
.pick {
margin-top: 14rpx;
padding: 16rpx 18rpx;
border-radius: 18rpx;
background: rgba(17, 24, 39, 0.05);
font-size: 26rpx;
font-weight: 900;
}
.date {
width: 150rpx;
padding: 16rpx;
margin-right: 12rpx;
border-radius: 18rpx;
border: 1rpx solid rgba(17, 24, 39, 0.08);
background: rgba(17, 24, 39, 0.03);
flex: 0 0 auto;
}
.on {
background: rgba(59, 130, 246, 0.12);
border-color: rgba(59, 130, 246, 0.35);
}
.d1 {
font-weight: 900;
font-size: 30rpx;
}
.d2 {
margin-top: 8rpx;
font-size: 24rpx;
}
.grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12rpx;
}
.slot {
height: 70rpx;
border-radius: 18rpx;
border: 1rpx solid rgba(17, 24, 39, 0.08);
background: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 26rpx;
}
.off {
background: rgba(17, 24, 39, 0.05);
color: rgba(17, 24, 39, 0.35);
}
.hint {
margin-top: 14rpx;
font-size: 24rpx;
}
.techs {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.tech {
padding: 16rpx;
border-radius: 20rpx;
border: 1rpx solid rgba(17, 24, 39, 0.08);
background: #fff;
}
.t-name {
font-weight: 900;
font-size: 30rpx;
}
.t-sub {
margin-top: 6rpx;
font-size: 24rpx;
}
.tags {
display: flex;
gap: 8rpx;
flex-wrap: wrap;
justify-content: flex-end;
max-width: 280rpx;
}
.ta {
width: 100%;
min-height: 160rpx;
padding: 16rpx;
border-radius: 18rpx;
border: 1rpx solid rgba(17, 24, 39, 0.08);
background: #fff;
font-size: 28rpx;
box-sizing: border-box;
}
.space {
height: 160rpx;
}
.fixbar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 18rpx 24rpx 24rpx;
background: linear-gradient(180deg, rgba(246, 247, 251, 0) 0%, rgba(246, 247, 251, 1) 46%);
}
.submit {
height: 92rpx;
}
</style>
+233
View File
@@ -0,0 +1,233 @@
<template>
<view class="container">
<view class="card filters">
<view class="row sc">
<view v-for="s in tabs" :key="s.value" class="chip" :class="{ on: s.value === active }" hover-class="none" @tap="setActive(s.value)">
{{ s.label }}
</view>
</view>
</view>
<view class="list">
<view v-for="c in viewList" :key="c.id" class="card item" @tap="open(c.id)">
<view class="row between">
<view class="n">{{ c.projectName }}</view>
<view class="tag">剩余 {{ c.remainingTimes }}</view>
</view>
<view class="muted meta">核销码{{ c.verifyCode }}</view>
<view class="muted meta" v-if="c.validText">有效期{{ c.validText }}</view>
<view class="row between meta2">
<view class="muted">状态{{ c.uiStatus }}</view>
<view class="amt">¥{{ c.amount }}</view>
</view>
<view class="row between ops" v-if="c.uiStatus === '未使用'">
<view class="btn btn-ghost ob" @tap.stop="book(c)">去使用预约</view>
<view class="btn btn-primary ob" @tap.stop="openCode(c)">核销码</view>
</view>
</view>
</view>
<AiFloat />
</view>
</template>
<script>
import AiFloat from '@/components/AiFloat.vue'
import { demoOrders } from '@/common/demoOrders'
function buildCoupons() {
const now = Date.now()
const extra = [
{
id: 'cp_demo_unused_001',
createdAt: now - 2 * 24 * 60 * 60 * 1000,
status: '待核销',
amount: 299,
projectId: 'p9',
projectName: '新客体验套餐 · 3 次',
durationMin: 60,
orderType: 'coupon',
couponTitle: '新客体验套餐 · 3 次',
couponPlanKey: 'package',
couponPlanLabel: '套餐',
validText: '有效期 90 天',
remainingTimes: 3,
verifyCode: 'VCCP20260621001'
},
{
id: 'cp_demo_pending_001',
createdAt: now - 6 * 24 * 60 * 60 * 1000,
status: '待付款',
amount: 168,
projectId: 'p2',
projectName: '深层清洁黑头管理',
durationMin: 75,
orderType: 'coupon',
couponTitle: '深层清洁黑头管理',
couponPlanKey: 'single',
couponPlanLabel: '单次券',
validText: '有效期 30 天',
remainingTimes: 1,
verifyCode: 'VCCP20260621002'
},
{
id: 'cp_demo_used_001',
createdAt: now - 18 * 24 * 60 * 60 * 1000,
status: '已完成',
amount: 899,
projectId: 'p5',
projectName: '皮肤管理次卡 5 次',
durationMin: 60,
orderType: 'coupon',
couponTitle: '皮肤管理次卡 5 次',
couponPlanKey: 'times5',
couponPlanLabel: '次卡 5 次',
validText: '有效期 180 天',
remainingTimes: 0,
verifyCode: 'VCCP20260621003'
},
{
id: 'cp_demo_expired_001',
createdAt: now - 65 * 24 * 60 * 60 * 1000,
status: '已过期',
amount: 99,
projectId: 'p1',
projectName: '水氧净透体验',
durationMin: 60,
orderType: 'coupon',
couponTitle: '水氧净透体验',
couponPlanKey: 'single',
couponPlanLabel: '单次券',
validText: '已过期(原型演示)',
remainingTimes: 1,
verifyCode: 'VCCP20260621004'
}
]
const all = [...demoOrders.filter((x) => x.orderType === 'coupon'), ...extra]
return all.map((x) => {
const uiStatus = x.status === '待核销' ? '未使用' : x.status === '已完成' ? '已核销' : x.status === '待付款' ? '待付款' : '已过期'
const group = uiStatus === '已核销' ? 'used' : uiStatus === '待付款' ? 'pending' : uiStatus === '已过期' ? 'expired' : 'unused'
return { ...x, uiStatus, group }
})
}
export default {
components: { AiFloat },
data() {
const coupons = buildCoupons()
const init = coupons.filter((x) => x.group === 'unused')
return {
tabs: [
{ value: 'unused', label: '未使用' },
{ value: 'used', label: '已核销' },
{ value: 'pending', label: '待付款' },
{ value: 'expired', label: '已过期' }
],
active: 'unused',
coupons,
viewList: init && init.length ? init : coupons.slice(0, 3)
}
},
onLoad() {
this.apply()
},
onShow() {
this.coupons = buildCoupons()
this.apply()
},
methods: {
apply() {
const v = this.coupons.filter((x) => x.group === this.active)
this.viewList = v && v.length ? v : this.coupons.slice(0, 3)
},
setActive(v) {
if (!v || v === this.active) return
this.active = v
this.apply()
},
open(id) {
const c = this.coupons.find((x) => x.id === id) || this.coupons[0]
const payload = encodeURIComponent(JSON.stringify(c))
uni.navigateTo({ url: `/pages/orders/detail?payload=${payload}` })
},
book(c) {
uni.navigateTo({ url: `/pages/booking/create?projectId=${c.projectId}` })
},
openCode(c) {
const payload = encodeURIComponent(JSON.stringify(c))
uni.navigateTo({ url: `/pages/verify/code?payload=${payload}` })
},
goProjects() {
uni.switchTab({ url: '/pages/projects/list' })
}
}
}
</script>
<style lang="scss" scoped>
.filters {
padding: 16rpx;
}
.sc {
white-space: nowrap;
}
.chip {
padding: 14rpx 18rpx;
margin-right: 12rpx;
border-radius: 999rpx;
font-size: 26rpx;
background: rgba(17, 24, 39, 0.06);
color: #111827;
flex: 0 0 auto;
}
.on {
background: rgba(59, 130, 246, 0.14);
color: #1d4ed8;
}
.empty {
margin-top: 18rpx;
padding: 26rpx;
}
.e1 {
font-size: 36rpx;
font-weight: 950;
}
.e2 {
margin-top: 10rpx;
font-size: 26rpx;
}
.ebtn {
margin-top: 18rpx;
}
.list {
margin-top: 18rpx;
}
.item {
padding: 22rpx;
margin-bottom: 18rpx;
}
.n {
font-size: 32rpx;
font-weight: 950;
max-width: 520rpx;
}
.meta {
margin-top: 12rpx;
font-size: 26rpx;
}
.meta2 {
margin-top: 10rpx;
}
.ops {
margin-top: 16rpx;
gap: 14rpx;
}
.ob {
flex: 1;
height: 80rpx;
}
.amt {
font-weight: 950;
}
</style>
+341
View File
@@ -0,0 +1,341 @@
<template>
<view class="container">
<view class="card hero" @tap="goStore">
<view class="row between">
<view>
<view class="trow">
<image v-if="store.logo" class="logo" :src="store.logo" mode="aspectFill" />
<view class="title">{{ store.name }}</view>
</view>
<view class="sub muted">{{ store.openHours }} · {{ store.address }}</view>
</view>
<view class="pill" @tap.stop="callStore">
<text class="p-t">电话</text>
</view>
</view>
<view class="locrow row between">
<view class="loc muted">{{ locText }} · {{ distText }}</view>
<view class="nav" @tap.stop="navToStore">导航</view>
</view>
</view>
<view class="card banner">
<swiper class="sw" circular autoplay interval="3500" duration="500">
<swiper-item>
<view class="b b1">
<view class="b-t">新客体验</view>
<view class="b-s">水氧净透 ¥99</view>
</view>
</swiper-item>
<swiper-item>
<view class="b b2">
<view class="b-t">皮肤管理</view>
<view class="b-s">补水修护 ¥238</view>
</view>
</swiper-item>
<swiper-item>
<view class="b b3">
<view class="b-t">次卡特惠</view>
<view class="b-s">5 ¥899</view>
</view>
</swiper-item>
</swiper>
</view>
<view class="grid4">
<view class="card q" @tap="goBooking">
<view class="q-t">立即预约</view>
<view class="q-s muted">选时间/技师</view>
</view>
<view class="card q" @tap="goProjects">
<view class="q-t">全部项目</view>
<view class="q-s muted">价格/时长</view>
</view>
<view class="card q" @tap="goCoupons">
<view class="q-t">我的次卡</view>
<view class="q-s muted">剩余次数</view>
</view>
<view class="card q" @tap="goMember">
<view class="q-t">会员中心</view>
<view class="q-s muted">积分/储值</view>
</view>
</view>
<view class="section row between">
<view class="h">人气推荐项目</view>
<view class="more" @tap="goProjects">全部</view>
</view>
<view class="grid2">
<ProjectGridCard v-for="p in hotProjects" :key="p.id" :project="p" />
</view>
<view class="card store">
<view class="row between">
<view class="sh">门店信息</view>
<view class="more" @tap="goStore">查看</view>
</view>
<view class="muted sline">营业时间{{ store.openHours }}休息{{ store.restDay }}</view>
<view class="muted sline">地址{{ store.address }}</view>
<view class="muted sline">电话{{ store.phone }}</view>
<view class="row between sline">
<view class="muted">技师团队点击查看</view>
<view class="rt">
<view class="star"> {{ teamRating }}</view>
<view class="like" @tap.stop="likeTeam"> {{ likeCount }}</view>
</view>
</view>
</view>
<AiFloat />
</view>
</template>
<script>
import AiFloat from '@/components/AiFloat.vue'
import ProjectGridCard from '@/components/ProjectGridCard.vue'
import { storeProfile, projects } from '@/common/mockData'
export default {
components: { AiFloat, ProjectGridCard },
data() {
return {
store: storeProfile,
hotProjects: projects.slice(0, 4),
locText: '定位:获取中…',
distText: '距离:计算中…',
teamRating: 4.9,
likeCount: 128
}
},
onShow() {
this.locText = '定位:获取中…'
this.distText = '距离:计算中…'
uni.getLocation({
type: 'gcj02',
success: (res) => {
this.locText = '定位:上海市 静安区(示例)'
const d = this.calcDistanceMeter(res.latitude, res.longitude, this.store.latitude, this.store.longitude)
this.distText = `距离:${this.formatDistance(d)}`
},
fail: () => {
this.locText = '定位:未开启(可在“我的”页授权定位)'
this.distText = '距离:--'
}
})
},
methods: {
goProjects() {
uni.switchTab({ url: '/pages/projects/list' })
},
goBooking() {
const p = Array.isArray(projects) && projects.length ? projects[0] : null
if (!p) {
this.goProjects()
return
}
uni.navigateTo({ url: `/pages/booking/create?projectId=${p.id}` })
},
goCoupons() {
uni.navigateTo({ url: '/pages/coupons/list' })
},
goMember() {
uni.switchTab({ url: '/pages/member/index' })
},
goStore() {
uni.navigateTo({ url: '/pages/store/detail' })
},
callStore() {
uni.makePhoneCall({ phoneNumber: this.store.phone })
},
navToStore() {
const lat = this.store.latitude
const lng = this.store.longitude
if (typeof lat !== 'number' || typeof lng !== 'number') {
uni.showToast({ title: '门店坐标未配置', icon: 'none' })
return
}
uni.openLocation({
latitude: lat,
longitude: lng,
name: this.store.name,
address: this.store.address
})
},
calcDistanceMeter(lat1, lng1, lat2, lng2) {
if ([lat1, lng1, lat2, lng2].some((x) => typeof x !== 'number')) return NaN
const toRad = (v) => (v * Math.PI) / 180
const R = 6371000
const dLat = toRad(lat2 - lat1)
const dLng = toRad(lng2 - lng1)
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) * Math.sin(dLng / 2)
return 2 * R * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
},
formatDistance(m) {
if (!Number.isFinite(m)) return '--'
if (m < 1000) return `${Math.max(1, Math.round(m))}m`
const km = m / 1000
if (km < 10) return `${km.toFixed(1)}km`
return `${Math.round(km)}km`
},
likeTeam() {
this.likeCount += 1
uni.showToast({ title: '已点赞', icon: 'none' })
}
}
}
</script>
<style lang="scss" scoped>
.hero {
padding: 26rpx;
}
.trow {
display: flex;
align-items: center;
gap: 14rpx;
}
.logo {
width: 56rpx;
height: 56rpx;
border-radius: 16rpx;
}
.title {
font-size: 40rpx;
font-weight: 950;
}
.sub {
margin-top: 8rpx;
font-size: 26rpx;
}
.loc {
font-size: 24rpx;
}
.locrow {
margin-top: 14rpx;
}
.nav {
padding: 12rpx 16rpx;
border-radius: 999rpx;
background: rgba(59, 130, 246, 0.14);
color: #1d4ed8;
font-size: 24rpx;
font-weight: 900;
}
.pill {
padding: 14rpx 18rpx;
border-radius: 999rpx;
background: rgba(17, 24, 39, 0.06);
}
.p-t {
font-weight: 700;
font-size: 26rpx;
}
.banner {
margin-top: 18rpx;
padding: 0;
overflow: hidden;
}
.sw {
height: 220rpx;
}
.b {
height: 220rpx;
padding: 26rpx;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.b1 {
background: linear-gradient(135deg, rgba(17, 24, 39, 1) 0%, rgba(59, 130, 246, 1) 100%);
}
.b2 {
background: linear-gradient(135deg, rgba(3, 105, 161, 1) 0%, rgba(16, 185, 129, 1) 100%);
}
.b3 {
background: linear-gradient(135deg, rgba(124, 58, 237, 1) 0%, rgba(59, 130, 246, 1) 100%);
}
.b-t {
color: #fff;
font-weight: 950;
font-size: 42rpx;
}
.b-s {
margin-top: 10rpx;
color: rgba(255, 255, 255, 0.84);
font-size: 28rpx;
font-weight: 700;
}
.grid4 {
margin-top: 18rpx;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 18rpx;
}
.q {
padding: 18rpx;
}
.q-t {
font-weight: 950;
font-size: 30rpx;
}
.q-s {
margin-top: 8rpx;
font-size: 24rpx;
}
.section {
margin-top: 28rpx;
padding: 6rpx 2rpx;
}
.h {
font-size: 32rpx;
font-weight: 950;
}
.more {
color: #3b82f6;
font-weight: 700;
font-size: 28rpx;
}
.grid2 {
margin-top: 14rpx;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 18rpx;
}
.store {
margin-top: 18rpx;
padding: 22rpx;
}
.rt {
display: flex;
align-items: center;
gap: 14rpx;
}
.star {
padding: 10rpx 14rpx;
border-radius: 999rpx;
font-size: 24rpx;
font-weight: 900;
background: rgba(245, 158, 11, 0.14);
color: #b45309;
}
.like {
padding: 10rpx 14rpx;
border-radius: 999rpx;
font-size: 24rpx;
font-weight: 900;
background: rgba(17, 24, 39, 0.06);
color: rgba(17, 24, 39, 0.86);
}
.sh {
font-size: 32rpx;
font-weight: 950;
}
.sline {
margin-top: 12rpx;
font-size: 26rpx;
}
</style>
+37
View File
@@ -0,0 +1,37 @@
<template>
<view class="container">
<view class="card block">
<view class="t">用户隐私协议示例</view>
<view class="p muted">本页面用于原型演示正式商用请替换为合规隐私政策全文</view>
<view class="p muted">1. 我们可能会收集昵称/头像授权后订单与预约信息</view>
<view class="p muted">2. 定位权限仅用于展示附近门店与距离可在系统设置中关闭</view>
<view class="p muted">3. 我们不会向无关第三方出售你的个人信息</view>
<view class="p muted">4. 你可申请查询更正删除个人信息</view>
</view>
<AiFloat />
</view>
</template>
<script>
import AiFloat from '@/components/AiFloat.vue'
export default {
components: { AiFloat }
}
</script>
<style lang="scss" scoped>
.block {
padding: 22rpx;
}
.t {
font-size: 36rpx;
font-weight: 950;
}
.p {
margin-top: 14rpx;
font-size: 28rpx;
line-height: 1.7;
}
</style>
+37
View File
@@ -0,0 +1,37 @@
<template>
<view class="container">
<view class="card block">
<view class="t">服务协议示例</view>
<view class="p muted">本页面用于原型演示正式商用请替换为合规服务条款全文</view>
<view class="p muted">1. 用户可通过本小程序进行预约与购买具体服务以门店实际确认为准</view>
<view class="p muted">2. 预约改期与取消规则以订单页展示为准</view>
<view class="p muted">3. 卡券核销后视为完成服务相关售后按门店规则执行</view>
<view class="p muted">4. 平台将尽力保障服务可用性但不对不可抗力导致的中断负责</view>
</view>
<AiFloat />
</view>
</template>
<script>
import AiFloat from '@/components/AiFloat.vue'
export default {
components: { AiFloat }
}
</script>
<style lang="scss" scoped>
.block {
padding: 22rpx;
}
.t {
font-size: 36rpx;
font-weight: 950;
}
.p {
margin-top: 14rpx;
font-size: 28rpx;
line-height: 1.7;
}
</style>
+256
View File
@@ -0,0 +1,256 @@
<template>
<view class="container">
<view class="card profile">
<view class="row between">
<view class="row">
<image class="avatar" :src="avatarUrl" mode="aspectFill" />
<view class="uinfo">
<view class="u">微信用户</view>
<view class="muted s">会员档案 · {{ tag }}</view>
</view>
</view>
<view class="badge">VIP</view>
</view>
<view class="stats row">
<view class="st">
<view class="v">{{ points }}</view>
<view class="muted l">积分</view>
</view>
<view class="sep"></view>
<view class="st">
<view class="v">¥{{ balance }}</view>
<view class="muted l">储值</view>
</view>
<view class="sep"></view>
<view class="st">
<view class="v">{{ visitCount }}</view>
<view class="muted l">到店</view>
</view>
</view>
</view>
<view class="card menu">
<view class="cell row between" @tap="goAppointments">
<view class="c-l">
<view class="c-t">我的预约</view>
<view class="muted c-s">待到店 / 改约 / 取消</view>
</view>
<view class="arr"></view>
</view>
<view class="cell row between" @tap="goCoupons">
<view class="c-l">
<view class="c-t">我的卡券 / 次卡</view>
<view class="muted c-s">剩余次数 / 去使用</view>
</view>
<view class="arr"></view>
</view>
<view class="cell row between" @tap="goRecords">
<view class="c-l">
<view class="c-t">消费记录</view>
<view class="muted c-s"> 7 / 30 </view>
</view>
<view class="arr"></view>
</view>
<view class="cell row between" @tap="goSkin">
<view class="c-l">
<view class="c-t">我的肤质档案</view>
<view class="muted c-s">肤质 / 需求 / 建议</view>
</view>
<view class="arr"></view>
</view>
<view class="cell row between" @tap="goMsg">
<view class="c-l">
<view class="c-t">消息提醒</view>
<view class="muted c-s">预约成功 / 到店提醒</view>
</view>
<view class="arr"></view>
</view>
<view class="cell row between" @tap="goStore">
<view class="c-l">
<view class="c-t">门店设置</view>
<view class="muted c-s">地址 / 电话 / 技师团队</view>
</view>
<view class="arr"></view>
</view>
</view>
<view class="card block">
<view class="title">定位与附近流量</view>
<view class="muted line">用于附近门店匹配与推荐展示</view>
<view class="btn btn-ghost loc" @tap="getLoc">获取定位</view>
<view class="muted line" v-if="locText">{{ locText }}</view>
</view>
<view class="card foot">
<view class="row between">
<view class="muted">版本 {{ version }}</view>
<view class="kefu" @tap="call">联系客服</view>
</view>
</view>
<AiFloat />
</view>
</template>
<script>
import AiFloat from '@/components/AiFloat.vue'
import { storeProfile } from '@/common/mockData'
export default {
components: { AiFloat },
data() {
return {
avatarUrl:
'https://coresg-normal.trae.ai/api/ide/v1/text_to_image?prompt=photorealistic%20portrait%20avatar%2C%20friendly%20woman%2C%20clean%20background%2C%20soft%20studio%20lighting%2C%20high-end%20beauty%20brand%20style%2C%2035mm%2C%20ultra%20detail&image_size=square',
points: 1260,
balance: 200,
visitCount: 12,
tag: '敏感肌 · 高复购',
locText: '',
version: '1.0.0',
store: storeProfile
}
},
methods: {
goAppointments() {
uni.navigateTo({ url: '/pages/appointments/list' })
},
goCoupons() {
uni.navigateTo({ url: '/pages/coupons/list' })
},
goRecords() {
uni.navigateTo({ url: '/pages/records/list' })
},
goSkin() {
uni.navigateTo({ url: '/pages/profile/skin' })
},
goMsg() {
uni.navigateTo({ url: '/pages/messages/settings' })
},
goStore() {
uni.navigateTo({ url: '/pages/store/detail' })
},
getLoc() {
uni.getLocation({
type: 'gcj02',
success: (res) => {
this.locText = `已获取:${res.latitude.toFixed(6)}, ${res.longitude.toFixed(6)}`
},
fail: () => {
uni.showToast({ title: '未授权定位', icon: 'none' })
}
})
},
call() {
uni.makePhoneCall({ phoneNumber: this.store.phone })
}
}
}
</script>
<style lang="scss" scoped>
.profile {
padding: 26rpx;
}
.avatar {
width: 88rpx;
height: 88rpx;
border-radius: 999rpx;
background: rgba(17, 24, 39, 0.06);
border: 1rpx solid rgba(17, 24, 39, 0.08);
}
.uinfo {
margin-left: 18rpx;
}
.u {
font-size: 38rpx;
font-weight: 950;
}
.s {
margin-top: 10rpx;
font-size: 26rpx;
}
.badge {
padding: 12rpx 16rpx;
border-radius: 999rpx;
background: rgba(59, 130, 246, 0.14);
color: #1d4ed8;
font-size: 24rpx;
font-weight: 950;
}
.stats {
margin-top: 22rpx;
padding: 18rpx;
border-radius: 18rpx;
background: rgba(17, 24, 39, 0.04);
}
.st {
flex: 1;
}
.v {
font-size: 32rpx;
font-weight: 950;
}
.l {
margin-top: 6rpx;
font-size: 24rpx;
}
.sep {
width: 1rpx;
height: 48rpx;
background: rgba(17, 24, 39, 0.08);
}
.menu {
margin-top: 18rpx;
padding: 10rpx 22rpx;
}
.cell {
padding: 18rpx 0;
border-top: 1rpx solid rgba(17, 24, 39, 0.08);
}
.cell:first-of-type {
border-top: 0;
}
.c-t {
font-size: 30rpx;
font-weight: 950;
}
.c-s {
margin-top: 8rpx;
font-size: 24rpx;
}
.arr {
font-size: 40rpx;
color: rgba(17, 24, 39, 0.4);
margin-left: 16rpx;
}
.block {
padding: 22rpx;
margin-top: 18rpx;
}
.title {
font-weight: 950;
font-size: 30rpx;
margin-bottom: 10rpx;
}
.line {
padding: 8rpx 0;
font-size: 26rpx;
}
.loc {
margin-top: 10rpx;
}
.foot {
margin-top: 18rpx;
padding: 18rpx 22rpx;
}
.kefu {
padding: 12rpx 16rpx;
border-radius: 999rpx;
background: rgba(17, 24, 39, 0.06);
font-size: 26rpx;
font-weight: 900;
}
</style>
+79
View File
@@ -0,0 +1,79 @@
<template>
<view class="container">
<view class="card block">
<view class="t">消息提醒</view>
<view class="muted p">用于接收预约成功到店提醒过期提醒等通知</view>
<view class="row between line">
<view>预约成功提醒</view>
<switch :checked="on1" @change="toggle1" color="#3b82f6" />
</view>
<view class="row between line">
<view>到店提醒</view>
<switch :checked="on2" @change="toggle2" color="#3b82f6" />
</view>
<view class="row between line">
<view>卡券过期提醒</view>
<switch :checked="on3" @change="toggle3" color="#3b82f6" />
</view>
</view>
<view class="card block">
<view class="t">订阅消息</view>
<view class="muted p">商用版可在此引导用户订阅微信消息模板本原型仅展示</view>
<view class="btn btn-primary" @tap="mockSub">一键订阅演示</view>
</view>
<AiFloat />
</view>
</template>
<script>
import AiFloat from '@/components/AiFloat.vue'
export default {
components: { AiFloat },
data() {
return {
on1: true,
on2: true,
on3: true
}
},
methods: {
toggle1(e) {
this.on1 = !!e.detail.value
},
toggle2(e) {
this.on2 = !!e.detail.value
},
toggle3(e) {
this.on3 = !!e.detail.value
},
mockSub() {
uni.showToast({ title: '已订阅(演示)', icon: 'none' })
}
}
}
</script>
<style lang="scss" scoped>
.block {
padding: 22rpx;
margin-bottom: 18rpx;
}
.t {
font-size: 32rpx;
font-weight: 950;
}
.p {
margin-top: 14rpx;
font-size: 26rpx;
line-height: 1.7;
}
.line {
padding: 18rpx 0;
border-top: 1rpx solid rgba(17, 24, 39, 0.08);
}
.line:first-of-type {
border-top: 0;
}
</style>
+339
View File
@@ -0,0 +1,339 @@
<template>
<view class="container">
<view class="card block">
<view class="row between">
<view class="name">{{ vm.project.name }}</view>
<view class="price">¥{{ vm.amount }}</view>
</view>
<view class="muted meta" v-if="vm.type === 'booking'">
{{ vm.date }} {{ vm.slot }} · {{ vm.techName }}
</view>
<view class="muted meta" v-else>{{ vm.planLabel }} · {{ vm.validText }}</view>
</view>
<view class="card block">
<view class="title">订单信息</view>
<view class="line row between">
<view class="muted">类型</view>
<view>{{ vm.type === 'booking' ? '预约订单' : '购买卡券' }}</view>
</view>
<view class="line row between">
<view class="muted">单价</view>
<view>¥{{ vm.amount }}</view>
</view>
<view class="line row between">
<view class="muted">数量</view>
<view>1</view>
</view>
<view class="line row between">
<view class="muted">合计</view>
<view class="sum">¥{{ vm.amount }}</view>
</view>
<view class="line row between" v-if="vm.note">
<view class="muted">备注</view>
<view class="note">{{ vm.note }}</view>
</view>
</view>
<view class="card block">
<view class="title">规则摘要</view>
<view class="muted rline" v-if="vm.type === 'booking'">改约可在我的预约中发起改约原型演示</view>
<view class="muted rline" v-if="vm.type === 'booking'">取消支持取消预约取消后订单状态变为已取消</view>
<view class="muted rline" v-if="vm.type === 'booking'">爽约商用版可按门店策略收取爽约金此处占位</view>
<view class="muted rline" v-if="vm.type !== 'booking'">有效期以卡券类型展示为准过期后不可核销原型演示</view>
<view class="muted rline" v-if="vm.type !== 'booking'">核销到店出示核销码核销后自动扣次</view>
</view>
<view class="card block" v-if="vm.type !== 'booking'">
<view class="title">订单类型</view>
<view class="plans">
<view
v-for="p in plans"
:key="p.key"
class="plan"
:class="{ on: p.key === vm.planKey }"
@tap="selectPlan(p.key)"
>
<view class="p1">{{ p.label }}</view>
<view class="muted p2">{{ p.validText }}</view>
</view>
</view>
<view class="muted hint">购买后可在我的卡券/次卡里查看预约后到店核销</view>
</view>
<view class="card block">
<view class="title">支付方式</view>
<view class="pay row between">
<view class="row">
<view class="p-icon">W</view>
<view class="p-text">微信支付原型模拟</view>
</view>
<view class="tag">默认</view>
</view>
</view>
<view class="btn btn-primary submit" @tap="mockPay">去支付</view>
<view class="btn btn-ghost submit2" @tap="saveUnpaid">先保存为待付款</view>
<AiFloat />
</view>
</template>
<script>
import AiFloat from '@/components/AiFloat.vue'
import { projects } from '@/common/mockData'
const fallbackProjects = [
{
id: 'p1',
categoryId: 'c1',
name: '水氧净透体验',
price: 99,
originPrice: 199,
durationMin: 60,
fitFor: '初次体验、暗沉、出油',
taboo: '近期激光/微针术后 7 天内不建议',
cover: '',
desc: '轻盈水氧 + 净透护理,适合快速提升肤感与通透度。'
}
]
function safeJsonParse(s) {
try {
return JSON.parse(s)
} catch (e) {
return null
}
}
export default {
components: { AiFloat },
data() {
const safeProjects = Array.isArray(projects) && projects.length ? projects : fallbackProjects
const initProject = safeProjects[0]
const initPlan = { key: 'single', label: '单次券', validText: '有效期 30 天', times: 1, mul: 1 }
return {
safeProjects,
vm: {
type: 'coupon',
project: initProject,
note: '',
planKey: initPlan.key,
planLabel: initPlan.label,
validText: initPlan.validText,
times: initPlan.times,
amount: initProject ? Math.round(initProject.price * initPlan.mul) : 99
},
plans: [
{ key: 'single', label: '单次券', validText: '有效期 30 天', times: 1, mul: 1 },
{ key: 'times5', label: '次卡 5 次', validText: '有效期 180 天', times: 5, mul: 4.2 },
{ key: 'package', label: '套餐', validText: '有效期 90 天', times: 3, mul: 2.6 }
]
}
},
onLoad(query) {
try {
const payload = query.payload ? safeJsonParse(decodeURIComponent(query.payload)) : null
const safeProjects = Array.isArray(this.safeProjects) && this.safeProjects.length ? this.safeProjects : fallbackProjects
if (payload && payload.type === 'booking') {
const p = safeProjects.find((x) => x.id === payload.projectId) || safeProjects[0]
const amount = p ? p.price : 99
this.vm = {
type: 'booking',
project: p || fallbackProjects[0],
amount,
planKey: '',
planLabel: '预约订单',
validText: '',
times: 0,
date: payload.date || '',
slot: payload.slot || '',
techId: payload.techId || '',
techName: payload.techName || '自动分配',
note: payload.note || ''
}
return
}
const projectId = query.projectId || ''
const p = safeProjects.find((x) => x.id === projectId) || safeProjects.find((x) => x.categoryId === 'c5') || safeProjects[0]
const basePlanKey = p && p.categoryId === 'c5' ? 'times5' : 'single'
const plan = this.plans.find((x) => x.key === basePlanKey) || this.plans[0]
const price = p ? p.price : 99
const amount = Math.round(price * plan.mul)
this.vm = {
type: 'coupon',
project: p || fallbackProjects[0],
note: '',
planKey: plan.key,
planLabel: plan.label,
validText: plan.validText,
times: plan.times,
amount
}
} catch (e) {
const p = (Array.isArray(this.safeProjects) && this.safeProjects[0]) || fallbackProjects[0]
const plan = this.plans[0]
this.vm = {
type: 'coupon',
project: p,
note: '',
planKey: plan.key,
planLabel: plan.label,
validText: plan.validText,
times: plan.times,
amount: Math.round((p ? p.price : 99) * plan.mul)
}
}
},
methods: {
selectPlan(key) {
const p = this.plans.find((x) => x.key === key) || this.plans[0]
const amount = Math.round(this.vm.project.price * p.mul)
this.vm = {
...this.vm,
planKey: p.key,
planLabel: p.label,
validText: p.validText,
times: p.times,
amount
}
},
buildOrder(status) {
const now = Date.now()
const id = `ord_demo_${now}`
const base = {
id,
createdAt: now,
status,
amount: this.vm.amount,
projectId: this.vm.project.id,
projectName: this.vm.project.name,
durationMin: this.vm.project.durationMin
}
if (this.vm.type === 'booking') {
return {
...base,
orderType: 'booking',
appointmentDate: this.vm.date,
appointmentSlot: this.vm.slot,
technicianName: this.vm.techName,
note: this.vm.note || '',
verifyCode: `VC${now}`
}
}
return {
...base,
orderType: 'coupon',
couponTitle: this.vm.project.name,
couponPlanKey: this.vm.planKey,
couponPlanLabel: this.vm.planLabel,
validText: this.vm.validText,
remainingTimes: this.vm.times || 1,
verifyCode: `VC${now}`
}
},
mockPay() {
const order = this.buildOrder('待核销')
const payload = encodeURIComponent(JSON.stringify(order))
uni.redirectTo({ url: `/pages/verify/code?payload=${payload}` })
},
saveUnpaid() {
const order = this.buildOrder('待付款')
const payload = encodeURIComponent(JSON.stringify(order))
uni.redirectTo({ url: `/pages/orders/detail?payload=${payload}` })
}
}
}
</script>
<style lang="scss" scoped>
.block {
padding: 22rpx;
margin-bottom: 18rpx;
}
.name {
font-size: 34rpx;
font-weight: 950;
max-width: 520rpx;
}
.price {
font-size: 32rpx;
font-weight: 950;
}
.meta {
margin-top: 10rpx;
font-size: 26rpx;
}
.title {
font-weight: 950;
font-size: 30rpx;
margin-bottom: 14rpx;
}
.line {
padding: 12rpx 0;
}
.sum {
font-weight: 950;
}
.note {
max-width: 520rpx;
text-align: right;
}
.plans {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12rpx;
}
.plan {
padding: 14rpx 12rpx;
border-radius: 18rpx;
border: 1rpx solid rgba(17, 24, 39, 0.08);
background: rgba(17, 24, 39, 0.03);
}
.on {
background: rgba(59, 130, 246, 0.12);
border-color: rgba(59, 130, 246, 0.35);
}
.p1 {
font-size: 26rpx;
font-weight: 950;
}
.p2 {
margin-top: 8rpx;
font-size: 22rpx;
}
.hint {
margin-top: 14rpx;
font-size: 24rpx;
}
.rline {
margin-top: 10rpx;
font-size: 24rpx;
}
.pay {
padding: 14rpx 0;
}
.p-icon {
width: 56rpx;
height: 56rpx;
border-radius: 16rpx;
background: rgba(16, 185, 129, 0.16);
color: #059669;
display: flex;
align-items: center;
justify-content: center;
font-weight: 900;
margin-right: 12rpx;
}
.p-text {
font-weight: 800;
}
.submit {
margin-top: 10rpx;
}
.submit2 {
margin-top: 16rpx;
}
</style>
+255
View File
@@ -0,0 +1,255 @@
<template>
<view class="container">
<view class="card head">
<view class="row between">
<view
class="st"
:class="{
w: o.status === '待付款',
g: o.status === '已完成',
d: o.status === '已取消',
p: o.status !== '待付款' && o.status !== '已完成' && o.status !== '已取消'
}"
>
{{ o.status }}
</view>
<view class="muted">订单号 {{ o.id }}</view>
</view>
<view class="name">{{ o.projectName }}</view>
<view class="muted meta">{{ typeLabel(o.orderType) }} · ¥{{ o.amount }}</view>
<view class="muted meta" v-if="o.orderType === 'booking'">
{{ o.appointmentDate }} {{ o.appointmentSlot }} · {{ o.technicianName }}
</view>
<view class="muted meta" v-else>剩余次数{{ o.remainingTimes }}</view>
</view>
<view class="card block">
<view class="title">核销码</view>
<view class="code row between">
<view class="c">{{ o.verifyCode }}</view>
<view class="tag" @tap="copy(o.verifyCode)">复制</view>
</view>
<view class="muted hint">到店出示核销码由门店扫码/输码核销此处为原型演示</view>
<view class="btn btn-ghost more" @tap="openCode">查看大码</view>
</view>
<view class="card block" v-if="o.note">
<view class="title">备注</view>
<view class="muted">{{ o.note }}</view>
</view>
<view class="actions">
<view v-if="o.status === '待付款'" class="btn btn-primary" @tap="pay">模拟支付</view>
<view v-if="o.status === '待核销'" class="btn btn-primary" @tap="verify">模拟核销</view>
<view v-if="o.orderType === 'booking' && o.status === '待核销'" class="btn btn-ghost" @tap="reschedule">改约</view>
<view v-if="o.status !== '已取消' && o.status !== '已完成'" class="btn btn-ghost" @tap="cancel">取消订单</view>
</view>
<AiFloat />
</view>
</template>
<script>
import AiFloat from '@/components/AiFloat.vue'
import { demoOrders } from '@/common/demoOrders'
function safeJsonParse(s) {
try {
return JSON.parse(s)
} catch (e) {
return null
}
}
const __DBG_URL = 'http://127.0.0.1:7777/event'
export default {
components: { AiFloat },
data() {
return {
order: null
}
},
onLoad(query) {
//#region debug-point orders-detail-load
try {
uni.request({
url: __DBG_URL,
method: 'POST',
timeout: 2000,
data: {
sessionId: 'orders-detail-blank',
runId: 'pre-fix',
hypothesisId: 'H2',
msg: 'orders/detail onLoad',
queryKeys: query ? Object.keys(query) : [],
payloadLen: query && query.payload ? String(query.payload).length : 0
}
})
} catch (e) {}
//#endregion debug-point orders-detail-load
const raw = query.payload ? safeJsonParse(decodeURIComponent(query.payload)) : null
if (raw) this.order = raw
},
computed: {
o() {
return this.order || demoOrders[0] || {}
}
},
onReady() {
//#region debug-point orders-detail-ready
try {
uni.request({
url: __DBG_URL,
method: 'POST',
timeout: 2000,
data: {
sessionId: 'orders-detail-blank',
runId: 'pre-fix',
hypothesisId: 'H2',
msg: 'orders/detail onReady',
hasOrder: !!this.order,
oKeys: this.o ? Object.keys(this.o) : []
}
})
} catch (e) {}
//#endregion debug-point orders-detail-ready
},
onError(err) {
//#region debug-point orders-detail-error
try {
uni.request({
url: __DBG_URL,
method: 'POST',
timeout: 2000,
data: {
sessionId: 'orders-detail-blank',
runId: 'pre-fix',
hypothesisId: 'H2',
msg: 'orders/detail onError',
err: String(err || '')
}
})
} catch (e) {}
//#endregion debug-point orders-detail-error
},
methods: {
typeLabel(t) {
return t === 'booking' ? '预约订单' : '购买卡券'
},
openCode() {
const payload = encodeURIComponent(JSON.stringify(this.o))
uni.navigateTo({ url: `/pages/verify/code?payload=${payload}` })
},
copy(text) {
uni.setClipboardData({ data: text })
},
pay() {
this.order = { ...this.o, status: '待核销', paidAt: Date.now() }
uni.showToast({ title: '支付成功(模拟)', icon: 'none' })
setTimeout(() => {
const payload = encodeURIComponent(JSON.stringify(this.o))
uni.navigateTo({ url: `/pages/verify/code?payload=${payload}` })
}, 200)
},
verify() {
if (this.o.orderType === 'coupon') {
const left = Math.max(0, (this.o.remainingTimes || 0) - 1)
this.order = { ...this.o, remainingTimes: left, status: left === 0 ? '已完成' : '待核销' }
uni.showToast({ title: left === 0 ? '已核销完成' : '核销成功,已扣次', icon: 'none' })
return
}
this.order = { ...this.o, status: '已完成', verifiedAt: Date.now() }
uni.showToast({ title: '已核销完成(模拟)', icon: 'none' })
},
reschedule() {
uni.navigateTo({ url: `/pages/booking/create?projectId=${this.o.projectId}` })
},
cancel() {
uni.showModal({
title: '确认取消',
content: '原型演示:取消后订单状态将变为已取消。',
success: (res) => {
if (!res.confirm) return
this.order = { ...this.o, status: '已取消', canceledAt: Date.now() }
}
})
},
goMember() {
uni.switchTab({ url: '/pages/member/index' })
}
}
}
</script>
<style lang="scss" scoped>
.head {
padding: 24rpx;
}
.st {
padding: 10rpx 14rpx;
border-radius: 999rpx;
font-size: 24rpx;
font-weight: 900;
}
.w {
background: rgba(245, 158, 11, 0.16);
color: #b45309;
}
.p {
background: rgba(59, 130, 246, 0.16);
color: #1d4ed8;
}
.g {
background: rgba(16, 185, 129, 0.16);
color: #047857;
}
.d {
background: rgba(239, 68, 68, 0.12);
color: #b91c1c;
}
.name {
margin-top: 14rpx;
font-size: 38rpx;
font-weight: 950;
}
.meta {
margin-top: 10rpx;
font-size: 26rpx;
}
.block {
padding: 22rpx;
margin-top: 18rpx;
}
.title {
font-weight: 950;
font-size: 30rpx;
margin-bottom: 14rpx;
}
.code {
padding: 16rpx;
border-radius: 18rpx;
background: rgba(17, 24, 39, 0.04);
border: 1rpx dashed rgba(17, 24, 39, 0.18);
}
.c {
font-size: 36rpx;
font-weight: 900;
letter-spacing: 1rpx;
}
.hint {
margin-top: 12rpx;
font-size: 24rpx;
}
.more {
margin-top: 16rpx;
}
.actions {
margin-top: 24rpx;
display: flex;
flex-direction: column;
gap: 16rpx;
}
</style>
+176
View File
@@ -0,0 +1,176 @@
<template>
<view class="container">
<view class="card filters">
<scroll-view class="sc" scroll-x>
<view class="row">
<view
v-for="s in statuses"
:key="s.value"
class="chip"
:class="{ on: s.value === activeStatus }"
@tap="activeStatus = s.value"
>
{{ s.label }}
</view>
</view>
</scroll-view>
</view>
<view v-if="list.length === 0" class="card empty">
<view class="e1">暂无订单</view>
<view class="e2 muted">从项目页预约或购买后会在这里展示</view>
<view class="btn btn-primary ebtn" @tap="goProjects">去看看项目</view>
</view>
<view v-else class="list">
<view v-for="o in list" :key="o.id" class="card item" @tap="open(o.id)">
<view class="row between">
<view class="n">{{ o.projectName }}</view>
<view
class="st"
:class="{
w: o.status === '待付款',
g: o.status === '已完成',
d: o.status === '已取消',
p: o.status !== '待付款' && o.status !== '已完成' && o.status !== '已取消'
}"
>
{{ o.status }}
</view>
</view>
<view class="row between meta">
<view class="muted">{{ typeLabel(o.orderType) }}</view>
<view class="amt">¥{{ o.amount }}</view>
</view>
<view class="muted meta2" v-if="o.orderType === 'booking'">
{{ o.appointmentDate }} {{ o.appointmentSlot }} · {{ o.technicianName }}
</view>
<view class="muted meta2" v-else>核销码{{ o.verifyCode }}</view>
</view>
</view>
</view>
</template>
<script>
import { demoOrders } from '@/common/demoOrders'
export default {
data() {
return {
statuses: [
{ value: 'all', label: '全部' },
{ value: '待付款', label: '待付款' },
{ value: '待核销', label: '待核销' },
{ value: '已完成', label: '已完成' },
{ value: '已取消', label: '已取消' }
],
activeStatus: 'all',
orders: []
}
},
computed: {
list() {
if (this.activeStatus === 'all') return this.orders
return this.orders.filter((x) => x.status === this.activeStatus)
}
},
onShow() {
this.orders = demoOrders
},
methods: {
open(id) {
const o = this.orders.find((x) => x.id === id) || this.orders[0]
const payload = encodeURIComponent(JSON.stringify(o))
uni.navigateTo({ url: `/pages/orders/detail?payload=${payload}` })
},
goProjects() {
uni.switchTab({ url: '/pages/projects/list' })
},
typeLabel(t) {
return t === 'booking' ? '预约订单' : '购买卡券'
}
}
}
</script>
<style lang="scss" scoped>
.filters {
padding: 16rpx;
}
.sc {
white-space: nowrap;
}
.chip {
padding: 14rpx 18rpx;
margin-right: 12rpx;
border-radius: 999rpx;
font-size: 26rpx;
background: rgba(17, 24, 39, 0.06);
color: #111827;
flex: 0 0 auto;
}
.on {
background: rgba(59, 130, 246, 0.14);
color: #1d4ed8;
}
.empty {
margin-top: 18rpx;
padding: 26rpx;
}
.e1 {
font-size: 36rpx;
font-weight: 900;
}
.e2 {
margin-top: 10rpx;
font-size: 26rpx;
}
.ebtn {
margin-top: 18rpx;
}
.list {
margin-top: 18rpx;
}
.item {
padding: 22rpx;
margin-bottom: 18rpx;
}
.n {
font-size: 32rpx;
font-weight: 900;
max-width: 520rpx;
}
.st {
padding: 10rpx 14rpx;
border-radius: 999rpx;
font-size: 24rpx;
font-weight: 800;
}
.w {
background: rgba(245, 158, 11, 0.16);
color: #b45309;
}
.p {
background: rgba(59, 130, 246, 0.16);
color: #1d4ed8;
}
.g {
background: rgba(16, 185, 129, 0.16);
color: #047857;
}
.d {
background: rgba(239, 68, 68, 0.12);
color: #b91c1c;
}
.meta {
margin-top: 10rpx;
}
.amt {
font-weight: 900;
}
.meta2 {
margin-top: 10rpx;
font-size: 26rpx;
}
</style>
+42
View File
@@ -0,0 +1,42 @@
<template>
<view class="container">
<view class="card block">
<view class="t">我的肤质档案</view>
<view class="muted p">肤质敏感偏干</view>
<view class="muted p">关注补水修护减少泛红</view>
<view class="muted p">过敏史示例</view>
<view class="muted p">备注本页为原型演示商用版可由门店持续更新</view>
</view>
<view class="card block">
<view class="t">建议</view>
<view class="muted p">优先选择补水修护舒缓敏感类项目</view>
<view class="muted p">避免爆痘炎症期的强刺激清洁项目</view>
</view>
<AiFloat />
</view>
</template>
<script>
import AiFloat from '@/components/AiFloat.vue'
export default {
components: { AiFloat }
}
</script>
<style lang="scss" scoped>
.block {
padding: 22rpx;
margin-bottom: 18rpx;
}
.t {
font-size: 32rpx;
font-weight: 950;
}
.p {
margin-top: 14rpx;
font-size: 26rpx;
line-height: 1.7;
}
</style>
+428
View File
@@ -0,0 +1,428 @@
<template>
<view class="container">
<view>
<view class="card head">
<image v-if="p.cover" class="cover" :src="p.cover" mode="aspectFill" />
<view class="name">{{ p.name }}</view>
<view class="row between meta">
<view class="muted">服务时长{{ p.durationMin }} 分钟</view>
<view class="price">
<text class="yen">¥</text>
<text class="num">{{ p.price }}</text>
<text class="ori muted" v-if="p.originPrice">¥{{ p.originPrice }}</text>
</view>
</view>
<view class="row between meta2">
<view class="tag2">{{ categoryName }}</view>
<view class="muted">评分 {{ rating }} / 5.0</view>
</view>
<view class="tags row">
<view class="tag">{{ p.fitFor }}</view>
<view class="tag warn">禁忌{{ p.taboo }}</view>
</view>
</view>
<view class="card block">
<view class="b-t">服务介绍</view>
<view class="b-v muted">{{ p.desc }}</view>
</view>
<view class="card block">
<view class="b-t">功效说明</view>
<view class="b-v muted" v-for="x in effects" :key="x">· {{ x }}</view>
</view>
<view class="card block">
<view class="b-t">服务流程</view>
<view class="b-v muted" v-for="x in steps" :key="x">· {{ x }}</view>
</view>
<view class="card block">
<view class="b-t">适合人群</view>
<view class="b-v muted">{{ p.fitFor }}</view>
</view>
<view class="card block">
<view class="b-t">禁忌说明</view>
<view class="b-v muted">{{ p.taboo }}</view>
</view>
<view class="card block">
<view class="b-t">用户评价</view>
<view class="review">
<view class="r1 row between">
<view class="rn">顾客 A</view>
<view class="muted">5.0</view>
</view>
<view class="muted rt">做完肤感很通透过程舒服推荐</view>
</view>
<view class="review">
<view class="r1 row between">
<view class="rn">顾客 B</view>
<view class="muted">4.8</view>
</view>
<view class="muted rt">店里环境很干净技师很专业</view>
</view>
</view>
<view class="space"></view>
<view class="fixbar">
<view class="bar card">
<view class="btn btn-ghost a" @tap="book">立即预约</view>
<view class="btn btn-primary b" @tap="buy">立即购买</view>
</view>
</view>
</view>
<AiFloat />
</view>
</template>
<script>
import AiFloat from '@/components/AiFloat.vue'
import { categories, projects } from '@/common/mockData'
const fallbackCategories = [
{ id: 'c1', name: '体验爆款' },
{ id: 'c2', name: '面部护理' },
{ id: 'c3', name: '身体养生' },
{ id: 'c4', name: '美甲美睫' },
{ id: 'c5', name: '特惠套餐' }
]
const fallbackProjects = [
{
id: 'p1',
categoryId: 'c1',
name: '水氧净透体验',
price: 99,
originPrice: 199,
durationMin: 60,
fitFor: '初次体验、暗沉、出油',
taboo: '近期激光/微针术后 7 天内不建议',
cover: '',
desc: '轻盈水氧 + 净透护理,适合快速提升肤感与通透度。'
},
{
id: 'p2',
categoryId: 'c2',
name: '深层清洁黑头管理',
price: 168,
originPrice: 268,
durationMin: 75,
fitFor: 'T 区油脂旺盛、黑头粉刺',
taboo: '炎症爆痘期需评估后进行',
cover: '',
desc: '清洁、舒缓、收敛三步走,减少反复出油与毛孔困扰。'
},
{
id: 'p3',
categoryId: 'c2',
name: '补水修护屏障护理',
price: 238,
originPrice: 368,
durationMin: 80,
fitFor: '敏感泛红、干燥紧绷',
taboo: '过敏急性期请先咨询',
cover: '',
desc: '修护屏障与舒缓敏感,适合换季与长期干燥人群。'
},
{
id: 'p4',
categoryId: 'c3',
name: '肩颈舒缓筋膜放松',
price: 188,
originPrice: 288,
durationMin: 60,
fitFor: '久坐办公、肩颈僵硬',
taboo: '急性损伤与发热期不建议',
cover: '',
desc: '深度放松肌群与筋膜,改善紧绷与酸胀。'
},
{
id: 'p5',
categoryId: 'c5',
name: '皮肤管理次卡 5 次',
price: 899,
originPrice: 1199,
durationMin: 60,
fitFor: '长期管理、复购人群',
taboo: '具体项目以到店评估为准',
cover: '',
desc: '灵活使用,随时预约,到店核销自动扣次。'
},
{
id: 'p6',
categoryId: 'c4',
name: '轻奢美甲 · 单色',
price: 168,
originPrice: 238,
durationMin: 75,
fitFor: '通勤、日常、显白',
taboo: '甲面破损/感染需先处理',
cover: '',
desc: '干净利落的通勤单色,显白耐看,可按肤色搭配色卡。'
},
{
id: 'p7',
categoryId: 'c4',
name: '自然单根美睫 · 清透款',
price: 198,
originPrice: 298,
durationMin: 90,
fitFor: '自然放大双眼、日常耐看',
taboo: '眼部炎症/过敏期不建议',
cover: '',
desc: '清透自然的单根嫁接,整体更轻盈,适合新手与通勤。'
},
{
id: 'p8',
categoryId: 'c3',
name: '全身精油舒压 · 90 分钟',
price: 298,
originPrice: 398,
durationMin: 90,
fitFor: '压力大、睡眠欠佳、疲劳',
taboo: '孕期/发热/急性炎症期不建议',
cover: '',
desc: '精油舒压与深度放松结合,帮助缓解疲劳与紧绷。'
},
{
id: 'p9',
categoryId: 'c5',
name: '新客体验套餐 · 3 次',
price: 299,
originPrice: 499,
durationMin: 60,
fitFor: '初次体验、想要快速改善肤感',
taboo: '具体项目以到店评估为准',
cover: '',
desc: '高性价比新客套餐,适合建立基础皮肤管理节奏。'
}
]
const runtimeProjects = Array.isArray(projects) && projects.length ? projects : fallbackProjects
const runtimeCategories = Array.isArray(categories) && categories.length ? categories : fallbackCategories
const detailPreset = {
p1: {
rating: 5.0,
effects: ['净透提亮肤感', '补水保湿', '舒缓修护'],
steps: ['洁面清洁', '水氧净透护理', '精华导入', '舒缓收尾与防护']
},
p2: {
rating: 4.8,
effects: ['减少黑头粉刺困扰', '清理油脂与角质', '舒缓收敛毛孔观感'],
steps: ['皮肤评估与卸妆', '深层清洁与导出', '舒缓镇定', '收尾修护']
},
p3: {
rating: 4.9,
effects: ['补水修护屏障', '舒缓敏感泛红', '改善干燥紧绷'],
steps: ['温和清洁', '舒缓修护导入', '补水面膜', '收尾锁水防护']
},
p4: {
rating: 4.8,
effects: ['放松肩颈肌群', '改善紧绷酸胀', '提升舒适度与精神状态'],
steps: ['热敷放松', '筋膜松解', '肩颈重点放松', '收尾舒缓']
},
p5: {
rating: 4.9,
effects: ['长期皮肤管理更划算', '支持随时预约使用', '到店核销自动扣次'],
steps: ['购买次卡', '在线预约', '到店出示核销码', '门店核销扣次']
}
}
export default {
components: { AiFloat },
data() {
return {
id: '',
project: runtimeProjects[0] || null,
rating: 4.9,
effects: ['效果以到店评估为准', '体验舒适、过程规范', '可按肤质做个性化调整'],
steps: ['到店评估', '护理服务', '舒缓收尾', '给出居家建议'],
categoryName: runtimeCategories[0]?.name || '项目'
}
},
computed: {
p() {
return (
this.project ||
runtimeProjects[0] ||
fallbackProjects[0] || {
id: 'p1',
categoryId: 'c1',
name: '水氧净透体验',
price: 99,
originPrice: 199,
durationMin: 60,
fitFor: '初次体验、暗沉、出油',
taboo: '近期激光/微针术后 7 天内不建议',
cover: '',
desc: '轻盈水氧 + 净透护理,适合快速提升肤感与通透度。'
}
)
},
displayProject() {
return this.project || runtimeProjects[0] || null
}
},
onLoad(query) {
this.id = query.id || query.projectId || ''
if (!this.id && runtimeProjects.length) this.id = runtimeProjects[0].id
this.project = runtimeProjects.find((x) => x.id === this.id) || runtimeProjects[0] || null
const cat = this.p ? runtimeCategories.find((c) => c.id === this.p.categoryId) : null
this.categoryName = cat ? cat.name : '项目'
const p = detailPreset[this.id] || null
this.rating = p ? p.rating : 4.9
this.effects = p ? p.effects : ['效果以到店评估为准', '体验舒适、过程规范', '可按肤质做个性化调整']
this.steps = p ? p.steps : ['到店评估', '护理服务', '舒缓收尾', '给出居家建议']
},
methods: {
book() {
uni.navigateTo({ url: `/pages/booking/create?projectId=${this.p.id}` })
},
buy() {
uni.navigateTo({ url: `/pages/order/confirm?type=coupon&projectId=${this.p.id}` })
},
goList() {
uni.switchTab({ url: '/pages/projects/list' })
}
}
}
</script>
<style lang="scss" scoped>
.head {
padding: 26rpx;
}
.cover {
width: 100%;
height: 320rpx;
border-radius: 18rpx;
margin-bottom: 18rpx;
}
.name {
font-size: 42rpx;
font-weight: 950;
}
.meta {
margin-top: 14rpx;
}
.meta2 {
margin-top: 12rpx;
}
.price {
display: flex;
align-items: baseline;
}
.yen {
font-size: 24rpx;
opacity: 0.8;
}
.num {
font-size: 46rpx;
font-weight: 950;
}
.ori {
margin-left: 10rpx;
font-size: 24rpx;
text-decoration: line-through;
}
.tag2 {
padding: 10rpx 14rpx;
border-radius: 999rpx;
font-size: 24rpx;
background: rgba(17, 24, 39, 0.06);
font-weight: 900;
}
.tags {
margin-top: 18rpx;
gap: 12rpx;
flex-wrap: wrap;
}
.tag {
padding: 10rpx 14rpx;
border-radius: 999rpx;
font-size: 24rpx;
background: rgba(17, 24, 39, 0.06);
color: #111827;
}
.warn {
background: rgba(245, 158, 11, 0.14);
color: #b45309;
}
.block {
margin-top: 18rpx;
padding: 22rpx;
}
.b-t {
font-weight: 950;
font-size: 30rpx;
}
.b-v {
margin-top: 12rpx;
font-size: 26rpx;
}
.review {
margin-top: 16rpx;
padding-top: 16rpx;
border-top: 1rpx solid rgba(17, 24, 39, 0.08);
}
.review:first-of-type {
border-top: 0;
padding-top: 0;
}
.r1 {
margin-bottom: 8rpx;
}
.rn {
font-size: 28rpx;
font-weight: 900;
}
.rt {
font-size: 26rpx;
}
.space {
height: 150rpx;
}
.empty {
margin-top: 18rpx;
padding: 34rpx 26rpx;
text-align: center;
}
.en {
font-size: 36rpx;
font-weight: 950;
}
.et {
margin-top: 12rpx;
font-size: 26rpx;
}
.eb {
margin-top: 20rpx;
}
.fixbar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 18rpx 24rpx 24rpx;
background: linear-gradient(180deg, rgba(246, 247, 251, 0) 0%, rgba(246, 247, 251, 1) 46%);
}
.bar {
padding: 18rpx;
display: flex;
gap: 14rpx;
}
.a {
flex: 1;
height: 82rpx;
}
.b {
flex: 1;
height: 82rpx;
}
</style>
+111
View File
@@ -0,0 +1,111 @@
<template>
<view class="container">
<view class="card search">
<view class="row between">
<input class="ipt" v-model="kw" placeholder="搜索项目:补水/清洁/肩颈…" confirm-type="search" />
<view class="sbtn" @tap="kw = ''">清空</view>
</view>
</view>
<view class="card filters">
<scroll-view class="sc" scroll-x>
<view class="row">
<view
v-for="c in allCategories"
:key="c.id"
class="chip"
:class="{ on: c.id === activeCategoryId }"
@tap="setCategory(c.id)"
>
{{ c.name }}
</view>
</view>
</scroll-view>
</view>
<view class="list">
<ProjectCard v-for="p in filteredProjects" :key="p.id" :project="p" class="mb" />
</view>
<AiFloat />
</view>
</template>
<script>
import AiFloat from '@/components/AiFloat.vue'
import ProjectCard from '@/components/ProjectCard.vue'
import { categories, projects } from '@/common/mockData'
export default {
components: { ProjectCard, AiFloat },
data() {
return {
allCategories: [{ id: 'all', name: '全部' }, ...categories],
activeCategoryId: 'all',
kw: ''
}
},
computed: {
filteredProjects() {
const kw = (this.kw || '').trim()
const list = this.activeCategoryId === 'all' ? projects : projects.filter((p) => p.categoryId === this.activeCategoryId)
if (!kw) return list
return list.filter((p) => `${p.name}${p.fitFor}${p.desc}`.includes(kw))
}
},
methods: {
setCategory(id) {
this.activeCategoryId = id
}
}
}
</script>
<style lang="scss" scoped>
.search {
padding: 16rpx;
}
.ipt {
flex: 1;
height: 76rpx;
padding: 0 16rpx;
border-radius: 18rpx;
background: rgba(17, 24, 39, 0.05);
font-size: 28rpx;
}
.sbtn {
margin-left: 12rpx;
padding: 18rpx 14rpx;
border-radius: 18rpx;
background: rgba(17, 24, 39, 0.06);
font-size: 26rpx;
font-weight: 800;
}
.filters {
margin-top: 16rpx;
padding: 16rpx;
}
.sc {
white-space: nowrap;
}
.chip {
padding: 14rpx 18rpx;
margin-right: 12rpx;
border-radius: 999rpx;
font-size: 26rpx;
background: rgba(17, 24, 39, 0.06);
color: #111827;
flex: 0 0 auto;
}
.on {
background: rgba(59, 130, 246, 0.14);
color: #1d4ed8;
}
.list {
margin-top: 18rpx;
}
.mb {
margin-bottom: 18rpx;
}
</style>
+286
View File
@@ -0,0 +1,286 @@
<template>
<view class="container">
<view class="card filters">
<view class="row between">
<view class="title">消费记录</view>
<view class="row">
<view class="chip2" :class="{ on: range === 7 }" @tap="setRange(7)">近7天</view>
<view class="chip2" :class="{ on: range === 30 }" @tap="setRange(30)">近30天</view>
<view class="chip2" :class="{ on: range === 0 }" @tap="setRange(0)">全部</view>
</view>
</view>
</view>
<view v-if="!viewList || !viewList.length" class="card empty">
<view class="btn btn-primary ebtn" @tap="goProjects">去看看项目</view>
</view>
<view v-else class="list">
<view v-for="o in viewList" :key="o.id" class="card item" @tap="open(o.id)">
<view class="row between">
<view class="n">{{ o.projectName }}</view>
<view
class="st"
:class="{
w: o.status === '待付款',
g: o.status === '已完成',
d: o.status === '已取消',
p: o.status !== '待付款' && o.status !== '已完成' && o.status !== '已取消'
}"
>
{{ o.status }}
</view>
</view>
<view class="row between meta">
<view class="muted">{{ o.orderType === 'booking' ? '预约' : '购买' }}</view>
<view class="amt">¥{{ o.amount }}</view>
</view>
<view class="muted meta2">{{ fmt(o.createdAt) }}</view>
</view>
</view>
<AiFloat />
</view>
</template>
<script>
import AiFloat from '@/components/AiFloat.vue'
import { demoOrders } from '@/common/demoOrders'
function tsToText(ts) {
const d = new Date(ts)
const y = d.getFullYear()
const m = `${d.getMonth() + 1}`.padStart(2, '0')
const dd = `${d.getDate()}`.padStart(2, '0')
const hh = `${d.getHours()}`.padStart(2, '0')
const mm = `${d.getMinutes()}`.padStart(2, '0')
return `${y}-${m}-${dd} ${hh}:${mm}`
}
function buildOrders() {
const now = Date.now()
const extra = [
{
id: 'rc_demo_001',
createdAt: now - 2 * 60 * 60 * 1000,
status: '已完成',
amount: 99,
projectId: 'p1',
projectName: '水氧净透体验',
durationMin: 60,
orderType: 'booking',
appointmentDate: '2026-06-21',
appointmentSlot: '12:30',
technicianName: '系统分配',
note: '',
verifyCode: 'VCRC20260621001'
},
{
id: 'rc_demo_002',
createdAt: now - 20 * 60 * 60 * 1000,
status: '已完成',
amount: 168,
projectId: 'p2',
projectName: '深层清洁黑头管理',
durationMin: 75,
orderType: 'coupon',
couponTitle: '深层清洁黑头管理',
couponPlanKey: 'single',
couponPlanLabel: '单次券',
validText: '有效期 30 天',
remainingTimes: 0,
verifyCode: 'VCRC20260621002'
},
{
id: 'rc_demo_003',
createdAt: now - 5 * 24 * 60 * 60 * 1000,
status: '已取消',
amount: 238,
projectId: 'p3',
projectName: '补水修护屏障护理',
durationMin: 80,
orderType: 'booking',
appointmentDate: '2026-06-18',
appointmentSlot: '15:00',
technicianName: '许言',
note: '',
verifyCode: 'VCRC20260621003'
},
{
id: 'rc_demo_004',
createdAt: now - 12 * 24 * 60 * 60 * 1000,
status: '已完成',
amount: 899,
projectId: 'p5',
projectName: '皮肤管理次卡 5 次',
durationMin: 60,
orderType: 'coupon',
couponTitle: '皮肤管理次卡 5 次',
couponPlanKey: 'times5',
couponPlanLabel: '次卡 5 次',
validText: '有效期 180 天',
remainingTimes: 4,
verifyCode: 'VCRC20260621004'
},
{
id: 'rc_demo_005',
createdAt: now - 35 * 24 * 60 * 60 * 1000,
status: '已完成',
amount: 188,
projectId: 'p4',
projectName: '肩颈舒缓筋膜放松',
durationMin: 60,
orderType: 'booking',
appointmentDate: '2026-05-17',
appointmentSlot: '19:00',
technicianName: '周晴',
note: '',
verifyCode: 'VCRC20260621005'
},
{
id: 'rc_demo_006',
createdAt: now - 62 * 24 * 60 * 60 * 1000,
status: '已完成',
amount: 299,
projectId: 'p9',
projectName: '新客体验套餐 · 3 次',
durationMin: 60,
orderType: 'coupon',
couponTitle: '新客体验套餐 · 3 次',
couponPlanKey: 'package',
couponPlanLabel: '套餐',
validText: '有效期 90 天',
remainingTimes: 2,
verifyCode: 'VCRC20260621006'
}
]
return [...demoOrders, ...extra].slice().sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0))
}
export default {
components: { AiFloat },
data() {
return {
range: 30,
orders: buildOrders(),
viewList: []
}
},
onLoad() {
this.apply()
},
onShow() {
this.orders = buildOrders()
this.apply()
},
methods: {
apply() {
if (this.range === 0) {
this.viewList = this.orders && this.orders.length ? this.orders : []
return
}
const from = Date.now() - this.range * 24 * 60 * 60 * 1000
this.viewList = this.orders.filter((x) => (x.createdAt || 0) >= from)
if (!this.viewList || !this.viewList.length) this.viewList = this.orders.slice(0, 6)
},
setRange(n) {
this.range = n
this.apply()
},
open(id) {
const o = this.orders.find((x) => x.id === id) || this.orders[0]
const payload = encodeURIComponent(JSON.stringify(o))
uni.navigateTo({ url: `/pages/orders/detail?payload=${payload}` })
},
goProjects() {
uni.switchTab({ url: '/pages/projects/list' })
},
fmt(ts) {
return ts ? tsToText(ts) : ''
}
}
}
</script>
<style lang="scss" scoped>
.filters {
padding: 18rpx;
}
.title {
font-size: 32rpx;
font-weight: 950;
}
.chip2 {
padding: 10rpx 14rpx;
margin-left: 10rpx;
border-radius: 999rpx;
font-size: 24rpx;
background: rgba(17, 24, 39, 0.06);
color: #111827;
}
.on {
background: rgba(59, 130, 246, 0.14);
color: #1d4ed8;
}
.empty {
margin-top: 18rpx;
padding: 26rpx;
}
.e1 {
font-size: 36rpx;
font-weight: 950;
}
.e2 {
margin-top: 10rpx;
font-size: 26rpx;
}
.ebtn {
margin-top: 18rpx;
}
.list {
margin-top: 18rpx;
}
.item {
padding: 22rpx;
margin-bottom: 18rpx;
}
.n {
font-size: 32rpx;
font-weight: 950;
max-width: 520rpx;
}
.st {
padding: 10rpx 14rpx;
border-radius: 999rpx;
font-size: 24rpx;
font-weight: 900;
}
.w {
background: rgba(245, 158, 11, 0.16);
color: #b45309;
}
.p {
background: rgba(59, 130, 246, 0.16);
color: #1d4ed8;
}
.g {
background: rgba(16, 185, 129, 0.16);
color: #047857;
}
.d {
background: rgba(239, 68, 68, 0.12);
color: #b91c1c;
}
.meta {
margin-top: 10rpx;
}
.amt {
font-weight: 950;
}
.meta2 {
margin-top: 10rpx;
font-size: 26rpx;
}
</style>
+135
View File
@@ -0,0 +1,135 @@
<template>
<view class="container">
<view class="card hero">
<view class="name">{{ store.name }}</view>
<view class="muted sub">{{ store.address }}</view>
<view class="row between meta">
<view class="tag">营业 {{ store.openHours }}</view>
<view class="tag" @tap="call">电话</view>
</view>
</view>
<view class="card block">
<view class="title">门店信息</view>
<view class="line row between">
<view class="muted">地址</view>
<view class="val" @tap="copy(store.address)">复制</view>
</view>
<view class="muted tip">{{ store.address }}</view>
<view class="line row between">
<view class="muted">电话</view>
<view class="val" @tap="call">{{ store.phone }}</view>
</view>
<view class="line row between">
<view class="muted">营业时间</view>
<view class="val">{{ store.openHours }}</view>
</view>
</view>
<view class="card block">
<view class="title">技师团队</view>
<view class="tech" v-for="t in techs" :key="t.id">
<view class="row between">
<view>
<view class="tname">{{ t.name }}</view>
<view class="muted tsub">{{ t.title }}</view>
</view>
<view class="tags">
<text class="tag2" v-for="g in t.tags" :key="g">{{ g }}</text>
</view>
</view>
</view>
</view>
<AiFloat />
</view>
</template>
<script>
import AiFloat from '@/components/AiFloat.vue'
import { storeProfile, technicians } from '@/common/mockData'
export default {
components: { AiFloat },
data() {
return {
store: storeProfile,
techs: technicians
}
},
methods: {
call() {
uni.makePhoneCall({ phoneNumber: this.store.phone })
},
copy(text) {
uni.setClipboardData({ data: text })
}
}
}
</script>
<style lang="scss" scoped>
.hero {
padding: 26rpx;
}
.name {
font-size: 42rpx;
font-weight: 950;
}
.sub {
margin-top: 12rpx;
font-size: 26rpx;
}
.meta {
margin-top: 18rpx;
}
.block {
margin-top: 18rpx;
padding: 22rpx;
}
.title {
font-weight: 950;
font-size: 30rpx;
margin-bottom: 14rpx;
}
.line {
padding: 12rpx 0;
}
.val {
color: #1d4ed8;
font-weight: 900;
}
.tip {
font-size: 26rpx;
line-height: 1.6;
}
.tech {
padding: 16rpx 0;
border-top: 1rpx solid rgba(17, 24, 39, 0.08);
}
.tech:first-of-type {
border-top: 0;
}
.tname {
font-size: 30rpx;
font-weight: 950;
}
.tsub {
margin-top: 6rpx;
font-size: 24rpx;
}
.tags {
display: flex;
gap: 8rpx;
flex-wrap: wrap;
justify-content: flex-end;
max-width: 280rpx;
}
.tag2 {
padding: 10rpx 14rpx;
border-radius: 999rpx;
font-size: 24rpx;
background: rgba(17, 24, 39, 0.06);
}
</style>
+208
View File
@@ -0,0 +1,208 @@
<template>
<view class="container">
<view class="card hero">
<view class="t1">支付成功</view>
<view class="t2 muted">已为你生成订单支持随时预约/到店核销</view>
<view class="code">{{ o.verifyCode }}</view>
<view class="row between info">
<view class="muted">{{ o.projectName }}</view>
<view class="amt">¥{{ o.amount }}</view>
</view>
<view class="muted info2" v-if="o.orderType === 'coupon'">
{{ o.couponPlanLabel || '卡券' }} · {{ o.validText || '有效期以到店确认' }} · 剩余次数{{ o.remainingTimes }}
</view>
<view class="muted info2" v-else>预约{{ o.appointmentDate }} {{ o.appointmentSlot }}</view>
</view>
<view class="card block">
<view class="line row between">
<view class="muted">订单状态</view>
<view class="tag">{{ o.status }}</view>
</view>
<view class="line row between">
<view class="muted">核销方式</view>
<view>门店扫码 / 手动输码</view>
</view>
<view class="line row between" v-if="o.validText && o.orderType === 'coupon'">
<view class="muted">有效期</view>
<view>{{ o.validText }}</view>
</view>
<view class="line row between" v-if="o.orderType === 'booking'">
<view class="muted">技师</view>
<view>{{ o.technicianName }}</view>
</view>
</view>
<view class="btn btn-primary" @tap="goBooking">去预约</view>
<view class="btn btn-ghost" @tap="goOrders">查看订单</view>
<view class="btn btn-ghost" @tap="goMember">返回个人中心</view>
<AiFloat />
</view>
</template>
<script>
import AiFloat from '@/components/AiFloat.vue'
import { demoOrders } from '@/common/demoOrders'
function safeJsonParse(s) {
try {
return JSON.parse(s)
} catch (e) {
return null
}
}
const __DBG_URL = 'http://127.0.0.1:7777/event'
export default {
components: { AiFloat },
data() {
return {
order: null
}
},
onLoad(query) {
//#region debug-point verify-code-load
try {
uni.request({
url: __DBG_URL,
method: 'POST',
timeout: 2000,
data: {
sessionId: 'orders-detail-blank',
runId: 'pre-fix',
hypothesisId: 'H2',
msg: 'verify/code onLoad',
queryKeys: query ? Object.keys(query) : [],
payloadLen: query && query.payload ? String(query.payload).length : 0
}
})
} catch (e) {}
//#endregion debug-point verify-code-load
const raw = query.payload ? safeJsonParse(decodeURIComponent(query.payload)) : null
if (raw) this.order = raw
},
computed: {
o() {
const defaultCoupon = Array.isArray(demoOrders) ? demoOrders.find((x) => x && x.orderType === 'coupon') : null
return (
this.order ||
defaultCoupon ||
demoOrders[0] || {
id: 'ord_demo_fallback',
status: '待核销',
amount: 99,
projectId: 'p1',
projectName: '水氧净透体验',
durationMin: 60,
orderType: 'coupon',
couponPlanLabel: '单次券',
validText: '有效期以到店确认',
remainingTimes: 1,
verifyCode: 'VC20260622000'
}
)
}
},
methods: {
goBooking() {
const o = this.o
//#region debug-point verify-code-goBooking
try {
uni.request({
url: __DBG_URL,
method: 'POST',
timeout: 2000,
data: {
sessionId: 'orders-detail-blank',
runId: 'pre-fix',
hypothesisId: 'H2',
msg: 'verify/code goBooking',
projectId: o && o.projectId ? String(o.projectId) : ''
}
})
} catch (e) {}
//#endregion debug-point verify-code-goBooking
uni.navigateTo({ url: `/pages/booking/create?projectId=${o.projectId}` })
},
goOrders() {
//#region debug-point verify-code-goOrders
try {
uni.request({
url: __DBG_URL,
method: 'POST',
timeout: 2000,
data: {
sessionId: 'orders-detail-blank',
runId: 'pre-fix',
hypothesisId: 'H2',
msg: 'verify/code goOrders',
orderId: this.o && this.o.id ? String(this.o.id) : ''
}
})
} catch (e) {}
//#endregion debug-point verify-code-goOrders
const payload = encodeURIComponent(JSON.stringify(this.o))
uni.navigateTo({ url: `/pages/orders/detail?payload=${payload}` })
},
goMember() {
uni.switchTab({ url: '/pages/member/index' })
}
}
}
</script>
<style lang="scss" scoped>
.hero {
padding: 28rpx;
background: linear-gradient(135deg, rgba(17, 24, 39, 1) 0%, rgba(59, 130, 246, 1) 100%);
border: 0;
color: #fff;
}
.t1 {
font-size: 42rpx;
font-weight: 950;
}
.t2 {
margin-top: 10rpx;
color: rgba(255, 255, 255, 0.82);
font-size: 26rpx;
}
.code {
margin-top: 22rpx;
padding: 22rpx;
border-radius: 22rpx;
background: rgba(255, 255, 255, 0.12);
border: 1rpx dashed rgba(255, 255, 255, 0.42);
font-size: 50rpx;
font-weight: 950;
letter-spacing: 2rpx;
text-align: center;
}
.info {
margin-top: 18rpx;
}
.amt {
font-weight: 950;
}
.info2 {
margin-top: 10rpx;
color: rgba(255, 255, 255, 0.82);
font-size: 26rpx;
}
.block {
margin-top: 18rpx;
padding: 22rpx;
}
.line {
padding: 14rpx 0;
}
.tag {
padding: 10rpx 14rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.14);
}
</style>