petstore-frontend/src/pages/mine/Store.vue
2026-04-17 12:13:46 +08:00

499 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="page-shell store-page">
<view class="store-nav nav-gradient" :style="navSafeStyle">
<view class="nav-back" @click="goBackToMine"><AppIcon name="back" :size="18" color="#ffffff" /></view>
<text class="nav-title">店铺设置</text>
<view class="nav-placeholder"></view>
</view>
<view class="store-body">
<view class="store-hero">
<view class="store-hero-icon" aria-hidden="true">
<AppIcon name="store" :size="22" color="#15803d" />
</view>
<view class="store-hero-text">
<text class="store-hero-title">门店资料</text>
<text class="store-hero-desc">在此维护门店名称电话地址与简介并设置营业时间营业时间决定客户线上可预约的时段范围每半小时为一个时段同一时段仅一单</text>
</view>
</view>
<view class="store-card">
<view class="store-card-head">
<text class="store-card-kicker">基础信息</text>
</view>
<view class="store-field">
<view class="store-field-label">
<span class="store-ico"><AppIcon name="store" :size="15" color="#475569" /></span>
<text>店铺名称</text>
</view>
<input v-model="form.name" class="van-field store-input" placeholder="请输入店铺名称" />
</view>
<view class="store-field">
<view class="store-field-label">
<span class="store-ico"><AppIcon name="phone" :size="15" color="#475569" /></span>
<text>联系电话</text>
</view>
<input v-model="form.phone" type="tel" class="van-field store-input" placeholder="请输入联系电话" maxlength="20" />
</view>
<view class="store-field">
<view class="store-field-label">
<span class="store-ico"><AppIcon name="pin" :size="15" color="#475569" /></span>
<text>地址</text>
</view>
<view class="address-row">
<input v-model="form.address" class="van-field store-input address-input" placeholder="输入地址或点右侧选点" />
<button type="button" class="pick-location-btn" :disabled="pickingLocation" @click="pickAddress">
地图选点
</button>
</view>
<view v-if="hasMapPin" class="geo-hint">已选地图位置,可再微调上方文字后保存</view>
</view>
<view class="store-field store-field-last">
<view class="store-field-label">
<span class="store-ico"><AppIcon name="report" :size="15" color="#475569" /></span>
<text>简介</text>
</view>
<textarea
v-model="form.intro"
class="van-field store-input intro-textarea"
placeholder="一句话介绍门店特色、服务亮点等"
/>
</view>
</view>
<view class="store-card store-card--hours store-card--after-basic">
<view class="store-card-head">
<text class="store-card-title">营业时间</text>
<text class="store-card-sub">客户可预约时段为「开始」至「结束」之间的半点档;每半小时为一档,每档最多一单,已满或已过不可选。</text>
</view>
<view class="store-field">
<view class="store-field-label">
<span class="store-ico"><AppIcon name="service" :size="15" color="#475569" /></span>
<text>开始时间</text>
</view>
<text class="store-field-tip">当日第一个可预约的半点(例如 09:00</text>
<picker mode="selector" :range="HALF_HOUR_TIME_SLOTS" :value="bookingStartIdx" @change="onBookingStartChange">
<view class="app-form-input app-form-picker store-picker-like">
<text>{{ form.bookingDayStart || '请选择' }}</text>
<text class="app-form-arrow"></text>
</view>
</picker>
</view>
<view class="store-field store-field-last">
<view class="store-field-label">
<span class="store-ico"><AppIcon name="service" :size="15" color="#475569" /></span>
<text>结束时间</text>
</view>
<text class="store-field-tip">当日最后一个可预约的半点起始时刻(例如 21:30 表示最晚可约 21:0021:30 这一档)</text>
<picker mode="selector" :range="HALF_HOUR_TIME_SLOTS" :value="bookingLastIdx" @change="onBookingLastChange">
<view class="app-form-input app-form-picker store-picker-like">
<text>{{ form.bookingLastSlotStart || '请选择' }}</text>
<text class="app-form-arrow"></text>
</view>
</picker>
<view class="booking-hint">结束须不早于开始。保存后对新建预约生效。</view>
</view>
</view>
<view class="store-actions">
<button
class="van-button van-button--primary van-button--block store-save-btn"
:loading="saving"
@click="saveStore"
>
保存设置
</button>
<view class="store-actions-hint">保存后立即生效,并写入本地缓存。</view>
</view>
<view class="store-footer-tip">宠伴生活馆 · 商家版</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import AppIcon from '../../components/AppIcon.vue'
import { navigateTo } from '../../utils/globalState.js'
import { updateStore, getStoreDetail } from '../../api/index.js'
import { HALF_HOUR_TIME_SLOTS, halfHourPickerIndex } from '../../utils/halfHourTime.js'
import { getStoreSession, setStoreSession } from '../../utils/session.js'
defineEmits(['change-page'])
const storeInfo = getStoreSession()
const goBackToMine = () => {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
return
}
navigateTo('mine')
}
const navSafeStyle = (() => {
const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20
let navHeight = statusBarHeight + 44
const menuRect = uni.getMenuButtonBoundingClientRect?.()
if (menuRect && menuRect.top && menuRect.height) {
const verticalGap = Math.max(menuRect.top - statusBarHeight, 4)
navHeight = statusBarHeight + verticalGap * 2 + menuRect.height
}
return `padding-top:${statusBarHeight}px;height:${navHeight}px;`
})()
const saving = ref(false)
const pickingLocation = ref(false)
const form = ref({
name: '',
phone: '',
address: '',
intro: '',
latitude: null,
longitude: null,
bookingDayStart: '09:00',
bookingLastSlotStart: '21:30'
})
const toHHmm = (v) => {
if (v == null || v === '') return ''
if (typeof v === 'string') return v.length >= 5 ? v.slice(0, 5) : v
if (Array.isArray(v) && v.length >= 2) {
return `${String(v[0]).padStart(2, '0')}:${String(v[1]).padStart(2, '0')}`
}
return ''
}
const bookingStartIdx = computed(() => halfHourPickerIndex(form.value.bookingDayStart))
const bookingLastIdx = computed(() => halfHourPickerIndex(form.value.bookingLastSlotStart))
const onBookingStartChange = (e) => {
const i = Number(e.detail.value)
form.value.bookingDayStart = HALF_HOUR_TIME_SLOTS[i] || '09:00'
}
const onBookingLastChange = (e) => {
const i = Number(e.detail.value)
form.value.bookingLastSlotStart = HALF_HOUR_TIME_SLOTS[i] || '21:30'
}
const hasMapPin = computed(() => form.value.latitude != null && form.value.longitude != null)
const toCoord = (v) => {
if (v == null || v === '') return null
const n = typeof v === 'number' ? v : parseFloat(v)
return Number.isFinite(n) ? n : null
}
const pickAddress = () => {
pickingLocation.value = true
uni.chooseLocation({
success: (res) => {
const name = res.name || ''
const addr = res.address || ''
form.value.address = [name, addr].filter(Boolean).join(' · ') || addr || name
form.value.latitude = toCoord(res.latitude)
form.value.longitude = toCoord(res.longitude)
},
fail: (err) => {
const msg = (err && err.errMsg) || ''
if (/cancel|取消/i.test(msg)) return
uni.showToast({ title: '无法打开选点,请手动填写', icon: 'none' })
},
complete: () => {
pickingLocation.value = false
}
})
}
const saveStore = async () => {
const i0 = HALF_HOUR_TIME_SLOTS.indexOf(form.value.bookingDayStart)
const i1 = HALF_HOUR_TIME_SLOTS.indexOf(form.value.bookingLastSlotStart)
if (i0 < 0 || i1 < 0 || i1 < i0) {
uni.showToast({ title: '结束时间须不早于开始时间', icon: 'none' })
return
}
saving.value = true
const res = await updateStore({ id: storeInfo.id, ...form.value })
saving.value = false
if (res.code === 200) {
uni.showToast({ title: '保存成功', icon: 'success' })
const merged = res.data ? { ...storeInfo, ...res.data } : { ...storeInfo, ...form.value }
setStoreSession(merged)
} else {
uni.showToast({ title: res.message || '保存失败', icon: 'none' })
}
}
onMounted(async () => {
const base = {
name: storeInfo.name || '',
phone: storeInfo.phone || '',
address: storeInfo.address || '',
intro: storeInfo.intro || '',
latitude: toCoord(storeInfo.latitude),
longitude: toCoord(storeInfo.longitude),
bookingDayStart: toHHmm(storeInfo.bookingDayStart) || '09:00',
bookingLastSlotStart: toHHmm(storeInfo.bookingLastSlotStart) || '21:30'
}
if (storeInfo.id) {
const res = await getStoreDetail(storeInfo.id)
if (res.code === 200 && res.data) {
const d = res.data
base.bookingDayStart = toHHmm(d.bookingDayStart) || base.bookingDayStart
base.bookingLastSlotStart = toHHmm(d.bookingLastSlotStart) || base.bookingLastSlotStart
if (d.name != null) base.name = d.name
if (d.phone != null) base.phone = d.phone
if (d.address != null) base.address = d.address
if (d.intro != null) base.intro = d.intro
if (d.latitude != null) base.latitude = toCoord(d.latitude)
if (d.longitude != null) base.longitude = toCoord(d.longitude)
}
}
form.value = base
})
</script>
<style scoped>
.store-page {
min-height: 100vh;
background: var(--pet-bg, #f5f7fb);
padding-bottom: 120rpx;
}
.nav-placeholder {
width: 32px;
}
.store-nav {
padding: 14px 16px 12px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 10;
}
.nav-back {
font-size: 20px;
color: #fff;
}
.nav-title {
font-size: 18px;
font-weight: 700;
color: #fff;
}
.store-body {
padding: 12px 14px 28px;
}
.store-hero {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 16px 14px;
border-radius: 16px;
background: linear-gradient(135deg, #ffffff 0%, #ecfdf3 55%, #f0fdf4 100%);
border: 1px solid #d8f0e2;
box-shadow: var(--pet-card-shadow, 0 8px 24px rgba(15, 23, 42, 0.06));
margin-bottom: 14px;
}
.store-hero-icon {
width: 44px;
height: 44px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid #bbf7d0;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.store-hero-text {
flex: 1;
min-width: 0;
}
.store-hero-title {
display: block;
font-size: 17px;
font-weight: 700;
color: #14532d;
letter-spacing: 0.02em;
}
.store-hero-desc {
display: block;
margin-top: 6px;
font-size: 13px;
line-height: 1.55;
color: #4b5563;
}
.store-card {
background: #fff;
border: 1px solid #e6ecf4;
border-radius: 16px;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.06);
padding: 4px 4px 8px;
overflow: hidden;
}
.store-card--hours {
border-color: #86efac;
box-shadow: 0 8px 22px rgba(34, 197, 94, 0.12);
}
.store-card--after-basic {
margin-top: 14px;
}
.store-card-head {
padding: 14px 14px 6px;
}
.store-card-kicker {
font-size: 12px;
font-weight: 700;
color: #94a3b8;
letter-spacing: 0.04em;
}
.store-card-title {
display: block;
font-size: 17px;
font-weight: 800;
color: #14532d;
letter-spacing: 0.02em;
}
.store-card-sub {
display: block;
margin-top: 6px;
font-size: 13px;
line-height: 1.55;
color: #64748b;
}
.store-field {
padding: 12px 12px 4px;
}
.store-field + .store-field {
border-top: 1px solid #f1f5f9;
}
.store-field-label {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
font-size: 14px;
font-weight: 600;
color: #374151;
}
.store-field-tip {
display: block;
margin-bottom: 8px;
font-size: 13px;
line-height: 1.5;
color: #64748b;
}
.store-ico {
width: 28px;
height: 28px;
border-radius: 8px;
background: #f1f5f9;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
/* 输入区:略抬高层级,避免贴边过紧 */
.store-input {
margin-top: 0 !important;
}
.address-row {
display: flex;
align-items: stretch;
gap: 10px;
}
.address-input {
flex: 1;
min-width: 0;
}
.pick-location-btn {
flex-shrink: 0;
padding: 0 14px;
min-height: 44px;
border-radius: 12px;
border: 1px solid #2db96d;
background: #f0fdf4;
color: #15803d;
font-size: 14px;
font-weight: 600;
line-height: 44px;
-webkit-tap-highlight-color: transparent;
}
.pick-location-btn:active:not([disabled]) {
opacity: 0.92;
transform: translateY(1px);
}
.pick-location-btn[disabled] {
opacity: 0.55;
}
.geo-hint {
margin-top: 8px;
font-size: 12px;
color: #2db96d;
line-height: 1.4;
}
.intro-textarea {
min-height: 112px !important;
line-height: 1.55 !important;
padding-top: 12px !important;
padding-bottom: 12px !important;
resize: none;
}
.store-field-last {
padding-bottom: 14px;
}
.store-actions {
margin-top: 18px;
}
.store-save-btn {
margin-top: 0 !important;
box-shadow: 0 10px 26px rgba(34, 197, 94, 0.35) !important;
}
.store-actions-hint {
margin-top: 10px;
text-align: center;
font-size: 12px;
color: #94a3b8;
line-height: 1.45;
}
.store-footer-tip {
margin-top: 28px;
text-align: center;
font-size: 12px;
color: #cbd5e1;
}
.store-picker-like {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 44px;
padding: 0 12px;
border-radius: 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
font-size: 15px;
color: #111827;
}
.booking-hint {
margin-top: 8px;
font-size: 13px;
color: #64748b;
line-height: 1.5;
}
</style>