Update mengyastore
This commit is contained in:
49
mengyastore-frontend/package-lock.json
generated
49
mengyastore-frontend/package-lock.json
generated
@@ -10,7 +10,6 @@
|
||||
"dependencies": {
|
||||
"axios": "^1.6.8",
|
||||
"markdown-it": "^14.1.0",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
@@ -5899,28 +5898,6 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pinia": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz",
|
||||
"integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^6.6.3",
|
||||
"vue-demi": "^0.14.10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/posva"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.4.4",
|
||||
"vue": "^2.7.0 || ^3.5.11"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
@@ -7204,32 +7181,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-demi": {
|
||||
"version": "0.14.10",
|
||||
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
|
||||
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"vue-demi-fix": "bin/vue-demi-fix.js",
|
||||
"vue-demi-switch": "bin/vue-demi-switch.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vue/composition-api": "^1.0.0-rc.1",
|
||||
"vue": "^3.0.0-0 || ^2.6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vue/composition-api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-router": {
|
||||
"version": "4.6.4",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"dependencies": {
|
||||
"axios": "^1.6.8",
|
||||
"markdown-it": "^14.1.0",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<div class="relative flex flex-col min-h-screen">
|
||||
<!-- Random background -->
|
||||
<div class="app-randbg" aria-hidden="true">
|
||||
<img
|
||||
v-if="randBgUrl"
|
||||
@@ -12,234 +11,49 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- PWA Splash Screen -->
|
||||
<SplashScreen :visible="showSplash" />
|
||||
|
||||
<!-- 移动端:菜单遮罩 -->
|
||||
<div
|
||||
v-show="mobileNavOpen"
|
||||
class="fixed inset-0 z-[98] bg-apptext/20 backdrop-blur-[2px] md:hidden motion-safe:transition-opacity"
|
||||
aria-hidden="true"
|
||||
@click="closeMobileNav"
|
||||
<AppTopNav
|
||||
:logged-in="loggedIn"
|
||||
:login-url="loginUrl"
|
||||
:wishlist-count="wishlistCount"
|
||||
@admin-unlock="openAdminModal"
|
||||
/>
|
||||
|
||||
<!-- Top navigation -->
|
||||
<div class="sticky top-0 z-[100] relative">
|
||||
<header
|
||||
class="flex items-center justify-between gap-3 min-h-[56px] px-[5vw] py-3 glass-strong border-b border-white/35 shadow-[0_4px_24px_rgba(33,33,40,0.08)] md:min-h-0 md:py-4 md:gap-5 max-md:px-3 max-md:py-2.5"
|
||||
>
|
||||
<!-- Brand -->
|
||||
<div
|
||||
class="flex gap-4 items-center cursor-pointer select-none min-w-0 flex-1 md:flex-initial md:gap-4 max-md:gap-2"
|
||||
@click="onLogoClick"
|
||||
>
|
||||
<div class="w-[46px] h-[46px] rounded-[10px] overflow-hidden border border-white/60 glass-strong shadow-[0_12px_24px_rgba(33,33,40,0.18)] flex items-center justify-center shrink-0 max-md:w-9 max-md:h-9">
|
||||
<img src="/logo.png" alt="萌芽小店" draggable="false" class="w-full h-full object-contain" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-[28px] tracking-wide truncate max-md:text-[15px] max-md:font-semibold">萌芽小店</h1>
|
||||
</div>
|
||||
<AppAdminUnlockModal
|
||||
v-model:open="showAdminModal"
|
||||
v-model:token="tokenInput"
|
||||
:error="tokenError"
|
||||
@submit="submitAdminToken"
|
||||
/>
|
||||
|
||||
<div class="flex-1 flex flex-col px-[5vw] py-4 max-md:px-[2vw] max-md:py-3 min-h-0">
|
||||
<template v-if="mergePageShell">
|
||||
<div class="page-card flex flex-col flex-1 min-h-0">
|
||||
<main class="flex-1 min-h-0">
|
||||
<router-view />
|
||||
</main>
|
||||
<AppSiteFooter
|
||||
embedded
|
||||
:stats-loaded="statsLoaded"
|
||||
:total-orders="stats.totalOrders"
|
||||
:total-visits="stats.totalVisits"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<main class="flex-1 py-4 min-h-0 max-md:py-3">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<!-- Desktop actions -->
|
||||
<div class="hidden md:flex gap-3 items-center shrink-0">
|
||||
<button type="button" class="ghost" @click="goHome">商店</button>
|
||||
<template v-if="loggedIn">
|
||||
<button type="button" class="ghost relative inline-flex items-center gap-1" @click="goWishlist">
|
||||
<span>☆</span>
|
||||
收藏夹
|
||||
<span
|
||||
v-if="wishlistCount > 0"
|
||||
class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-gradient-to-br from-accent to-accent2 text-white text-[11px] font-bold leading-none"
|
||||
>{{ wishlistCount }}</span>
|
||||
</button>
|
||||
<button type="button" class="ghost" @click="goMyOrders">我的订单</button>
|
||||
<div
|
||||
class="flex items-center gap-2 px-3 py-1.5 rounded-full bg-white/60 border border-white/35 cursor-pointer hover:bg-white/85 transition-colors"
|
||||
@click="goProfile"
|
||||
>
|
||||
<img
|
||||
v-if="authState.avatarUrl"
|
||||
class="w-7 h-7 rounded-full object-cover border border-white/35"
|
||||
:src="authState.avatarUrl"
|
||||
:alt="authState.username"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-7 h-7 rounded-full flex items-center justify-center bg-gradient-to-br from-accent to-accent2 text-white text-[15px] font-semibold border border-white/35"
|
||||
>
|
||||
{{ (authState.username || authState.account || '?').charAt(0) }}
|
||||
</div>
|
||||
<span class="text-[17px] font-bold text-apptext max-w-[100px] overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{ authState.username || authState.account }}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" class="ghost" @click="logout">退出</button>
|
||||
</template>
|
||||
<a
|
||||
v-else
|
||||
class="inline-flex items-center px-[18px] py-[10px] rounded-lg bg-gradient-to-br from-accent to-accent2 text-white no-underline text-[17px] shadow-[0_10px_30px_rgba(145,168,208,0.35)] transition-transform hover:-translate-y-px"
|
||||
:href="loginUrl"
|
||||
>萌芽账号登录</a>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu toggle -->
|
||||
<button
|
||||
type="button"
|
||||
class="md:hidden flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-xl border border-white/50 bg-white/60 text-apptext shadow-sm tap-target"
|
||||
:aria-expanded="mobileNavOpen"
|
||||
:aria-label="mobileNavOpen ? '关闭菜单' : '打开菜单'"
|
||||
@click="mobileNavOpen = !mobileNavOpen"
|
||||
>
|
||||
<svg v-if="!mobileNavOpen" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M4 6h16M4 12h16M4 18h16"/>
|
||||
</svg>
|
||||
<svg v-else width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Mobile dropdown -->
|
||||
<Transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="opacity-0 -translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition ease-in duration-150"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 -translate-y-2"
|
||||
>
|
||||
<nav
|
||||
v-if="mobileNavOpen"
|
||||
class="md:hidden absolute left-0 right-0 top-full z-[101] border-t border-white/35 glass-strong shadow-[0_16px_40px_rgba(33,33,40,0.14)] max-h-[min(75vh,560px)] overflow-y-auto overscroll-contain px-3 py-3 pb-[max(16px,env(safe-area-inset-bottom))]"
|
||||
@click.stop
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<button type="button" class="app-mobile-nav-btn" @click="navGoHome">商店</button>
|
||||
<template v-if="loggedIn">
|
||||
<button type="button" class="app-mobile-nav-btn flex items-center justify-between gap-3" @click="navGoWishlist">
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<span class="text-accent">☆</span>
|
||||
收藏夹
|
||||
</span>
|
||||
<span
|
||||
v-if="wishlistCount > 0"
|
||||
class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-full bg-gradient-to-br from-accent to-accent2 text-white text-[12px] font-bold"
|
||||
>{{ wishlistCount }}</span>
|
||||
</button>
|
||||
<button type="button" class="app-mobile-nav-btn" @click="navGoMyOrders">我的订单</button>
|
||||
<button type="button" class="app-mobile-nav-btn flex items-center gap-3 text-left min-w-0" @click="navGoProfile">
|
||||
<img
|
||||
v-if="authState.avatarUrl"
|
||||
class="w-9 h-9 rounded-full object-cover border border-white/35 shrink-0"
|
||||
:src="authState.avatarUrl"
|
||||
alt=""
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-9 h-9 rounded-full flex items-center justify-center bg-gradient-to-br from-accent to-accent2 text-white text-sm font-semibold shrink-0"
|
||||
>
|
||||
{{ (authState.username || authState.account || '?').charAt(0) }}
|
||||
</div>
|
||||
<span class="font-bold truncate min-w-0">{{ authState.username || authState.account }}</span>
|
||||
<span class="text-muted text-[13px] shrink-0 ml-auto">个人主页</span>
|
||||
</button>
|
||||
<button type="button" class="app-mobile-nav-btn !text-[#b84d5c] !border-[rgba(184,77,92,0.25)]" @click="navLogout">
|
||||
退出登录
|
||||
</button>
|
||||
</template>
|
||||
<a
|
||||
v-else
|
||||
class="app-mobile-nav-btn app-mobile-nav-btn-primary text-center no-underline"
|
||||
:href="loginUrl"
|
||||
@click="closeMobileNav"
|
||||
>萌芽账号登录</a>
|
||||
</div>
|
||||
</nav>
|
||||
</Transition>
|
||||
<AppSiteFooter
|
||||
:stats-loaded="statsLoaded"
|
||||
:total-orders="stats.totalOrders"
|
||||
:total-visits="stats.totalVisits"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Admin modal -->
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="showAdminModal"
|
||||
class="admin-modal fixed inset-0 z-[1000] flex items-center justify-center bg-apptext/35 backdrop-blur-lg"
|
||||
@click.self="showAdminModal = false"
|
||||
>
|
||||
<div class="admin-modal relative w-[360px] max-w-[90vw] px-8 pt-9 pb-7 glass-strong rounded-xl shadow-[0_24px_60px_rgba(33,33,40,0.22)] text-center">
|
||||
<button
|
||||
class="absolute top-3 right-4 bg-transparent border-none text-[22px] text-muted hover:text-apptext cursor-pointer px-2 py-1 rounded leading-none transition-colors"
|
||||
@click="showAdminModal = false"
|
||||
>×</button>
|
||||
<div class="inline-flex items-center justify-center w-14 h-14 rounded-[10px] bg-gradient-to-br from-accent to-accent2 text-white mb-4">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
</div>
|
||||
<h3 class="text-[22px] mb-1.5">管理员验证</h3>
|
||||
<p class="text-[15px] text-muted mb-5">请输入管理员令牌以访问后台</p>
|
||||
<form @submit.prevent="submitAdminToken">
|
||||
<input
|
||||
ref="tokenInputRef"
|
||||
v-model="tokenInput"
|
||||
type="password"
|
||||
class="w-full px-4 py-3 rounded-lg border border-white/35 bg-white/65 text-center tracking-widest text-[18px] outline-none transition focus:border-accent focus:shadow-[0_0_0_3px_rgba(180,154,203,0.18)]"
|
||||
style="font-family: var(--font-serif);"
|
||||
placeholder="输入 Token…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p v-if="tokenError" class="text-[15px] text-[#d4566a] mt-2">{{ tokenError }}</p>
|
||||
<button type="submit" class="primary w-full mt-4 py-3 text-[17px]" :disabled="!tokenInput.trim()">
|
||||
进入后台
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- Page body -->
|
||||
<div class="flex-1 flex flex-col px-[5vw] py-7 max-md:px-[2vw] max-md:py-3">
|
||||
<main class="flex-1 py-7 min-h-0">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="mt-3 px-[26px] py-6 glass rounded-[var(--radius-default)] shadow-[0_-4px_30px_rgba(33,33,40,0.06)] max-md:px-3.5 max-md:py-5">
|
||||
<div class="flex flex-col items-center gap-3.5">
|
||||
<!-- Brand -->
|
||||
<div class="flex items-center gap-2.5">
|
||||
<img src="/logo.png" alt="萌芽小店" class="w-7 h-7 rounded-[5px] object-contain border border-white/50 shadow-[0_4px_12px_rgba(33,33,40,0.1)]" />
|
||||
<span class="text-[19px] font-semibold text-apptext tracking-[0.5px]" style="font-family: var(--font-serif);">萌芽小店</span>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="w-10 h-0.5 rounded-full bg-gradient-to-r from-accent to-accent2 opacity-50"></div>
|
||||
|
||||
<!-- Links / stats -->
|
||||
<div class="flex items-center gap-6 flex-wrap justify-center max-md:flex-row max-md:flex-wrap max-md:gap-2.5 max-md:justify-center">
|
||||
<a
|
||||
class="inline-flex items-center gap-1.5 text-[#3d5a88] no-underline text-[15px] font-semibold px-3 py-1.5 rounded bg-[rgba(145,168,208,0.12)] hover:bg-[rgba(145,168,208,0.22)] transition-colors"
|
||||
href="mailto:mail@smyhub.com"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>
|
||||
mail@smyhub.com
|
||||
</a>
|
||||
<span v-if="statsLoaded" class="inline-flex items-center gap-1.5 text-[15px] font-semibold text-[#6b4d85] px-3 py-1.5 rounded bg-[rgba(180,154,203,0.12)]">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20V10"/><path d="M18 20V4"/><path d="M6 20v-4"/></svg>
|
||||
累计下单 {{ stats.totalOrders }} 次
|
||||
</span>
|
||||
<span v-if="statsLoaded" class="inline-flex items-center gap-1.5 text-[15px] font-semibold text-[#3d5a88] px-3 py-1.5 rounded bg-[rgba(145,168,208,0.12)]">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
累计访问 {{ stats.totalVisits }} 次
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-[14px] text-[#45424a]/95 tracking-[0.3px]">© 2025 – 2026 萌芽小店 · All rights reserved</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Floating chat widget -->
|
||||
<ChatWidget
|
||||
v-if="loggedIn && authState.token"
|
||||
:user-token="authState.token"
|
||||
@@ -247,7 +61,6 @@
|
||||
:user-avatar="authState.avatarUrl"
|
||||
/>
|
||||
|
||||
<!-- PWA update toast -->
|
||||
<Transition name="pwa-toast">
|
||||
<div
|
||||
v-if="needRefresh"
|
||||
@@ -264,13 +77,16 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { verifyAdminToken, fetchStats, recordSiteVisit } from './modules/shared/api'
|
||||
import { authState, isLoggedIn, clearAuth, getLoginUrl } from './modules/shared/auth'
|
||||
import { authState, isLoggedIn, getLoginUrl } from './modules/shared/auth'
|
||||
import { wishlistCount, loadWishlist } from './modules/shared/useWishlist'
|
||||
import ChatWidget from './modules/chat/ChatWidget.vue'
|
||||
import SplashScreen from './modules/shared/SplashScreen.vue'
|
||||
import AppTopNav from './modules/shell/AppTopNav.vue'
|
||||
import AppAdminUnlockModal from './modules/shell/AppAdminUnlockModal.vue'
|
||||
import AppSiteFooter from './modules/shell/AppSiteFooter.vue'
|
||||
import { useRegisterSW } from 'virtual:pwa-register/vue'
|
||||
|
||||
const RAND_BG_JSON_URL = 'https://randbg.api.smyhub.com/api/random?format=json'
|
||||
@@ -285,6 +101,8 @@ const showSplash = ref(true)
|
||||
const randBgUrl = ref('')
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const mergePageShell = computed(() => route.path !== '/admin')
|
||||
const stats = reactive({ totalOrders: 0, totalVisits: 0 })
|
||||
const statsLoaded = ref(false)
|
||||
|
||||
@@ -294,25 +112,11 @@ const loginUrl = computed(() => getLoginUrl())
|
||||
const showAdminModal = ref(false)
|
||||
const tokenInput = ref('')
|
||||
const tokenError = ref('')
|
||||
const tokenInputRef = ref(null)
|
||||
|
||||
let logoClickCount = 0
|
||||
let logoClickTimer = null
|
||||
const CLICK_THRESHOLD = 5
|
||||
const CLICK_WINDOW_MS = 2000
|
||||
|
||||
const onLogoClick = () => {
|
||||
logoClickCount++
|
||||
clearTimeout(logoClickTimer)
|
||||
if (logoClickCount >= CLICK_THRESHOLD) {
|
||||
logoClickCount = 0
|
||||
tokenInput.value = ''
|
||||
tokenError.value = ''
|
||||
showAdminModal.value = true
|
||||
nextTick(() => tokenInputRef.value?.focus())
|
||||
return
|
||||
}
|
||||
logoClickTimer = setTimeout(() => { logoClickCount = 0 }, CLICK_WINDOW_MS)
|
||||
const openAdminModal = () => {
|
||||
tokenInput.value = ''
|
||||
tokenError.value = ''
|
||||
showAdminModal.value = true
|
||||
}
|
||||
|
||||
const submitAdminToken = async () => {
|
||||
@@ -332,56 +136,6 @@ const submitAdminToken = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const goHome = () => { router.push('/') }
|
||||
const goMyOrders = () => { router.push('/my/orders') }
|
||||
const goWishlist = () => { router.push('/wishlist') }
|
||||
|
||||
const goProfile = () => {
|
||||
if (authState.account) {
|
||||
window.open(`https://auth.shumengya.top/user/${authState.account}`, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => { clearAuth() }
|
||||
|
||||
const mobileNavOpen = ref(false)
|
||||
const closeMobileNav = () => {
|
||||
mobileNavOpen.value = false
|
||||
}
|
||||
const navGoHome = () => {
|
||||
closeMobileNav()
|
||||
goHome()
|
||||
}
|
||||
const navGoWishlist = () => {
|
||||
closeMobileNav()
|
||||
goWishlist()
|
||||
}
|
||||
const navGoMyOrders = () => {
|
||||
closeMobileNav()
|
||||
goMyOrders()
|
||||
}
|
||||
const navGoProfile = () => {
|
||||
closeMobileNav()
|
||||
goProfile()
|
||||
}
|
||||
const navLogout = () => {
|
||||
closeMobileNav()
|
||||
logout()
|
||||
}
|
||||
|
||||
watch(() => router.currentRoute.value.fullPath, () => {
|
||||
closeMobileNav()
|
||||
})
|
||||
|
||||
watch(mobileNavOpen, (open) => {
|
||||
if (typeof document === 'undefined') return
|
||||
document.body.style.overflow = open ? 'hidden' : ''
|
||||
})
|
||||
|
||||
const onNavEscape = (e) => {
|
||||
if (e.key === 'Escape') closeMobileNav()
|
||||
}
|
||||
|
||||
const SPLASH_MIN_MS = 1400
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -409,14 +163,9 @@ onMounted(async () => {
|
||||
|
||||
const elapsed = Date.now() - splashStart
|
||||
const remaining = SPLASH_MIN_MS - elapsed
|
||||
setTimeout(() => { showSplash.value = false }, remaining > 0 ? remaining : 0)
|
||||
|
||||
window.addEventListener('keydown', onNavEscape)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', onNavEscape)
|
||||
if (typeof document !== 'undefined') document.body.style.overflow = ''
|
||||
setTimeout(() => {
|
||||
showSplash.value = false
|
||||
}, remaining > 0 ? remaining : 0)
|
||||
})
|
||||
|
||||
watch(() => authState.token, async (newToken) => {
|
||||
@@ -425,37 +174,3 @@ watch(() => authState.token, async (newToken) => {
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-mobile-nav-btn {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
text-align: left;
|
||||
padding: 0.875rem 1rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--line-color);
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
color: var(--color-apptext);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-serif);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, transform 0.12s ease;
|
||||
}
|
||||
.app-mobile-nav-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
.app-mobile-nav-btn:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
.app-mobile-nav-btn-primary {
|
||||
background: linear-gradient(135deg, var(--color-accent), var(--color-accent2));
|
||||
color: #fff !important;
|
||||
border-color: transparent;
|
||||
box-shadow: 0 8px 24px rgba(145, 168, 208, 0.35);
|
||||
}
|
||||
.tap-target {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 136 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 156 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 238 KiB |
@@ -1,352 +1,418 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ─────────────────────────────────────────
|
||||
Design tokens → Tailwind theme
|
||||
───────────────────────────────────────── */
|
||||
@theme {
|
||||
--color-accent: #b49acb;
|
||||
--color-accent2: #91a8d0;
|
||||
--color-apptext: #2c2b2d;
|
||||
--color-muted: #6c6870;
|
||||
--color-bg1: #f8f5f2;
|
||||
--color-bg2: #e9f0f7;
|
||||
--color-bg3: #f4eaf1;
|
||||
|
||||
--font-serif: 'KaiTi', 'STKaiti', '楷体', '楷体_GB2312', serif;
|
||||
|
||||
--shadow-card: 0 20px 50px rgba(33, 33, 40, 0.12);
|
||||
--radius-default: 8px;
|
||||
|
||||
/* glass helpers as raw values (used in @layer) */
|
||||
--glass-bg: rgba(255, 255, 255, 0.55);
|
||||
--glass-strong-bg: rgba(255, 255, 255, 0.72);
|
||||
--line-color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────
|
||||
Base layer
|
||||
───────────────────────────────────────── */
|
||||
@layer base {
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
html {
|
||||
scrollbar-width: none;
|
||||
background-color: var(--color-bg1);
|
||||
}
|
||||
html::-webkit-scrollbar { display: none; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 18px;
|
||||
color: var(--color-apptext);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
font-family: var(--font-serif);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p { margin: 0; }
|
||||
|
||||
button {
|
||||
font-family: var(--font-serif);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────
|
||||
Shared component classes
|
||||
───────────────────────────────────────── */
|
||||
@layer components {
|
||||
|
||||
/* Glass surfaces */
|
||||
.glass {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--line-color);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.glass-strong {
|
||||
background: var(--glass-strong-bg);
|
||||
border: 1px solid var(--line-color);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
/* Card wrapper used on every page */
|
||||
.page-card {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--line-color);
|
||||
border-radius: var(--radius-default);
|
||||
padding: 26px;
|
||||
box-shadow: var(--shadow-card);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
/* Button variants */
|
||||
.ghost {
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
color: var(--color-apptext);
|
||||
border: 1px solid var(--line-color);
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-serif);
|
||||
padding: 10px 18px;
|
||||
border-radius: 8px;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
.ghost:hover { transform: translateY(-1px); }
|
||||
|
||||
.primary {
|
||||
background: linear-gradient(135deg, var(--color-accent), var(--color-accent2));
|
||||
color: #fff;
|
||||
font-family: var(--font-serif);
|
||||
padding: 10px 18px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
box-shadow: 0 10px 30px rgba(145, 168, 208, 0.35);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
.primary:hover { transform: translateY(-1px); }
|
||||
|
||||
/* Badge */
|
||||
.badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
/* Form fields */
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-field input,
|
||||
.form-field textarea {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line-color);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
padding: 10px 12px;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 18px;
|
||||
color: var(--color-apptext);
|
||||
outline: none;
|
||||
}
|
||||
.form-field textarea {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* Markdown rendered content (can't use Tailwind for inner HTML) */
|
||||
.markdown {
|
||||
font-size: 15px;
|
||||
color: var(--color-muted);
|
||||
line-height: 1.6;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
overflow: hidden;
|
||||
}
|
||||
.markdown p { margin: 0 0 0.5em; }
|
||||
.markdown ul { padding-left: 1.2em; }
|
||||
.markdown a { color: var(--color-accent2); }
|
||||
.markdown strong { font-weight: 700; color: var(--color-apptext); }
|
||||
|
||||
/* Product grid */
|
||||
.product-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 20px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* Shared tag text */
|
||||
.tag {
|
||||
font-size: 14px;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
/* Admin two-column layout (used globally in admin) */
|
||||
.admin-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* Detail two-column layout */
|
||||
.detail-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.table th,
|
||||
.table td {
|
||||
text-align: left;
|
||||
padding: 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.table tr {
|
||||
border-bottom: 1px solid var(--line-color);
|
||||
}
|
||||
|
||||
/* Random background layer (full-page) */
|
||||
.app-randbg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(circle at top, var(--color-bg2), transparent 60%),
|
||||
radial-gradient(circle at 10% 20%, var(--color-bg3), transparent 55%),
|
||||
linear-gradient(140deg, var(--color-bg1), #fefefe 65%);
|
||||
}
|
||||
.app-randbg-img {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
min-width: calc(100% + 48px);
|
||||
min-height: calc(100% + 48px);
|
||||
width: auto;
|
||||
height: auto;
|
||||
transform: translate(-50%, -50%) scale(1.06);
|
||||
object-fit: cover;
|
||||
filter: blur(10px);
|
||||
-webkit-filter: blur(10px);
|
||||
}
|
||||
|
||||
/* Buy button */
|
||||
.buy-button {
|
||||
background: linear-gradient(135deg, var(--color-accent), var(--color-accent2));
|
||||
color: #fff;
|
||||
padding: 12px 22px;
|
||||
border-radius: 8px;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 17px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.buy-button:hover { transform: translateY(-1px); }
|
||||
|
||||
/* Secondary link */
|
||||
.secondary-link {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
padding: 8px 14px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
color: var(--color-apptext);
|
||||
font-size: 15px;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
.secondary-link:hover { background: rgba(255, 255, 255, 0.85); }
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────
|
||||
Vue <Transition> classes – cannot be in @layer components
|
||||
because the names are generated at runtime by Vue
|
||||
───────────────────────────────────────── */
|
||||
|
||||
/* Admin modal */
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
.modal-enter-active .admin-modal,
|
||||
.modal-leave-active .admin-modal {
|
||||
transition: transform 0.25s ease, opacity 0.25s ease;
|
||||
}
|
||||
.modal-enter-from,
|
||||
.modal-leave-to { opacity: 0; }
|
||||
.modal-enter-from .admin-modal {
|
||||
transform: scale(0.92) translateY(10px);
|
||||
opacity: 0;
|
||||
}
|
||||
.modal-leave-to .admin-modal {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* PWA update toast */
|
||||
.pwa-toast-enter-active,
|
||||
.pwa-toast-leave-active {
|
||||
transition: opacity 0.3s, transform 0.3s;
|
||||
}
|
||||
.pwa-toast-enter-from,
|
||||
.pwa-toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(16px);
|
||||
}
|
||||
|
||||
/* Splash fade */
|
||||
.splash-fade-leave-active {
|
||||
transition: opacity 0.45s ease, transform 0.45s ease;
|
||||
}
|
||||
.splash-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
/* Chat panel slide */
|
||||
.chat-slide-enter-active,
|
||||
.chat-slide-leave-active {
|
||||
transition: opacity 0.22s ease, transform 0.22s ease;
|
||||
}
|
||||
.chat-slide-enter-from,
|
||||
.chat-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.96);
|
||||
}
|
||||
|
||||
/* Chat icon flip */
|
||||
.icon-flip-enter-active,
|
||||
.icon-flip-leave-active {
|
||||
transition: opacity 0.15s, transform 0.15s;
|
||||
}
|
||||
.icon-flip-enter-from,
|
||||
.icon-flip-leave-to {
|
||||
opacity: 0;
|
||||
transform: rotate(90deg) scale(0.7);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────
|
||||
Responsive overrides (global)
|
||||
───────────────────────────────────────── */
|
||||
@media (max-width: 900px) {
|
||||
.product-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.admin-grid { grid-template-columns: 1fr; }
|
||||
.detail-layout { grid-template-columns: 1fr; }
|
||||
|
||||
.page-card { padding: 14px; }
|
||||
|
||||
.btn-ghost.ghost {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 5px 7px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ─────────────────────────────────────────
|
||||
Design tokens → Tailwind theme
|
||||
───────────────────────────────────────── */
|
||||
@theme {
|
||||
--color-accent: #d9a878;
|
||||
--color-accent2: #b86d5c;
|
||||
--color-apptext: #1c1714;
|
||||
--color-muted: #524840;
|
||||
--color-bg1: #faf7f2;
|
||||
--color-bg2: #f3e8dd;
|
||||
--color-bg3: #f8eae4;
|
||||
|
||||
--font-serif: 'KaiTi', 'STKaiti', '楷体', '楷体_GB2312', serif;
|
||||
|
||||
--shadow-card: 0 20px 50px rgba(42, 28, 22, 0.11);
|
||||
--radius-default: 8px;
|
||||
|
||||
/* glass helpers as raw values (used in @layer) — slight cream tint */
|
||||
--glass-bg: rgba(255, 249, 243, 0.58);
|
||||
--glass-strong-bg: rgba(255, 252, 247, 0.76);
|
||||
--line-color: rgba(255, 252, 248, 0.5);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────
|
||||
Base layer
|
||||
───────────────────────────────────────── */
|
||||
@layer base {
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
html {
|
||||
scrollbar-width: none;
|
||||
background-color: var(--color-bg1);
|
||||
}
|
||||
html::-webkit-scrollbar { display: none; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 18px;
|
||||
color: var(--color-apptext);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
font-family: var(--font-serif);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p { margin: 0; }
|
||||
|
||||
button {
|
||||
font-family: var(--font-serif);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--color-muted);
|
||||
opacity: 0.92;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────
|
||||
Shared component classes
|
||||
───────────────────────────────────────── */
|
||||
@layer components {
|
||||
|
||||
/* Glass surfaces */
|
||||
.glass {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--line-color);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.glass-strong {
|
||||
background: var(--glass-strong-bg);
|
||||
border: 1px solid var(--line-color);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
/* Card wrapper used on every page */
|
||||
.page-card {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--line-color);
|
||||
border-radius: var(--radius-default);
|
||||
padding: 18px;
|
||||
box-shadow: var(--shadow-card);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
/* Button variants */
|
||||
.ghost {
|
||||
background: rgba(255, 252, 248, 0.68);
|
||||
color: var(--color-apptext);
|
||||
border: 1px solid var(--line-color);
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-serif);
|
||||
padding: 10px 18px;
|
||||
border-radius: 8px;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
.ghost:hover { transform: translateY(-1px); }
|
||||
|
||||
.primary {
|
||||
background: linear-gradient(135deg, var(--color-accent), var(--color-accent2));
|
||||
color: #fff;
|
||||
font-family: var(--font-serif);
|
||||
padding: 10px 18px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
box-shadow: 0 10px 30px rgba(160, 95, 72, 0.32);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
.primary:hover { transform: translateY(-1px); }
|
||||
|
||||
/* Badge */
|
||||
.badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background: rgba(255, 252, 248, 0.78);
|
||||
}
|
||||
|
||||
/* Form fields */
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-field input,
|
||||
.form-field textarea {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line-color);
|
||||
background: rgba(255, 252, 248, 0.85);
|
||||
padding: 10px 12px;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 18px;
|
||||
color: var(--color-apptext);
|
||||
outline: none;
|
||||
}
|
||||
.form-field textarea {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* Markdown rendered content (can't use Tailwind for inner HTML) */
|
||||
.markdown {
|
||||
font-size: 15px;
|
||||
color: color-mix(in srgb, var(--color-apptext) 38%, var(--color-muted));
|
||||
line-height: 1.6;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
overflow: hidden;
|
||||
}
|
||||
.markdown p { margin: 0 0 0.5em; }
|
||||
.markdown ul { padding-left: 1.2em; }
|
||||
.markdown a { color: #8b4f38; }
|
||||
.markdown strong { font-weight: 700; color: var(--color-apptext); }
|
||||
|
||||
/** 商品详情:完整 Markdown,不单段截断 */
|
||||
.markdown-detail.markdown {
|
||||
display: block;
|
||||
-webkit-line-clamp: unset;
|
||||
-webkit-box-orient: unset;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Product grid: responsive cards with a hard cap of 5 columns on wide screens */
|
||||
.product-grid {
|
||||
--product-grid-gap: 20px;
|
||||
display: grid;
|
||||
gap: var(--product-grid-gap);
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
min-height: 430px;
|
||||
}
|
||||
|
||||
.product-card-description {
|
||||
min-height: 46px;
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
@media (min-width: 720px) {
|
||||
.product-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 980px) {
|
||||
.product-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1240px) {
|
||||
.product-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1540px) {
|
||||
.product-grid {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.product-grid {
|
||||
--product-grid-gap: 12px;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
min-height: 340px;
|
||||
}
|
||||
|
||||
.product-card-description {
|
||||
min-height: 38px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shared tag text */
|
||||
.tag {
|
||||
font-size: 14px;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
/* Admin two-column layout (used globally in admin) */
|
||||
.admin-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* Detail two-column layout */
|
||||
.detail-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.table th,
|
||||
.table td {
|
||||
text-align: left;
|
||||
padding: 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.table tr {
|
||||
border-bottom: 1px solid var(--line-color);
|
||||
}
|
||||
|
||||
/* Random background layer (full-page) */
|
||||
.app-randbg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(circle at top, var(--color-bg2), transparent 60%),
|
||||
radial-gradient(circle at 10% 20%, var(--color-bg3), transparent 55%),
|
||||
linear-gradient(140deg, var(--color-bg1), #fff9f4 65%);
|
||||
}
|
||||
.app-randbg-img {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
min-width: calc(100% + 48px);
|
||||
min-height: calc(100% + 48px);
|
||||
width: auto;
|
||||
height: auto;
|
||||
transform: translate(-50%, -50%) scale(1.06);
|
||||
object-fit: cover;
|
||||
filter: blur(10px);
|
||||
-webkit-filter: blur(10px);
|
||||
}
|
||||
|
||||
/* Buy button */
|
||||
.buy-button {
|
||||
background: linear-gradient(135deg, var(--color-accent), var(--color-accent2));
|
||||
color: #fff;
|
||||
padding: 10px 18px;
|
||||
border-radius: 8px;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 16px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.buy-button:hover { transform: translateY(-1px); }
|
||||
|
||||
/* Secondary link */
|
||||
.secondary-link {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
padding: 8px 14px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 250, 245, 0.65);
|
||||
background: rgba(255, 252, 248, 0.68);
|
||||
color: var(--color-apptext);
|
||||
font-size: 15px;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
.secondary-link:hover { background: rgba(255, 250, 243, 0.9); }
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────
|
||||
Vue <Transition> classes – cannot be in @layer components
|
||||
because the names are generated at runtime by Vue
|
||||
───────────────────────────────────────── */
|
||||
|
||||
/* Admin modal */
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
.modal-enter-active .admin-modal,
|
||||
.modal-leave-active .admin-modal {
|
||||
transition: transform 0.25s ease, opacity 0.25s ease;
|
||||
}
|
||||
.modal-enter-from,
|
||||
.modal-leave-to { opacity: 0; }
|
||||
.modal-enter-from .admin-modal {
|
||||
transform: scale(0.92) translateY(10px);
|
||||
opacity: 0;
|
||||
}
|
||||
.modal-leave-to .admin-modal {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* PWA update toast */
|
||||
.pwa-toast-enter-active,
|
||||
.pwa-toast-leave-active {
|
||||
transition: opacity 0.3s, transform 0.3s;
|
||||
}
|
||||
.pwa-toast-enter-from,
|
||||
.pwa-toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(16px);
|
||||
}
|
||||
|
||||
/* Splash fade */
|
||||
.splash-fade-leave-active {
|
||||
transition: opacity 0.45s ease, transform 0.45s ease;
|
||||
}
|
||||
.splash-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
/* Chat panel slide */
|
||||
.chat-slide-enter-active,
|
||||
.chat-slide-leave-active {
|
||||
transition: opacity 0.22s ease, transform 0.22s ease;
|
||||
}
|
||||
.chat-slide-enter-from,
|
||||
.chat-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.96);
|
||||
}
|
||||
|
||||
/* Chat icon flip */
|
||||
.icon-flip-enter-active,
|
||||
.icon-flip-leave-active {
|
||||
transition: opacity 0.15s, transform 0.15s;
|
||||
}
|
||||
.icon-flip-enter-from,
|
||||
.icon-flip-leave-to {
|
||||
opacity: 0;
|
||||
transform: rotate(90deg) scale(0.7);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────
|
||||
Responsive overrides (global)
|
||||
───────────────────────────────────────── */
|
||||
@media (max-width: 900px) {
|
||||
.product-grid {
|
||||
--product-grid-gap: 10px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.admin-grid { grid-template-columns: 1fr; }
|
||||
.detail-layout { grid-template-columns: 1fr; }
|
||||
|
||||
.page-card { padding: 14px; }
|
||||
|
||||
.btn-ghost.ghost {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 5px 7px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './assets/styles.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
<button
|
||||
v-for="item in NAV_ITEMS"
|
||||
:key="item.id"
|
||||
class="flex items-center gap-2.5 px-[18px] py-2.5 text-[15px] font-medium text-muted bg-transparent border-none cursor-pointer text-left rounded-none transition whitespace-nowrap hover:bg-[rgba(180,154,203,0.1)] hover:text-apptext max-md:flex-col max-md:gap-0.5 max-md:px-3.5 max-md:py-2 max-md:text-xs max-md:flex-shrink-0 max-md:items-center"
|
||||
:class="activeSection === item.id ? '!bg-[rgba(180,154,203,0.18)] !text-apptext font-bold border-r-[3px] border-accent max-md:border-r-0 max-md:border-b-[3px]' : ''"
|
||||
class="flex items-center gap-2.5 px-[18px] py-2.5 text-[15px] font-medium text-muted bg-transparent border-none cursor-pointer text-left rounded-none transition whitespace-nowrap hover:bg-[rgba(217,168,120,0.12)] hover:text-apptext max-md:flex-col max-md:gap-0.5 max-md:px-3.5 max-md:py-2 max-md:text-xs max-md:flex-shrink-0 max-md:items-center"
|
||||
:class="activeSection === item.id ? '!bg-[rgba(217,168,120,0.2)] !text-apptext font-bold border-r-[3px] border-accent max-md:border-r-0 max-md:border-b-[3px]' : ''"
|
||||
@click="activeSection = item.id"
|
||||
type="button"
|
||||
>
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
<div
|
||||
v-for="(msgs, account) in conversations"
|
||||
:key="account"
|
||||
class="flex items-center gap-2.5 px-3 py-2.5 cursor-pointer border-b border-white/50 last:border-b-0 transition-colors hover:bg-[rgba(180,154,203,0.08)] max-md:flex-col max-md:flex-shrink-0 max-md:items-center max-md:min-w-[70px] max-md:max-w-20 max-md:px-1.5 max-md:py-2 max-md:border-b-0 max-md:border-r max-md:border-white/35 max-md:text-center"
|
||||
:class="selectedAccount === account ? '!bg-[rgba(180,154,203,0.14)] border-l-[3px] border-accent pl-[9px] max-md:border-l-0 max-md:border-b-[2px] max-md:pl-1.5' : ''"
|
||||
class="flex items-center gap-2.5 px-3 py-2.5 cursor-pointer border-b border-white/50 last:border-b-0 transition-colors hover:bg-[rgba(217,168,120,0.08)] max-md:flex-col max-md:flex-shrink-0 max-md:items-center max-md:min-w-[70px] max-md:max-w-20 max-md:px-1.5 max-md:py-2 max-md:border-b-0 max-md:border-r max-md:border-white/35 max-md:text-center"
|
||||
:class="selectedAccount === account ? '!bg-[rgba(217,168,120,0.15)] border-l-[3px] border-accent pl-[9px] max-md:border-l-0 max-md:border-b-[2px] max-md:pl-1.5' : ''"
|
||||
@click="selectConv(account, msgs)"
|
||||
>
|
||||
<div class="w-[34px] h-[34px] rounded-full bg-[#e8e8e8] text-[#444] text-sm font-bold flex items-center justify-center flex-shrink-0 max-md:w-7 max-md:h-7 max-md:text-xs">
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<span
|
||||
v-if="message"
|
||||
class="text-[15px] px-2.5 py-1 rounded whitespace-nowrap"
|
||||
:class="message.includes('失败') ? 'text-[#c95a6a] bg-[rgba(201,90,106,0.08)]' : 'text-accent2 bg-[rgba(145,168,208,0.1)]'"
|
||||
:class="message.includes('失败') ? 'text-[#c95a6a] bg-[rgba(201,90,106,0.08)]' : 'text-accent2 bg-[rgba(217,168,120,0.1)]'"
|
||||
>{{ message }}</span>
|
||||
</div>
|
||||
<!-- Right: reason input + save -->
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="open"
|
||||
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto overscroll-contain p-6 bg-[rgba(30,28,32,0.35)] backdrop-blur-lg max-md:p-3"
|
||||
class="fixed inset-0 z-40 flex items-center justify-center overflow-y-auto overscroll-contain p-6 bg-[rgba(30,28,32,0.35)] backdrop-blur-lg max-md:p-3"
|
||||
@click.self="$emit('close')"
|
||||
>
|
||||
<section
|
||||
class="flex w-[min(920px,100%)] max-h-[min(calc(100dvh-32px),calc(100vh-32px))] flex-col min-h-0 my-4 shrink-0 overflow-hidden rounded-xl border border-white/35 bg-white/95 p-7 shadow-[0_24px_60px_rgba(33,33,40,0.2)] max-md:my-3 max-md:max-h-[min(calc(100dvh-24px),calc(100vh-24px))] max-md:p-[18px]"
|
||||
class="flex w-[min(1080px,100%)] flex-col min-h-0 shrink-0 overflow-hidden rounded-xl border border-white/35 bg-white/95 p-[18px] shadow-[0_24px_60px_rgba(33,33,40,0.2)] max-h-[min(calc(100dvh-24px),calc(100vh-24px))] md:p-6 md:max-h-[min(74dvh,calc(100dvh-48px),calc(100vh-48px))]"
|
||||
@click.stop
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex shrink-0 items-start justify-between gap-4 pb-[22px]">
|
||||
<div class="flex shrink-0 items-start justify-between gap-4 pb-4 md:pb-[18px]">
|
||||
<div>
|
||||
<h3>{{ form.id ? '编辑商品' : '添加新商品' }}</h3>
|
||||
<p class="tag">封面图和最多 5 张商品截图共用这一套编辑表单。</p>
|
||||
</div>
|
||||
<button class="ghost" @click="$emit('close')">关闭</button>
|
||||
</div>
|
||||
@@ -64,7 +63,6 @@
|
||||
<div v-if="form.fulfillmentType === 'fixed'" class="form-field">
|
||||
<label>固定发货内容</label>
|
||||
<textarea v-model="form.fixedContent" rows="5" placeholder="例如:https://… 网盘链接,或一段固定发给买家的文字"></textarea>
|
||||
<p class="tag">保存后顾客每笔订单会收到这份内容(若一次买多件,会复制多行相同内容便于核对)。</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="form-field">
|
||||
@@ -73,7 +71,6 @@
|
||||
<span class="tag">共 {{ form.inventoryItems.length }} 条</span>
|
||||
<button class="ghost px-3.5 py-1.5 text-[13px] rounded-full" type="button" @click="addInventoryItem">添加一行</button>
|
||||
</div>
|
||||
<p class="tag">每条一行,手动逐个添加;购买后按顺序发放并扣减库存。</p>
|
||||
<div class="flex flex-col gap-2 mt-1">
|
||||
<div v-for="(_, index) in form.inventoryItems" :key="index" class="flex items-center gap-2 max-md:flex-col max-md:items-stretch">
|
||||
<input
|
||||
@@ -93,7 +90,7 @@
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label>商品截图链接(最多 5 个)</label>
|
||||
<label>商品截图链接(最多 6 个)</label>
|
||||
<div class="grid grid-cols-2 gap-2.5 max-md:grid-cols-1">
|
||||
<input
|
||||
v-for="(_, index) in screenshotInputSlots"
|
||||
@@ -104,6 +101,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label>萌芽支付 · 收款码图片链接(最多 6 个)</label>
|
||||
<div class="grid grid-cols-1 gap-2.5 md:grid-cols-2">
|
||||
<input
|
||||
v-for="(_, index) in paymentQrUrlSlots"
|
||||
:key="index"
|
||||
v-model="form.paymentQrUrls[index]"
|
||||
:placeholder="`收款码链接 ${index + 1}`"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label>商品介绍(Markdown)</label>
|
||||
<textarea v-model="form.description"></textarea>
|
||||
@@ -163,7 +172,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex shrink-0 gap-3 border-t border-white/35 bg-white/95 pt-4 mt-5">
|
||||
<div class="flex shrink-0 gap-3 border-t border-white/35 bg-white/95 pt-3 mt-4 md:pt-4 md:mt-5">
|
||||
<button class="primary" @click="handleSubmit">{{ form.id ? '更新商品' : '新增商品' }}</button>
|
||||
<button class="ghost" @click="resetForm">重置表单</button>
|
||||
</div>
|
||||
@@ -174,9 +183,11 @@
|
||||
<script setup>
|
||||
import { nextTick, reactive, ref, watch } from 'vue'
|
||||
|
||||
const MAX_SCREENSHOT_URLS = 5
|
||||
const MAX_SCREENSHOT_URLS = 6
|
||||
const MAX_PAYMENT_QR_URLS = 6
|
||||
const MAX_INVENTORY_ITEMS = 500
|
||||
const screenshotInputSlots = Array.from({ length: MAX_SCREENSHOT_URLS })
|
||||
const paymentQrUrlSlots = Array.from({ length: MAX_PAYMENT_QR_URLS })
|
||||
const inventoryInputRefs = ref([])
|
||||
|
||||
const props = defineProps({
|
||||
@@ -199,9 +210,17 @@ const normalizeInventoryItems = (values = []) =>
|
||||
const normalizeScreenshotUrls = (values = []) =>
|
||||
values.map((item) => item.trim()).filter(Boolean).slice(0, MAX_SCREENSHOT_URLS)
|
||||
|
||||
const createPaymentQrSlots = (values = []) =>
|
||||
Array.from({ length: MAX_PAYMENT_QR_URLS }, (_, i) => values[i] || '')
|
||||
|
||||
const normalizePaymentQrUrls = (values = []) =>
|
||||
values.map((item) => item.trim()).filter(Boolean).slice(0, MAX_PAYMENT_QR_URLS)
|
||||
|
||||
const makeEmptyForm = () => ({
|
||||
id: '', name: '', price: 0, discountPrice: 0, tagsText: '', coverUrl: '',
|
||||
screenshotUrls: createScreenshotSlots(), inventoryItems: createInventoryItems(),
|
||||
screenshotUrls: createScreenshotSlots(),
|
||||
paymentQrUrls: createPaymentQrSlots(),
|
||||
inventoryItems: createInventoryItems(),
|
||||
fulfillmentType: 'card', fixedContent: '',
|
||||
description: '', active: true, requireLogin: false, maxPerAccount: 0,
|
||||
deliveryMode: 'auto', showNote: true, showContact: true
|
||||
@@ -215,6 +234,7 @@ const fillForm = (item) => {
|
||||
id: item?.id || '', name: item?.name || '', price: item?.price || 0, discountPrice: item?.discountPrice || 0,
|
||||
tagsText: (item?.tags || []).join(','), coverUrl: item?.coverUrl || '',
|
||||
screenshotUrls: createScreenshotSlots(item?.screenshotUrls || []),
|
||||
paymentQrUrls: createPaymentQrSlots(item?.paymentQrUrls || []),
|
||||
inventoryItems: ft === 'fixed' ? [''] : createInventoryItems(item?.codes || []),
|
||||
fulfillmentType: ft,
|
||||
fixedContent: item?.fixedContent || '',
|
||||
@@ -259,6 +279,7 @@ const handleSubmit = () => {
|
||||
fulfillmentType: ft,
|
||||
fixedContent: ft === 'fixed' ? (form.fixedContent || '').trim() : '',
|
||||
screenshotUrls: normalizeScreenshotUrls(form.screenshotUrls),
|
||||
paymentQrUrls: normalizePaymentQrUrls(form.paymentQrUrls),
|
||||
description: form.description, active: form.active, requireLogin: form.requireLogin,
|
||||
maxPerAccount: form.maxPerAccount || 0, deliveryMode: form.deliveryMode || 'auto',
|
||||
showNote: form.showNote, showContact: form.showContact
|
||||
|
||||
@@ -55,12 +55,12 @@
|
||||
<td class="px-4 py-3.5 align-middle max-md:px-2.5 max-md:py-2">
|
||||
<div class="flex gap-1.5 items-center">
|
||||
<button
|
||||
class="px-3 py-1.5 rounded text-sm font-semibold border-none cursor-pointer transition bg-[rgba(145,168,208,0.15)] text-accent2 hover:bg-[rgba(145,168,208,0.28)] max-md:px-2 max-md:py-1 max-md:text-xs"
|
||||
class="px-3 py-1.5 rounded text-sm font-semibold border-none cursor-pointer transition bg-[rgba(217,168,120,0.16)] text-accent2 hover:bg-[rgba(217,168,120,0.28)] max-md:px-2 max-md:py-1 max-md:text-xs"
|
||||
style="font-family: var(--font-serif);"
|
||||
@click="$emit('edit', item)"
|
||||
>编辑</button>
|
||||
<button
|
||||
class="px-3 py-1.5 rounded text-sm font-semibold border-none cursor-pointer transition bg-[rgba(180,154,203,0.12)] text-accent hover:bg-[rgba(180,154,203,0.24)] max-md:px-2 max-md:py-1 max-md:text-xs"
|
||||
class="px-3 py-1.5 rounded text-sm font-semibold border-none cursor-pointer transition bg-[rgba(217,168,120,0.14)] text-accent hover:bg-[rgba(217,168,120,0.26)] max-md:px-2 max-md:py-1 max-md:text-xs"
|
||||
style="font-family: var(--font-serif);"
|
||||
@click="$emit('toggle', item)"
|
||||
>{{ item.active ? '下架' : '上架' }}</button>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<span
|
||||
v-if="message"
|
||||
class="text-sm px-2 py-0.5 rounded whitespace-nowrap"
|
||||
:class="message.includes('失败') ? 'text-[#c95a6a] bg-[rgba(201,90,106,0.08)]' : 'text-accent2 bg-[rgba(145,168,208,0.1)]'"
|
||||
:class="message.includes('失败') ? 'text-[#c95a6a] bg-[rgba(201,90,106,0.08)]' : 'text-accent2 bg-[rgba(217,168,120,0.1)]'"
|
||||
>{{ message }}</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</div>
|
||||
<p
|
||||
v-if="message"
|
||||
class="text-[15px] px-2.5 py-1 rounded bg-[rgba(145,168,208,0.1)] whitespace-nowrap"
|
||||
class="text-[15px] px-2.5 py-1 rounded bg-[rgba(217,168,120,0.12)] whitespace-nowrap"
|
||||
:class="message.includes('失败') || message.includes('错误') ? 'text-[#c95a6a] bg-[rgba(201,90,106,0.08)]' : 'text-accent2'"
|
||||
>{{ message }}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<section class="page-card">
|
||||
<section>
|
||||
<div class="flex justify-center py-12">
|
||||
<div v-if="status === 'loading'" class="text-center flex flex-col items-center gap-3">
|
||||
<h2>正在验证登录...</h2>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
<!-- Messages area -->
|
||||
<div
|
||||
class="flex-1 overflow-y-auto px-3 py-3.5 flex flex-col gap-3 bg-[#f8f6fb] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
class="flex-1 overflow-y-auto px-3 py-3.5 flex flex-col gap-3 bg-[#faf6f1] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
ref="messagesEl"
|
||||
>
|
||||
<div v-if="loading" class="text-center py-8">
|
||||
@@ -89,7 +89,7 @@
|
||||
<form class="flex items-center gap-2 px-3 py-2.5 border-t border-black/[0.06] bg-white flex-shrink-0" @submit.prevent="send">
|
||||
<input
|
||||
v-model="inputText"
|
||||
class="flex-1 px-3.5 py-2.5 rounded-full border border-[rgba(180,154,203,0.3)] text-sm bg-[#f8f6fb] text-apptext outline-none transition focus:border-[#aaa] focus:bg-white"
|
||||
class="flex-1 px-3.5 py-2.5 rounded-full border border-[rgba(217,168,120,0.35)] text-sm bg-[#faf6f1] text-apptext outline-none transition focus:border-[#b8a090] focus:bg-white"
|
||||
style="font-family: var(--font-serif);"
|
||||
placeholder="输入消息,Enter 发送…"
|
||||
maxlength="500"
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
<template>
|
||||
<Transition name="splash-fade" appear>
|
||||
<div v-if="visible" class="fixed inset-0 z-[99999] flex items-center justify-center overflow-hidden bg-gradient-to-br from-[#f8f5f2] via-[#e9f0f7] to-[#f4eaf1]">
|
||||
<div v-if="visible" class="fixed inset-0 z-[99999] flex items-center justify-center overflow-hidden bg-gradient-to-br from-[#faf7f2] via-[#f3e8dd] to-[#f8eae4]">
|
||||
<!-- Ambient glow blobs -->
|
||||
<div class="glow-blob absolute w-[420px] h-[420px] -top-20 -left-24 rounded-full blur-[80px] pointer-events-none" style="background: radial-gradient(circle, rgba(180,154,203,0.35) 0%, transparent 70%); animation: glow-pulse 3s ease-in-out infinite alternate;"></div>
|
||||
<div class="glow-blob absolute w-[380px] h-[380px] -bottom-16 -right-20 rounded-full blur-[80px] pointer-events-none" style="background: radial-gradient(circle, rgba(145,168,208,0.3) 0%, transparent 70%); animation: glow-pulse 3s ease-in-out 1.5s infinite alternate;"></div>
|
||||
<div class="glow-blob absolute w-[420px] h-[420px] -top-20 -left-24 rounded-full blur-[80px] pointer-events-none" style="background: radial-gradient(circle, rgba(217,168,120,0.38) 0%, transparent 70%); animation: glow-pulse 3s ease-in-out infinite alternate;"></div>
|
||||
<div class="glow-blob absolute w-[380px] h-[380px] -bottom-16 -right-20 rounded-full blur-[80px] pointer-events-none" style="background: radial-gradient(circle, rgba(184,109,92,0.28) 0%, transparent 70%); animation: glow-pulse 3s ease-in-out 1.5s infinite alternate;"></div>
|
||||
|
||||
<!-- Center content -->
|
||||
<div class="relative z-[1] flex flex-col items-center gap-0">
|
||||
<!-- Rings + logo -->
|
||||
<div class="relative w-40 h-40 flex items-center justify-center mb-7">
|
||||
<div class="ring-anim absolute rounded-full border-2 border-[rgba(180,154,203,0.4)]" style="animation-delay: 0s;"></div>
|
||||
<div class="ring-anim absolute rounded-full border-2 border-[rgba(180,154,203,0.4)]" style="animation-delay: 0.8s;"></div>
|
||||
<div class="ring-anim absolute rounded-full border-2 border-[rgba(180,154,203,0.4)]" style="animation-delay: 1.6s;"></div>
|
||||
<div class="ring-anim absolute rounded-full border-2 border-[rgba(217,168,120,0.45)]" style="animation-delay: 0s;"></div>
|
||||
<div class="ring-anim absolute rounded-full border-2 border-[rgba(217,168,120,0.45)]" style="animation-delay: 0.8s;"></div>
|
||||
<div class="ring-anim absolute rounded-full border-2 border-[rgba(217,168,120,0.45)]" style="animation-delay: 1.6s;"></div>
|
||||
<div class="relative z-[2]" style="animation: logo-float 2.8s ease-in-out infinite;">
|
||||
<img
|
||||
src="/pwa-192x192.png"
|
||||
alt="萌芽小店"
|
||||
class="w-[88px] h-[88px] rounded-[22px] object-cover shadow-[0_12px_40px_rgba(33,33,40,0.18),0_2px_8px_rgba(180,154,203,0.25)]"
|
||||
class="w-[88px] h-[88px] rounded-[22px] object-cover shadow-[0_12px_40px_rgba(42,28,22,0.14),0_2px_8px_rgba(217,168,120,0.28)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="text-[26px] font-extrabold text-[#2c2b2d] tracking-[2px] mb-1.5" style="font-family: var(--font-serif);">萌芽小店</h1>
|
||||
<p class="text-[13px] text-[#8a8690] tracking-[3px] mb-5">加载中</p>
|
||||
<h1 class="text-[26px] font-extrabold text-apptext tracking-[2px] mb-1.5" style="font-family: var(--font-serif);">萌芽小店</h1>
|
||||
<p class="text-[13px] text-muted tracking-[3px] mb-5">加载中</p>
|
||||
|
||||
<!-- Dots -->
|
||||
<div class="flex gap-2 items-center mt-1">
|
||||
@@ -47,8 +47,8 @@ defineProps({ visible: { type: Boolean, default: true } })
|
||||
}
|
||||
|
||||
@keyframes ring-expand {
|
||||
0% { width: 88px; height: 88px; opacity: 0.8; border-color: rgba(180,154,203,0.5); }
|
||||
100% { width: 200px; height: 200px; opacity: 0; border-color: rgba(180,154,203,0); }
|
||||
0% { width: 88px; height: 88px; opacity: 0.8; border-color: rgba(217,168,120,0.5); }
|
||||
100% { width: 200px; height: 200px; opacity: 0; border-color: rgba(217,168,120,0); }
|
||||
}
|
||||
|
||||
@keyframes logo-float {
|
||||
|
||||
@@ -9,43 +9,56 @@ const api = axios.create({
|
||||
baseURL: apiBaseURL
|
||||
})
|
||||
|
||||
// 管理员请求头构建辅助函数,使用 X-Admin-Token 请求头。
|
||||
// 后端保留 ?token= 查询参数作为旧版兼容,新请求统一使用请求头以兼容 Spring Security。
|
||||
const adminHeaders = (token) => ({ 'X-Admin-Token': token })
|
||||
|
||||
const authApi = axios.create({
|
||||
baseURL: 'https://auth.api.shumengya.top'
|
||||
})
|
||||
|
||||
/** 后端常规信封 `{ data: { data: T } }` 解包 */
|
||||
function unwrapData(res) {
|
||||
return res?.data?.data
|
||||
}
|
||||
|
||||
export const fetchProducts = async () => {
|
||||
const { data } = await api.get('/api/products')
|
||||
return data.data || []
|
||||
const res = await api.get('/api/products')
|
||||
return unwrapData(res) || []
|
||||
}
|
||||
|
||||
export const recordProductView = async (id) => {
|
||||
const { data } = await api.post(`/api/products/${id}/view`)
|
||||
return data.data || {}
|
||||
const res = await api.post(`/api/products/${id}/view`)
|
||||
return unwrapData(res) || {}
|
||||
}
|
||||
|
||||
export const createOrder = async (payload, authToken) => {
|
||||
const headers = authToken ? { Authorization: `Bearer ${authToken}` } : {}
|
||||
const { data } = await api.post('/api/checkout', payload, { headers })
|
||||
return data.data || {}
|
||||
const res = await api.post('/api/checkout', payload, { headers })
|
||||
return unwrapData(res) || {}
|
||||
}
|
||||
|
||||
export const fetchOrderPaymentStatus = async (orderId) => {
|
||||
const res = await api.get(`/api/orders/${encodeURIComponent(orderId)}/payment-status`)
|
||||
return unwrapData(res) || {}
|
||||
}
|
||||
|
||||
export const confirmOrder = async (orderId) => {
|
||||
const { data } = await api.post(`/api/orders/${orderId}/confirm`)
|
||||
return data.data || {}
|
||||
const res = await api.post(`/api/orders/${orderId}/confirm`)
|
||||
return unwrapData(res) || {}
|
||||
}
|
||||
|
||||
export const cancelOrder = async (orderId) => {
|
||||
const res = await api.post(`/api/orders/${orderId}/cancel`)
|
||||
return unwrapData(res) || {}
|
||||
}
|
||||
|
||||
export const fetchStats = async () => {
|
||||
const { data } = await api.get('/api/stats')
|
||||
return data.data || {}
|
||||
const res = await api.get('/api/stats')
|
||||
return unwrapData(res) || {}
|
||||
}
|
||||
|
||||
export const recordSiteVisit = async () => {
|
||||
const { data } = await api.post('/api/site/visit')
|
||||
return data.data || {}
|
||||
const res = await api.post('/api/site/visit')
|
||||
return unwrapData(res) || {}
|
||||
}
|
||||
|
||||
export const verifySproutGateToken = async (token) => {
|
||||
@@ -61,10 +74,10 @@ export const fetchSproutGateUser = async (token) => {
|
||||
}
|
||||
|
||||
export const fetchMyOrders = async (authToken) => {
|
||||
const { data } = await api.get('/api/orders', {
|
||||
const res = await api.get('/api/orders', {
|
||||
headers: { Authorization: `Bearer ${authToken}` }
|
||||
})
|
||||
return data.data || []
|
||||
return unwrapData(res) || []
|
||||
}
|
||||
|
||||
export const verifyAdminToken = async (token) => {
|
||||
@@ -73,29 +86,29 @@ export const verifyAdminToken = async (token) => {
|
||||
}
|
||||
|
||||
export const fetchAdminProducts = async (token) => {
|
||||
const { data } = await api.get('/api/admin/products', { headers: adminHeaders(token) })
|
||||
return data.data || []
|
||||
const res = await api.get('/api/admin/products', { headers: adminHeaders(token) })
|
||||
return unwrapData(res) || []
|
||||
}
|
||||
|
||||
export const createProduct = async (token, payload) => {
|
||||
const { data } = await api.post('/api/admin/products', payload, {
|
||||
const res = await api.post('/api/admin/products', payload, {
|
||||
headers: adminHeaders(token)
|
||||
})
|
||||
return data.data
|
||||
return unwrapData(res)
|
||||
}
|
||||
|
||||
export const updateProduct = async (token, id, payload) => {
|
||||
const { data } = await api.put(`/api/admin/products/${id}`, payload, {
|
||||
const res = await api.put(`/api/admin/products/${id}`, payload, {
|
||||
headers: adminHeaders(token)
|
||||
})
|
||||
return data.data
|
||||
return unwrapData(res)
|
||||
}
|
||||
|
||||
export const toggleProduct = async (token, id, active) => {
|
||||
const { data } = await api.patch(`/api/admin/products/${id}/status`, { active }, {
|
||||
const res = await api.patch(`/api/admin/products/${id}/status`, { active }, {
|
||||
headers: adminHeaders(token)
|
||||
})
|
||||
return data.data
|
||||
return unwrapData(res)
|
||||
}
|
||||
|
||||
export const deleteProduct = async (token, id) => {
|
||||
@@ -106,29 +119,29 @@ export const deleteProduct = async (token, id) => {
|
||||
}
|
||||
|
||||
export const fetchWishlist = async (token) => {
|
||||
const { data } = await api.get('/api/wishlist', {
|
||||
const res = await api.get('/api/wishlist', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
return data.data?.items || []
|
||||
return unwrapData(res)?.items || []
|
||||
}
|
||||
|
||||
export const addToWishlist = async (token, productId) => {
|
||||
const { data } = await api.post('/api/wishlist', { productId }, {
|
||||
const res = await api.post('/api/wishlist', { productId }, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
return data.data?.items || []
|
||||
return unwrapData(res)?.items || []
|
||||
}
|
||||
|
||||
export const removeFromWishlist = async (token, productId) => {
|
||||
const { data } = await api.delete(`/api/wishlist/${productId}`, {
|
||||
const res = await api.delete(`/api/wishlist/${productId}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
return data.data?.items || []
|
||||
return unwrapData(res)?.items || []
|
||||
}
|
||||
|
||||
export const fetchAdminOrders = async (token) => {
|
||||
const { data } = await api.get('/api/admin/orders', { headers: adminHeaders(token) })
|
||||
return data.data || []
|
||||
const res = await api.get('/api/admin/orders', { headers: adminHeaders(token) })
|
||||
return unwrapData(res) || []
|
||||
}
|
||||
|
||||
export const deleteAdminOrder = async (token, orderId) => {
|
||||
@@ -136,63 +149,60 @@ export const deleteAdminOrder = async (token, orderId) => {
|
||||
}
|
||||
|
||||
export const fetchSiteMaintenance = async () => {
|
||||
const { data } = await api.get('/api/site/maintenance')
|
||||
return data.data || { maintenance: false, reason: '' }
|
||||
const res = await api.get('/api/site/maintenance')
|
||||
return unwrapData(res) || { maintenance: false, reason: '' }
|
||||
}
|
||||
|
||||
export const setSiteMaintenance = async (token, maintenance, reason) => {
|
||||
const { data } = await api.post('/api/admin/site/maintenance', { maintenance, reason }, {
|
||||
const res = await api.post('/api/admin/site/maintenance', { maintenance, reason }, {
|
||||
headers: adminHeaders(token)
|
||||
})
|
||||
return data.data || {}
|
||||
return unwrapData(res) || {}
|
||||
}
|
||||
|
||||
// ---- SMTP Config ----
|
||||
export const fetchSMTPConfig = async (token) => {
|
||||
const { data } = await api.get('/api/admin/site/smtp', { headers: adminHeaders(token) })
|
||||
return data.data || {}
|
||||
const res = await api.get('/api/admin/site/smtp', { headers: adminHeaders(token) })
|
||||
return unwrapData(res) || {}
|
||||
}
|
||||
|
||||
export const setSMTPConfig = async (token, cfg) => {
|
||||
const { data } = await api.post('/api/admin/site/smtp', cfg, { headers: adminHeaders(token) })
|
||||
return data.data
|
||||
const res = await api.post('/api/admin/site/smtp', cfg, { headers: adminHeaders(token) })
|
||||
return unwrapData(res)
|
||||
}
|
||||
|
||||
// ---- Chat (user) ----
|
||||
export const fetchMyChatMessages = async (userToken) => {
|
||||
const { data } = await api.get('/api/chat/messages', {
|
||||
const res = await api.get('/api/chat/messages', {
|
||||
headers: { Authorization: `Bearer ${userToken}` }
|
||||
})
|
||||
return data.data?.messages || []
|
||||
return unwrapData(res)?.messages || []
|
||||
}
|
||||
|
||||
export const sendChatMessage = async (userToken, content) => {
|
||||
const { data } = await api.post('/api/chat/messages', { content }, {
|
||||
const res = await api.post('/api/chat/messages', { content }, {
|
||||
headers: { Authorization: `Bearer ${userToken}` }
|
||||
})
|
||||
return data.data?.message || null
|
||||
return unwrapData(res)?.message || null
|
||||
}
|
||||
|
||||
// ---- Chat (admin) ----
|
||||
export const fetchAdminAllConversations = async (adminToken) => {
|
||||
const { data } = await api.get('/api/admin/chat', { headers: adminHeaders(adminToken) })
|
||||
return data.data?.conversations || {}
|
||||
const res = await api.get('/api/admin/chat', { headers: adminHeaders(adminToken) })
|
||||
return unwrapData(res)?.conversations || {}
|
||||
}
|
||||
|
||||
export const fetchAdminConversation = async (adminToken, account) => {
|
||||
const { data } = await api.get(`/api/admin/chat/${encodeURIComponent(account)}`, {
|
||||
const res = await api.get(`/api/admin/chat/${encodeURIComponent(account)}`, {
|
||||
headers: adminHeaders(adminToken)
|
||||
})
|
||||
return data.data?.messages || []
|
||||
return unwrapData(res)?.messages || []
|
||||
}
|
||||
|
||||
export const adminSendChatReply = async (adminToken, account, content) => {
|
||||
const { data } = await api.post(
|
||||
const res = await api.post(
|
||||
`/api/admin/chat/${encodeURIComponent(account)}`,
|
||||
{ content },
|
||||
{ headers: adminHeaders(adminToken) }
|
||||
)
|
||||
return data.data?.message || null
|
||||
return unwrapData(res)?.message || null
|
||||
}
|
||||
|
||||
export const adminClearConversation = async (adminToken, account) => {
|
||||
@@ -201,8 +211,7 @@ export const adminClearConversation = async (adminToken, account) => {
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Admin system status(后端 / MySQL / Redis / RabbitMQ)----
|
||||
export const fetchSystemStatus = async (adminToken) => {
|
||||
const { data } = await api.get('/api/admin/system-status', { headers: adminHeaders(adminToken) })
|
||||
return data.data || {}
|
||||
const res = await api.get('/api/admin/system-status', { headers: adminHeaders(adminToken) })
|
||||
return unwrapData(res) || {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="open"
|
||||
class="admin-modal fixed inset-0 z-[1000] flex items-center justify-center bg-apptext/35 backdrop-blur-lg"
|
||||
@click.self="close"
|
||||
>
|
||||
<div class="admin-modal relative w-[360px] max-w-[90vw] px-8 pt-9 pb-7 glass-strong rounded-xl shadow-[0_24px_60px_rgba(33,33,40,0.22)] text-center">
|
||||
<button
|
||||
class="absolute top-3 right-4 bg-transparent border-none text-[22px] text-muted hover:text-apptext cursor-pointer px-2 py-1 rounded leading-none transition-colors"
|
||||
@click="close"
|
||||
>×</button>
|
||||
<div class="inline-flex items-center justify-center w-14 h-14 rounded-[10px] bg-gradient-to-br from-accent to-accent2 text-white mb-4">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
</div>
|
||||
<h3 class="text-[22px] mb-1.5">管理员验证</h3>
|
||||
<p class="text-[15px] text-muted mb-5">请输入管理员令牌以访问后台</p>
|
||||
<form @submit.prevent="emit('submit')">
|
||||
<input
|
||||
ref="inputRef"
|
||||
:value="token"
|
||||
type="password"
|
||||
class="w-full px-4 py-3 rounded-lg border border-white/35 bg-white/65 text-center tracking-widest text-[18px] outline-none transition focus:border-accent focus:shadow-[0_0_0_3px_rgba(217,168,120,0.22)]"
|
||||
style="font-family: var(--font-serif);"
|
||||
placeholder="输入 Token…"
|
||||
autocomplete="off"
|
||||
@input="emit('update:token', $event.target.value)"
|
||||
/>
|
||||
<p v-if="error" class="text-[15px] text-[#d4566a] mt-2">{{ error }}</p>
|
||||
<button type="submit" class="primary w-full mt-4 py-3 text-[17px]" :disabled="!token.trim()">
|
||||
进入后台
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
token: { type: String, default: '' },
|
||||
error: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:open', 'update:token', 'submit'])
|
||||
|
||||
const inputRef = ref(null)
|
||||
|
||||
function close() {
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(o) => {
|
||||
if (o) nextTick(() => inputRef.value?.focus())
|
||||
}
|
||||
)
|
||||
</script>
|
||||
45
mengyastore-frontend/src/modules/shell/AppSiteFooter.vue
Normal file
45
mengyastore-frontend/src/modules/shell/AppSiteFooter.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<footer
|
||||
:class="embedded
|
||||
? 'pt-5 pb-1 mt-4 border-t border-white/35 max-md:pt-4 max-md:mt-3'
|
||||
: 'mt-3 px-[26px] py-6 glass rounded-[var(--radius-default)] shadow-[0_-4px_30px_rgba(33,33,40,0.06)] max-md:px-3.5 max-md:py-5'"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-3.5">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<img src="/pwa-192x192.png" alt="萌芽小店" class="w-7 h-7 rounded-[5px] object-contain border border-white/50 shadow-[0_4px_12px_rgba(33,33,40,0.1)]" />
|
||||
<span class="text-[19px] font-semibold text-apptext tracking-[0.5px]" style="font-family: var(--font-serif);">萌芽小店</span>
|
||||
</div>
|
||||
|
||||
<div class="w-10 h-0.5 rounded-full bg-gradient-to-r from-accent to-accent2 opacity-50"></div>
|
||||
|
||||
<div class="flex items-center gap-6 flex-wrap justify-center max-md:flex-row max-md:flex-wrap max-md:gap-2.5 max-md:justify-center">
|
||||
<a
|
||||
class="inline-flex items-center gap-1.5 text-[#7a4a38] no-underline text-[15px] font-semibold px-3 py-1.5 rounded bg-[rgba(217,168,120,0.14)] hover:bg-[rgba(217,168,120,0.26)] transition-colors"
|
||||
href="mailto:mail@smyhub.com"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>
|
||||
mail@smyhub.com
|
||||
</a>
|
||||
<span v-if="statsLoaded" class="inline-flex items-center gap-1.5 text-[15px] font-semibold text-[#8b4a58] px-3 py-1.5 rounded bg-[rgba(200,130,120,0.14)]">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20V10"/><path d="M18 20V4"/><path d="M6 20v-4"/></svg>
|
||||
累计下单 {{ totalOrders }} 次
|
||||
</span>
|
||||
<span v-if="statsLoaded" class="inline-flex items-center gap-1.5 text-[15px] font-semibold text-[#7d523c] px-3 py-1.5 rounded bg-[rgba(217,168,120,0.14)]">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
累计访问 {{ totalVisits }} 次
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-[14px] text-apptext/90 tracking-[0.3px]">© 2025 – 2026 萌芽小店 · All rights reserved</p>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
embedded: { type: Boolean, default: false },
|
||||
statsLoaded: { type: Boolean, default: false },
|
||||
totalOrders: { type: Number, default: 0 },
|
||||
totalVisits: { type: Number, default: 0 }
|
||||
})
|
||||
</script>
|
||||
271
mengyastore-frontend/src/modules/shell/AppTopNav.vue
Normal file
271
mengyastore-frontend/src/modules/shell/AppTopNav.vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<div
|
||||
v-show="mobileNavOpen"
|
||||
class="fixed inset-0 z-[98] bg-apptext/20 backdrop-blur-[2px] md:hidden motion-safe:transition-opacity"
|
||||
aria-hidden="true"
|
||||
@click="closeMobileNav"
|
||||
/>
|
||||
|
||||
<div class="sticky top-0 z-[100] relative">
|
||||
<header
|
||||
class="flex items-center justify-between gap-2 min-h-[52px] px-[5vw] py-2.5 glass-strong border-b border-white/35 shadow-[0_4px_24px_rgba(33,33,40,0.08)] md:min-h-0 md:py-2.5 md:gap-3 max-md:px-3 max-md:py-2"
|
||||
>
|
||||
<div
|
||||
class="flex gap-4 items-center cursor-pointer select-none min-w-0 flex-1 md:flex-initial md:gap-4 max-md:gap-2"
|
||||
@click="onLogoClick"
|
||||
>
|
||||
<div class="w-10 h-10 md:w-11 md:h-11 rounded-[10px] overflow-hidden border border-white/60 glass-strong shadow-[0_12px_24px_rgba(33,33,40,0.18)] flex items-center justify-center shrink-0 max-md:w-9 max-md:h-9">
|
||||
<img src="/pwa-192x192.png" alt="萌芽小店" draggable="false" class="w-full h-full object-contain" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-[20px] md:text-[22px] tracking-wide truncate max-md:text-[15px] max-md:font-semibold text-apptext">萌芽小店</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden md:flex gap-3 items-center shrink-0">
|
||||
<button type="button" class="ghost" @click="goHome">商店</button>
|
||||
<template v-if="loggedIn">
|
||||
<button type="button" class="ghost relative inline-flex items-center gap-1" @click="goWishlist">
|
||||
<span>☆</span>
|
||||
收藏夹
|
||||
<span
|
||||
v-if="wishlistCount > 0"
|
||||
class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-gradient-to-br from-accent to-accent2 text-white text-[11px] font-bold leading-none"
|
||||
>{{ wishlistCount }}</span>
|
||||
</button>
|
||||
<button type="button" class="ghost" @click="goMyOrders">我的订单</button>
|
||||
<div
|
||||
class="flex items-center gap-2 px-3 py-1.5 rounded-full bg-white/60 border border-white/35 cursor-pointer hover:bg-white/85 transition-colors"
|
||||
@click="goProfile"
|
||||
>
|
||||
<img
|
||||
v-if="authState.avatarUrl"
|
||||
class="w-7 h-7 rounded-full object-cover border border-white/35"
|
||||
:src="authState.avatarUrl"
|
||||
:alt="authState.username"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-7 h-7 rounded-full flex items-center justify-center bg-gradient-to-br from-accent to-accent2 text-white text-[15px] font-semibold border border-white/35"
|
||||
>
|
||||
{{ (authState.username || authState.account || '?').charAt(0) }}
|
||||
</div>
|
||||
<span class="text-[17px] font-bold text-apptext max-w-[100px] overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{ authState.username || authState.account }}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" class="ghost" @click="logout">退出</button>
|
||||
</template>
|
||||
<a
|
||||
v-else
|
||||
class="inline-flex items-center px-[18px] py-[10px] rounded-lg bg-gradient-to-br from-accent to-accent2 text-white no-underline text-[17px] shadow-[0_10px_30px_rgba(184,109,92,0.32)] transition-transform hover:-translate-y-px"
|
||||
:href="loginUrl"
|
||||
>萌芽账号登录</a>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="md:hidden flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-xl border border-white/50 bg-white/60 text-apptext shadow-sm tap-target"
|
||||
:aria-expanded="mobileNavOpen"
|
||||
:aria-label="mobileNavOpen ? '关闭菜单' : '打开菜单'"
|
||||
@click="mobileNavOpen = !mobileNavOpen"
|
||||
>
|
||||
<svg v-if="!mobileNavOpen" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M4 6h16M4 12h16M4 18h16"/>
|
||||
</svg>
|
||||
<svg v-else width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="opacity-0 -translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition ease-in duration-150"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 -translate-y-2"
|
||||
>
|
||||
<nav
|
||||
v-if="mobileNavOpen"
|
||||
class="md:hidden absolute left-0 right-0 top-full z-[101] border-t border-white/35 glass-strong shadow-[0_16px_40px_rgba(33,33,40,0.14)] max-h-[min(75vh,560px)] overflow-y-auto overscroll-contain px-3 py-3 pb-[max(16px,env(safe-area-inset-bottom))]"
|
||||
@click.stop
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<button type="button" class="app-mobile-nav-btn" @click="navGoHome">商店</button>
|
||||
<template v-if="loggedIn">
|
||||
<button type="button" class="app-mobile-nav-btn flex items-center justify-between gap-3" @click="navGoWishlist">
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<span class="text-accent">☆</span>
|
||||
收藏夹
|
||||
</span>
|
||||
<span
|
||||
v-if="wishlistCount > 0"
|
||||
class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-full bg-gradient-to-br from-accent to-accent2 text-white text-[12px] font-bold"
|
||||
>{{ wishlistCount }}</span>
|
||||
</button>
|
||||
<button type="button" class="app-mobile-nav-btn" @click="navGoMyOrders">我的订单</button>
|
||||
<button type="button" class="app-mobile-nav-btn flex items-center gap-3 text-left min-w-0" @click="navGoProfile">
|
||||
<img
|
||||
v-if="authState.avatarUrl"
|
||||
class="w-9 h-9 rounded-full object-cover border border-white/35 shrink-0"
|
||||
:src="authState.avatarUrl"
|
||||
alt=""
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-9 h-9 rounded-full flex items-center justify-center bg-gradient-to-br from-accent to-accent2 text-white text-sm font-semibold shrink-0"
|
||||
>
|
||||
{{ (authState.username || authState.account || '?').charAt(0) }}
|
||||
</div>
|
||||
<span class="font-bold truncate min-w-0">{{ authState.username || authState.account }}</span>
|
||||
<span class="text-muted text-[13px] shrink-0 ml-auto">个人主页</span>
|
||||
</button>
|
||||
<button type="button" class="app-mobile-nav-btn !text-[#b84d5c] !border-[rgba(184,77,92,0.25)]" @click="navLogout">
|
||||
退出登录
|
||||
</button>
|
||||
</template>
|
||||
<a
|
||||
v-else
|
||||
class="app-mobile-nav-btn app-mobile-nav-btn-primary text-center no-underline"
|
||||
:href="loginUrl"
|
||||
@click="closeMobileNav"
|
||||
>萌芽账号登录</a>
|
||||
</div>
|
||||
</nav>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { authState, clearAuth } from '../shared/auth'
|
||||
|
||||
defineProps({
|
||||
loggedIn: { type: Boolean, required: true },
|
||||
loginUrl: { type: String, required: true },
|
||||
wishlistCount: { type: Number, default: 0 }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['admin-unlock'])
|
||||
|
||||
const router = useRouter()
|
||||
const mobileNavOpen = ref(false)
|
||||
|
||||
const CLICK_THRESHOLD = 5
|
||||
const CLICK_WINDOW_MS = 2000
|
||||
let logoClickCount = 0
|
||||
let logoClickTimer = null
|
||||
|
||||
function onLogoClick() {
|
||||
logoClickCount++
|
||||
clearTimeout(logoClickTimer)
|
||||
if (logoClickCount >= CLICK_THRESHOLD) {
|
||||
logoClickCount = 0
|
||||
emit('admin-unlock')
|
||||
return
|
||||
}
|
||||
logoClickTimer = setTimeout(() => {
|
||||
logoClickCount = 0
|
||||
}, CLICK_WINDOW_MS)
|
||||
}
|
||||
|
||||
const closeMobileNav = () => {
|
||||
mobileNavOpen.value = false
|
||||
}
|
||||
|
||||
const goHome = () => router.push('/')
|
||||
const goMyOrders = () => router.push('/my/orders')
|
||||
const goWishlist = () => router.push('/wishlist')
|
||||
|
||||
const goProfile = () => {
|
||||
if (authState.account) {
|
||||
window.open(`https://auth.shumengya.top/user/${authState.account}`, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
clearAuth()
|
||||
}
|
||||
|
||||
const navGoHome = () => {
|
||||
closeMobileNav()
|
||||
goHome()
|
||||
}
|
||||
const navGoWishlist = () => {
|
||||
closeMobileNav()
|
||||
goWishlist()
|
||||
}
|
||||
const navGoMyOrders = () => {
|
||||
closeMobileNav()
|
||||
goMyOrders()
|
||||
}
|
||||
const navGoProfile = () => {
|
||||
closeMobileNav()
|
||||
goProfile()
|
||||
}
|
||||
const navLogout = () => {
|
||||
closeMobileNav()
|
||||
logout()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => router.currentRoute.value.fullPath,
|
||||
() => closeMobileNav()
|
||||
)
|
||||
|
||||
watch(mobileNavOpen, (open) => {
|
||||
if (typeof document === 'undefined') return
|
||||
document.body.style.overflow = open ? 'hidden' : ''
|
||||
})
|
||||
|
||||
const onNavEscape = (e) => {
|
||||
if (e.key === 'Escape') closeMobileNav()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', onNavEscape)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', onNavEscape)
|
||||
if (typeof document !== 'undefined') document.body.style.overflow = ''
|
||||
clearTimeout(logoClickTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-mobile-nav-btn {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
text-align: left;
|
||||
padding: 0.875rem 1rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--line-color);
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
color: var(--color-apptext);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-serif);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, transform 0.12s ease;
|
||||
}
|
||||
.app-mobile-nav-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
.app-mobile-nav-btn:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
.app-mobile-nav-btn-primary {
|
||||
background: linear-gradient(135deg, var(--color-accent), var(--color-accent2));
|
||||
color: #fff !important;
|
||||
border-color: transparent;
|
||||
box-shadow: 0 8px 24px rgba(184, 109, 92, 0.32);
|
||||
}
|
||||
.tap-target {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,210 +1,99 @@
|
||||
<template>
|
||||
<section class="page-card checkout-wrap max-w-full overflow-x-hidden min-w-0" v-if="!loading && product">
|
||||
<!-- Header:窄屏纵向排列,避免与标题抢宽度 -->
|
||||
<div class="flex flex-col gap-3 mb-5 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
|
||||
<button type="button" class="ghost shrink-0 self-start w-full sm:w-auto text-center" @click="goBack">返回商品</button>
|
||||
<section class="checkout-wrap max-w-full overflow-x-hidden min-w-0" v-if="!loading && product">
|
||||
<div class="mx-auto w-full max-w-[min(100%,760px)] mb-2 flex">
|
||||
<button type="button" class="ghost shrink-0 self-start w-auto px-3 py-1.5 text-sm sm:text-[15px]" @click="goBack">返回商品</button>
|
||||
</div>
|
||||
|
||||
<!-- Grid:禁止内联 columns(会盖掉 max-md 单列);小屏单列,lg 起双列 -->
|
||||
<div class="grid grid-cols-1 gap-6 mb-5 min-w-0 lg:grid-cols-[minmax(0,1fr)_320px] lg:items-start">
|
||||
<!-- Summary -->
|
||||
<div class="flex flex-col gap-[18px] bg-white/80 rounded-[var(--radius-default)] border border-white/35 p-4 min-w-0 sm:flex-row sm:p-[18px] sm:items-start">
|
||||
<img
|
||||
:src="product.coverUrl"
|
||||
:alt="product.name"
|
||||
class="w-full max-w-[200px] h-[160px] sm:h-[140px] object-cover rounded-lg mx-auto sm:mx-0 sm:w-[140px] sm:max-w-none sm:shrink-0"
|
||||
<div
|
||||
v-if="!orderResult"
|
||||
class="mx-auto w-full max-w-[min(100%,760px)] mb-3 flex flex-col gap-3"
|
||||
>
|
||||
<div class="min-w-0 w-full">
|
||||
<CheckoutProductSummary :product="product" />
|
||||
</div>
|
||||
<div class="min-w-0 w-full">
|
||||
<CheckoutOrderFormPanel
|
||||
:product="product"
|
||||
:form="form"
|
||||
:logged-in="loggedIn"
|
||||
:login-url="loginUrl"
|
||||
:submitting="submitting"
|
||||
:error="error"
|
||||
@submit="submitOrder"
|
||||
/>
|
||||
<div class="flex-1 flex flex-col gap-2 min-w-0 w-full">
|
||||
<h3>{{ product.name }}</h3>
|
||||
<p class="tag">库存:{{ stockLabel(product) }}</p>
|
||||
<p class="tag">浏览量:{{ product.viewCount || 0 }}</p>
|
||||
<p class="font-semibold text-accent2">
|
||||
<span v-if="isFree" class="text-[#3a9a68] font-black">免费</span>
|
||||
<span v-else-if="product.discountPrice > 0 && product.discountPrice < product.price">
|
||||
<span class="line-through text-muted mr-1.5 text-[15px]">¥ {{ product.price.toFixed(2) }}</span>
|
||||
¥ {{ product.discountPrice.toFixed(2) }}
|
||||
</span>
|
||||
<span v-else>¥ {{ product.price.toFixed(2) }}</span>
|
||||
</p>
|
||||
<p class="markdown break-words overflow-x-auto max-w-full" v-html="renderMarkdown(product.description)"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Require-login gate -->
|
||||
<div v-if="!orderResult && product.requireLogin && !loggedIn" class="flex flex-col gap-4 items-center p-8 text-center border border-white/35 rounded-lg bg-white/60">
|
||||
<p class="text-[18px] font-semibold text-apptext">该商品需要登录后才能购买</p>
|
||||
<a class="primary inline-block no-underline px-7 py-2.5 rounded-lg text-base font-bold cursor-pointer" :href="loginUrl">立即登录</a>
|
||||
</div>
|
||||
|
||||
<!-- Order form -->
|
||||
<form v-else-if="!orderResult" class="flex flex-col gap-3 min-w-0 w-full" @submit.prevent="submitOrder">
|
||||
<div class="form-field">
|
||||
<label>下单数量</label>
|
||||
<input
|
||||
v-model.number="form.quantity"
|
||||
min="1"
|
||||
:max="maxOrderQty"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
<p class="tag">预计总价:¥ {{ totalPrice.toFixed(2) }}</p>
|
||||
<p class="tag text-accent" v-if="product.maxPerAccount > 0">
|
||||
该商品每个账户最多可购买 {{ product.maxPerAccount }} 个
|
||||
</p>
|
||||
|
||||
<div class="form-field">
|
||||
<label>备注(选填)</label>
|
||||
<textarea v-model="form.note" placeholder="填写您的备注信息…" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
|
||||
<div class="form-field">
|
||||
<label>手机号(选填)</label>
|
||||
<input v-model="form.contactPhone" type="tel" placeholder="138xxxx0000" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>邮箱(选填)</label>
|
||||
<input v-model="form.contactEmail" type="email" placeholder="you@example.com" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="tag" v-if="!loggedIn">
|
||||
<a class="text-accent2 no-underline font-semibold hover:underline" :href="loginUrl">登录萌芽账号</a> 后购买可享受:历史订单记录、专属优惠商品及更多购买权益
|
||||
</p>
|
||||
|
||||
<button class="primary w-full" type="submit" :disabled="submitting">
|
||||
{{ submitting ? '生成中...' : '生成二维码下单' }}
|
||||
</button>
|
||||
<p class="text-[#d64848] text-[15px]" v-if="error">{{ error }}</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Order result -->
|
||||
<section v-if="orderResult" class="mt-4 text-left sm:text-center max-w-full min-w-0 px-0">
|
||||
<h4 class="text-base sm:text-lg font-bold mb-2 break-words leading-snug">
|
||||
订单已创建
|
||||
<span class="mt-2 flex flex-wrap items-center gap-2 justify-start sm:justify-center">
|
||||
<span class="font-mono text-[12px] sm:text-[13px] font-normal text-muted break-all text-left max-w-full">{{ orderResult.orderId }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="checkout-copy-id shrink-0 text-[13px] font-semibold px-3 py-1.5 rounded-lg border border-white/50 bg-white/70 text-apptext hover:bg-white/90 active:scale-[0.98] transition-colors"
|
||||
@click="copyOrderId"
|
||||
>{{ orderIdCopyHint || '复制订单号' }}</button>
|
||||
</span>
|
||||
</h4>
|
||||
<div
|
||||
v-if="orderResult && !confirmed && needPayChannel && isMengyaPendingPay"
|
||||
class="flex items-center gap-3 rounded-xl border border-white/40 bg-white/75 px-3 py-2.5 mb-4 max-w-lg mx-auto sm:max-w-none"
|
||||
>
|
||||
<img
|
||||
:src="product.coverUrl"
|
||||
:alt="product.name"
|
||||
class="w-12 h-12 sm:w-14 sm:h-14 shrink-0 object-cover rounded-lg border border-white/30"
|
||||
/>
|
||||
<div class="min-w-0 flex-1 text-left">
|
||||
<p class="font-bold text-apptext text-[15px] sm:text-base leading-tight truncate m-0">{{ product.name }}</p>
|
||||
<p class="tag m-0 mt-0.5">应付 <span class="font-semibold text-accent2">¥ {{ paidAmountSnapshot.toFixed(2) }}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="!confirmed">
|
||||
<!-- 付费:真实收款码模拟(资产见 src/assets/payment) -->
|
||||
<template v-if="needPayChannel">
|
||||
<p class="text-apptext text-base sm:text-lg font-bold mb-2">
|
||||
应付金额
|
||||
<span class="text-accent2 text-[#4a6fa8]">¥ {{ paidAmountSnapshot.toFixed(2) }}</span>
|
||||
</p>
|
||||
<p class="tag mb-3 leading-relaxed text-[14px] sm:text-[15px] text-apptext/90 max-w-[40rem] mx-auto sm:mx-auto">
|
||||
请使用下方<strong>支付宝</strong>或<strong>微信</strong>扫码支付;<strong>转账备注请务必填写订单号</strong>,便于核对。
|
||||
</p>
|
||||
<div class="tag mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3 rounded-lg bg-white/40 border border-white/30 px-3 py-2.5 text-left">
|
||||
<p class="font-mono text-[12px] sm:text-[13px] break-all m-0 flex-1 min-w-0 text-apptext">
|
||||
<span class="font-sans font-semibold text-muted">订单号:</span>{{ orderResult.orderId }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="checkout-copy-id shrink-0 w-full sm:w-auto text-[13px] font-semibold px-3 py-2 sm:py-1.5 rounded-lg border border-accent2/40 bg-white/80 text-accent2 hover:bg-white active:scale-[0.98] transition-colors"
|
||||
@click="copyOrderId"
|
||||
>{{ orderIdCopyHint || '一键复制' }}</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-2 w-full max-w-md mx-auto mb-4 sm:max-w-none sm:flex-row sm:flex-wrap sm:justify-center sm:gap-2"
|
||||
role="tablist"
|
||||
aria-label="收款渠道"
|
||||
>
|
||||
<button
|
||||
v-for="ch in payChannels"
|
||||
:key="ch.id"
|
||||
type="button"
|
||||
role="tab"
|
||||
class="w-full sm:w-auto sm:min-w-[7.5rem] px-4 py-3 sm:py-2 rounded-xl border text-[15px] font-semibold transition-colors text-left sm:text-center touch-manipulation"
|
||||
:class="payChannel === ch.id
|
||||
? 'border-accent2 bg-white/90 text-accent2 shadow-sm ring-2 ring-accent2/25'
|
||||
: 'border-white/45 bg-white/55 text-apptext active:bg-white/80'"
|
||||
:aria-selected="payChannel === ch.id"
|
||||
@click="payChannel = ch.id"
|
||||
>
|
||||
<span class="block">{{ ch.label }}</span>
|
||||
<span class="block text-[12px] font-normal text-muted mt-0.5">{{ ch.hint }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center my-3">
|
||||
<img
|
||||
:src="currentPayQrSrc"
|
||||
:alt="currentPayQrAlt"
|
||||
class="max-w-[min(300px,88vw)] w-full rounded-xl border border-white/40 shadow-[0_8px_28px_rgba(33,33,40,0.12)] bg-white/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="orderResult.qrCodeUrl" class="tag mb-3 opacity-80">
|
||||
<span class="sr-only">系统订单凭证</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-accent2 underline-offset-2 hover:underline bg-transparent border-none cursor-pointer p-0 font-inherit text-[13px]"
|
||||
@click="showOrderTokenQr = !showOrderTokenQr"
|
||||
>{{ showOrderTokenQr ? '收起' : '展开' }}订单系统二维码(可选)</button>
|
||||
</p>
|
||||
<div v-show="showOrderTokenQr" class="flex justify-center mb-2">
|
||||
<img
|
||||
:src="orderResult.qrCodeUrl"
|
||||
alt="订单凭证二维码"
|
||||
class="max-w-[160px] w-full rounded-lg border border-white/35 opacity-90"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 免费:仅展示系统下发的凭证码 -->
|
||||
<template v-else>
|
||||
<p class="tag mb-2">免费订单已生成,可保存下方凭证</p>
|
||||
<img
|
||||
v-if="orderResult.qrCodeUrl"
|
||||
:src="orderResult.qrCodeUrl"
|
||||
alt="订单凭证"
|
||||
class="max-w-[320px] mx-auto w-full rounded-lg border border-white/35 my-3"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col items-stretch sm:items-center gap-2.5 my-4 max-w-md mx-auto sm:max-w-none">
|
||||
<button type="button" class="primary w-full sm:w-auto px-6" :disabled="confirming" @click="doConfirm">
|
||||
{{ confirming ? '确认中...' : (needPayChannel ? '我已完成扫码付款' : '确认领取') }}
|
||||
</button>
|
||||
<p class="tag text-center text-[13px] sm:text-[15px]">{{ needPayChannel ? '完成支付后点击上方按钮获取购买内容' : '点击确认以查看购买内容' }}</p>
|
||||
</div>
|
||||
<p class="text-[#d64848] text-[15px]" v-if="confirmError">{{ confirmError }}</p>
|
||||
<section
|
||||
v-if="orderResult"
|
||||
class="max-w-full min-w-0 px-0"
|
||||
:class="showMengyaTimeoutCancelledOnly
|
||||
? 'flex min-h-[min(58vh,calc(100dvh-15rem))] items-center justify-center text-center'
|
||||
: 'mt-4 text-left sm:text-center'"
|
||||
>
|
||||
<template v-if="showMengyaTimeoutCancelledOnly">
|
||||
<p
|
||||
class="text-[#d64848] m-0 font-semibold leading-snug max-w-xl mx-auto text-[clamp(1.2rem,4vw,1.85rem)] sm:text-[1.45rem] md:text-[1.7rem]"
|
||||
role="alert"
|
||||
>
|
||||
{{ confirmError }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<span class="inline-block px-4 py-1.5 rounded-full bg-gradient-to-br from-accent to-accent2 text-white text-[15px] font-semibold mb-3">订单已完成</span>
|
||||
<div v-if="deliveredCodes.length" class="my-3 flex flex-col items-center gap-2">
|
||||
<p class="tag">购买后内容</p>
|
||||
<textarea
|
||||
class="w-[min(520px,100%)] min-h-[120px] px-4 py-3.5 rounded-lg border border-white/35 bg-white/90 text-apptext text-base leading-[1.7] resize-none"
|
||||
style="font-family: var(--font-serif);"
|
||||
readonly
|
||||
:value="deliveredCodes.join('\n')"
|
||||
></textarea>
|
||||
</div>
|
||||
<div v-else-if="isManualDelivery" class="flex items-start gap-3 p-4 bg-[rgba(90,180,120,0.08)] border border-[rgba(90,180,120,0.25)] rounded-lg text-apptext max-w-lg mx-auto text-left">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
||||
<div>
|
||||
<p class="text-base font-semibold text-[#3a9a68] mb-1">等待发货中</p>
|
||||
<p class="tag">管理员将尽快处理您的订单并进行发货,请关注您的邮箱或联系方式。</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="tag mt-3">感谢购买!如有问题请联系售后邮箱。</p>
|
||||
<CheckoutOrderHeader
|
||||
:order-id="orderResult.orderId"
|
||||
:is-mengya-pending-pay="isMengyaPendingPay"
|
||||
:copy-hint="orderIdCopyHint"
|
||||
@copy-order-id="copyOrderId"
|
||||
/>
|
||||
|
||||
<CheckoutOrderDone
|
||||
v-if="confirmed"
|
||||
:delivered-codes="deliveredCodes"
|
||||
:is-manual-delivery="isManualDelivery"
|
||||
/>
|
||||
|
||||
<CheckoutAwaitingFulfillment
|
||||
v-else
|
||||
:order-result="orderResult"
|
||||
:need-pay-channel="needPayChannel"
|
||||
:is-mengya-pending-pay="isMengyaPendingPay"
|
||||
:paid-amount-snapshot="paidAmountSnapshot"
|
||||
:mengya-qr-urls="mengyaQrUrls"
|
||||
v-model:mengya-qr-index="mengyaQrIndex"
|
||||
v-model:show-order-token-qr="showOrderTokenQr"
|
||||
:show-payment-countdown="showPaymentCountdown"
|
||||
:payment-countdown-urgent="paymentCountdownUrgent"
|
||||
:payment-countdown-display="paymentCountdownDisplay"
|
||||
:payment-countdown-expired="paymentCountdownExpired"
|
||||
:confirming="confirming"
|
||||
:cancelling="cancelling"
|
||||
:confirm-button-label="confirmButtonLabel"
|
||||
:confirm-hint="confirmHint"
|
||||
:confirm-error="confirmError"
|
||||
@confirm="doConfirm"
|
||||
@cancel="doCancel"
|
||||
/>
|
||||
</template>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="page-card" v-else-if="!loading">
|
||||
<section v-else-if="!loading">
|
||||
<h2>未找到该商品</h2>
|
||||
<p class="tag mt-2">该商品无法下单,请返回商店页。</p>
|
||||
<div class="flex gap-3 mt-[18px]">
|
||||
@@ -212,7 +101,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="page-card" v-else>
|
||||
<section v-else>
|
||||
<div class="py-6 text-muted">加载中...</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -220,17 +109,22 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { fetchProducts, createOrder, confirmOrder } from '../shared/api'
|
||||
import { fetchProducts, createOrder, confirmOrder, cancelOrder } from '../shared/api'
|
||||
import { authState, isLoggedIn, getLoginUrl } from '../shared/auth'
|
||||
import { isFixedFulfillment, stockLabel } from '../shared/fulfillment'
|
||||
import qrAlipay from '../../assets/payment/支付宝个人收款码.png'
|
||||
import qrWechatPay from '../../assets/payment/微信个人收款码.png'
|
||||
import qrWechatReward from '../../assets/payment/微信赞赏码.png'
|
||||
import { useClipboardFeedback } from './composables/useClipboardFeedback.js'
|
||||
import { usePaymentCountdown } from './composables/usePaymentCountdown.js'
|
||||
import { useMengyaPaymentPolling } from './composables/useMengyaPaymentPolling.js'
|
||||
import CheckoutProductSummary from './components/CheckoutProductSummary.vue'
|
||||
import CheckoutOrderFormPanel from './components/CheckoutOrderFormPanel.vue'
|
||||
import CheckoutOrderHeader from './components/CheckoutOrderHeader.vue'
|
||||
import CheckoutAwaitingFulfillment from './components/CheckoutAwaitingFulfillment.vue'
|
||||
import CheckoutOrderDone from './components/CheckoutOrderDone.vue'
|
||||
|
||||
/** Shown alone when mengya poll reports cancelled (timeout). Keep in sync with onCancelled below. */
|
||||
const MENGYA_PAYMENT_TIMEOUT_CANCEL_MSG = '订单未在时限内到账,已自动取消'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const md = new MarkdownIt()
|
||||
const product = ref(null)
|
||||
const loading = ref(true)
|
||||
const submitting = ref(false)
|
||||
@@ -238,57 +132,22 @@ const error = ref('')
|
||||
const orderResult = ref(null)
|
||||
const confirmed = ref(false)
|
||||
const confirming = ref(false)
|
||||
const cancelling = ref(false)
|
||||
const confirmError = ref('')
|
||||
const deliveredCodes = ref([])
|
||||
const paidAmountSnapshot = ref(0)
|
||||
const payChannel = ref('alipay')
|
||||
const showOrderTokenQr = ref(false)
|
||||
/** 复制订单号反馈:空串表示显示默认按钮文案 */
|
||||
const orderIdCopyHint = ref('')
|
||||
let orderIdCopyTimer = null
|
||||
const mengyaQrIndex = ref(0)
|
||||
|
||||
const copyOrderId = async () => {
|
||||
const id = orderResult.value?.orderId
|
||||
if (!id) return
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(id)
|
||||
} else {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = id
|
||||
ta.setAttribute('readonly', '')
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.left = '-9999px'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
orderIdCopyHint.value = '已复制'
|
||||
} catch {
|
||||
orderIdCopyHint.value = '复制失败'
|
||||
}
|
||||
clearTimeout(orderIdCopyTimer)
|
||||
orderIdCopyTimer = setTimeout(() => {
|
||||
orderIdCopyHint.value = ''
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
/** 真实收款码:与 src/assets/payment 内图片一致 */
|
||||
const payChannels = [
|
||||
{ id: 'alipay', label: '支付宝', hint: '个人收款', img: qrAlipay },
|
||||
{ id: 'wechat', label: '微信支付', hint: '个人收款', img: qrWechatPay },
|
||||
{ id: 'reward', label: '微信赞赏', hint: '可选 · 谢谢支持', img: qrWechatReward }
|
||||
]
|
||||
const { hint: orderIdCopyHint, copy: copyToClipboard, dispose: disposeClipboard } = useClipboardFeedback()
|
||||
const copyOrderId = () => copyToClipboard(orderResult.value?.orderId)
|
||||
|
||||
const loggedIn = computed(() => isLoggedIn())
|
||||
const loginUrl = computed(() => getLoginUrl())
|
||||
|
||||
const form = reactive({ quantity: 1, note: '', contactPhone: '', contactEmail: '' })
|
||||
const form = reactive({ note: '', contactPhone: '', contactEmail: '' })
|
||||
const isManualDelivery = ref(false)
|
||||
|
||||
const renderMarkdown = (content) => md.render(content || '')
|
||||
|
||||
const getPayPrice = (p) => {
|
||||
if (!p) return 0
|
||||
if (p.price === 0) return 0
|
||||
@@ -297,38 +156,65 @@ const getPayPrice = (p) => {
|
||||
}
|
||||
|
||||
const unitPrice = computed(() => getPayPrice(product.value))
|
||||
const isFree = computed(() => unitPrice.value === 0)
|
||||
const totalPrice = computed(() => {
|
||||
if (!product.value) return 0
|
||||
const qty = form.quantity && form.quantity > 0 ? form.quantity : 1
|
||||
return unitPrice.value * qty
|
||||
})
|
||||
|
||||
/** 数量上限:固定内容只受每账户限购;卡密还受库存限制 */
|
||||
const maxOrderQty = computed(() => {
|
||||
const p = product.value
|
||||
if (!p) return undefined
|
||||
const per = p.maxPerAccount > 0 ? p.maxPerAccount : null
|
||||
if (isFixedFulfillment(p)) {
|
||||
return per ?? undefined
|
||||
}
|
||||
const stock = (p.quantity ?? 0) > 0 ? p.quantity : null
|
||||
if (per != null && stock != null) return Math.min(per, stock)
|
||||
if (per != null) return per
|
||||
if (stock != null) return stock
|
||||
return undefined
|
||||
})
|
||||
|
||||
const needPayChannel = computed(() => paidAmountSnapshot.value > 0.0001)
|
||||
|
||||
const currentPayQrSrc = computed(() => {
|
||||
const ch = payChannels.find((c) => c.id === payChannel.value)
|
||||
return ch?.img || qrAlipay
|
||||
const mengyaQrUrls = computed(() =>
|
||||
(orderResult.value?.paymentQrUrls || []).map((u) => (u || '').trim()).filter(Boolean)
|
||||
)
|
||||
|
||||
const isMengyaPendingPay = computed(
|
||||
() =>
|
||||
orderResult.value?.paymentMethod === 'mengya' &&
|
||||
orderResult.value?.status === 'pending_payment'
|
||||
)
|
||||
|
||||
const showMengyaTimeoutCancelledOnly = computed(
|
||||
() =>
|
||||
!!orderResult.value &&
|
||||
!confirmed.value &&
|
||||
confirmError.value === MENGYA_PAYMENT_TIMEOUT_CANCEL_MSG
|
||||
)
|
||||
|
||||
async function finalizeOrderFromServer() {
|
||||
if (!orderResult.value?.orderId) return false
|
||||
try {
|
||||
const result = await confirmOrder(orderResult.value.orderId)
|
||||
deliveredCodes.value = result.deliveredCodes || []
|
||||
isManualDelivery.value = result.isManual || result.deliveryMode === 'manual'
|
||||
confirmed.value = true
|
||||
orderResult.value = { ...orderResult.value, ...result }
|
||||
stopPollingPayment()
|
||||
stopPaymentCountdown()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const { stopPollingPayment } = useMengyaPaymentPolling(orderResult, {
|
||||
onCompleted: finalizeOrderFromServer,
|
||||
onCancelled: () => {
|
||||
confirmError.value = MENGYA_PAYMENT_TIMEOUT_CANCEL_MSG
|
||||
}
|
||||
})
|
||||
|
||||
const currentPayQrAlt = computed(() => {
|
||||
const ch = payChannels.find((c) => c.id === payChannel.value)
|
||||
return ch ? `${ch.label}收款码` : '收款码'
|
||||
const {
|
||||
showPaymentCountdown,
|
||||
paymentCountdownDisplay,
|
||||
paymentCountdownExpired,
|
||||
paymentCountdownUrgent,
|
||||
stopPaymentCountdown
|
||||
} = usePaymentCountdown(orderResult, isMengyaPendingPay)
|
||||
|
||||
const confirmButtonLabel = computed(() => {
|
||||
if (!needPayChannel.value) return '确认领取'
|
||||
return '刷新订单状态'
|
||||
})
|
||||
|
||||
const confirmHint = computed(() => {
|
||||
if (!needPayChannel.value) return '点击确认以查看购买内容'
|
||||
return '到账后会自动完成;若未更新,可点击「刷新订单状态」。'
|
||||
})
|
||||
|
||||
const goBack = () => router.push('/')
|
||||
@@ -337,18 +223,21 @@ const submitOrder = async () => {
|
||||
if (!product.value) return
|
||||
error.value = ''
|
||||
submitting.value = true
|
||||
confirmError.value = ''
|
||||
orderResult.value = null
|
||||
confirmed.value = false
|
||||
deliveredCodes.value = []
|
||||
paidAmountSnapshot.value = totalPrice.value
|
||||
payChannel.value = 'alipay'
|
||||
paidAmountSnapshot.value = unitPrice.value
|
||||
mengyaQrIndex.value = 0
|
||||
showOrderTokenQr.value = false
|
||||
orderIdCopyHint.value = ''
|
||||
clearTimeout(orderIdCopyTimer)
|
||||
stopPollingPayment()
|
||||
stopPaymentCountdown()
|
||||
disposeClipboard()
|
||||
try {
|
||||
const payload = {
|
||||
productId: product.value.id,
|
||||
quantity: form.quantity,
|
||||
quantity: 1,
|
||||
note: form.note || '',
|
||||
contactPhone: form.contactPhone || '',
|
||||
contactEmail: form.contactEmail || '',
|
||||
@@ -357,6 +246,11 @@ const submitOrder = async () => {
|
||||
const token = isLoggedIn() ? authState.token : null
|
||||
const response = await createOrder(payload, token)
|
||||
orderResult.value = response
|
||||
if (typeof response.paymentExpectedTotal === 'number') {
|
||||
paidAmountSnapshot.value = response.paymentExpectedTotal
|
||||
} else {
|
||||
paidAmountSnapshot.value = unitPrice.value
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err?.response?.data?.error || '下单失败,请稍候再试'
|
||||
} finally {
|
||||
@@ -373,6 +267,9 @@ const doConfirm = async () => {
|
||||
deliveredCodes.value = result.deliveredCodes || []
|
||||
isManualDelivery.value = result.isManual || result.deliveryMode === 'manual'
|
||||
confirmed.value = true
|
||||
orderResult.value = { ...orderResult.value, ...result }
|
||||
stopPollingPayment()
|
||||
stopPaymentCountdown()
|
||||
} catch (err) {
|
||||
confirmError.value = err?.response?.data?.error || '确认失败,请重试'
|
||||
} finally {
|
||||
@@ -380,8 +277,28 @@ const doConfirm = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const doCancel = async () => {
|
||||
if (!orderResult.value?.orderId) return
|
||||
if (!window.confirm('确认要取消这笔订单吗?')) return
|
||||
confirmError.value = ''
|
||||
cancelling.value = true
|
||||
try {
|
||||
await cancelOrder(orderResult.value.orderId)
|
||||
stopPollingPayment()
|
||||
stopPaymentCountdown()
|
||||
confirmError.value = '订单已取消'
|
||||
orderResult.value = { ...orderResult.value, status: 'cancelled' }
|
||||
} catch (err) {
|
||||
confirmError.value = err?.response?.data?.error || '取消失败,请稍候再试'
|
||||
} finally {
|
||||
cancelling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimeout(orderIdCopyTimer)
|
||||
disposeClipboard()
|
||||
stopPollingPayment()
|
||||
stopPaymentCountdown()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
@@ -1,178 +1,295 @@
|
||||
<template>
|
||||
<section class="page-card" v-if="!loading && product">
|
||||
<div class="flex flex-col gap-[18px]">
|
||||
<!-- Summary -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<h2>{{ product.name }}</h2>
|
||||
<p class="tag">库存:{{ stockLabel(product) }}</p>
|
||||
<p class="tag">浏览量:{{ product.viewCount || 0 }}</p>
|
||||
<p class="font-semibold text-accent2">
|
||||
<span v-if="isFree" class="text-[#3a9a68] font-black">免费</span>
|
||||
<span v-else-if="product.discountPrice > 0 && product.discountPrice < product.price">
|
||||
<span class="line-through text-muted mr-1.5 text-[15px]">¥ {{ product.price.toFixed(2) }}</span>
|
||||
<span class="font-semibold">¥ {{ product.discountPrice.toFixed(2) }}</span>
|
||||
</span>
|
||||
<span v-else>¥ {{ product.price.toFixed(2) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Description (full markdown, not clamped) -->
|
||||
<div
|
||||
class="block text-apptext text-[17px] leading-[1.8] [display:block] [overflow:visible] [-webkit-box-orient:unset] [-webkit-line-clamp:unset] markdown"
|
||||
v-html="renderMarkdown(product.description)"
|
||||
></div>
|
||||
|
||||
<!-- Gallery -->
|
||||
<div v-if="galleryImages.length" class="flex flex-col gap-3.5">
|
||||
<div class="relative overflow-hidden rounded-[10px] border border-white/35 bg-white/45">
|
||||
<button
|
||||
v-if="galleryImages.length > 1"
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 z-[1] w-[42px] h-[42px] rounded-full p-0 bg-white/88 text-apptext text-[28px] leading-none shadow-[0_10px_20px_rgba(33,33,40,0.12)]"
|
||||
type="button"
|
||||
aria-label="上一张"
|
||||
@click="prevImage"
|
||||
>‹</button>
|
||||
<img
|
||||
class="w-full h-[360px] object-cover block max-md:h-60"
|
||||
:src="galleryImages[currentImageIndex].url"
|
||||
:alt="galleryImages[currentImageIndex].label"
|
||||
/>
|
||||
<button
|
||||
v-if="galleryImages.length > 1"
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 z-[1] w-[42px] h-[42px] rounded-full p-0 bg-white/88 text-apptext text-[28px] leading-none shadow-[0_10px_20px_rgba(33,33,40,0.12)]"
|
||||
type="button"
|
||||
aria-label="下一张"
|
||||
@click="nextImage"
|
||||
>›</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2.5 flex-wrap">
|
||||
<span class="badge">{{ galleryImages[currentImageIndex].label }}</span>
|
||||
<span class="tag">{{ currentImageIndex + 1 }} / {{ galleryImages.length }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="galleryImages.length > 1" class="grid gap-2.5" style="grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));">
|
||||
<button
|
||||
v-for="(image, index) in galleryImages"
|
||||
:key="`${image.url}-${index}`"
|
||||
class="flex flex-col gap-2 p-2 text-left bg-white/58 border transition max-md:p-1.5"
|
||||
:class="index === currentImageIndex ? 'border-accent2 shadow-[0_10px_24px_rgba(145,168,208,0.16)]' : 'border-transparent'"
|
||||
type="button"
|
||||
@click="currentImageIndex = index"
|
||||
>
|
||||
<img :src="image.url" :alt="image.label" class="w-full h-[76px] object-cover rounded-md" />
|
||||
<span class="text-sm text-muted">{{ image.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3 mt-[18px] flex-wrap">
|
||||
<button class="buy-button" @click="goCheckout">立即下单</button>
|
||||
<button
|
||||
v-if="loggedIn"
|
||||
class="ghost"
|
||||
:class="inWishlist ? 'text-[#e8826a] border-[rgba(232,130,106,0.35)] bg-[rgba(255,240,235,0.6)]' : 'text-muted'"
|
||||
type="button"
|
||||
@click="onToggleWishlist"
|
||||
>{{ inWishlist ? '★ 已收藏' : '☆ 加入收藏夹' }}</button>
|
||||
<button class="ghost" @click="goBack">返回商店首页</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="page-card" v-else-if="!loading && !product">
|
||||
<h2>未找到该商品</h2>
|
||||
<p class="tag mt-2">请返回商店查看其他商品。</p>
|
||||
<div class="flex gap-3 mt-[18px]">
|
||||
<button class="ghost" @click="goBack">返回商店首页</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="page-card" v-else>
|
||||
<div class="py-6 text-muted">加载中...</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { fetchProducts, recordProductView } from '../shared/api'
|
||||
import { isLoggedIn } from '../shared/auth'
|
||||
import { stockLabel } from '../shared/fulfillment'
|
||||
import { isInWishlist, toggleWishlist } from '../shared/useWishlist'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const md = new MarkdownIt()
|
||||
const loading = ref(true)
|
||||
const product = ref(null)
|
||||
const currentImageIndex = ref(0)
|
||||
|
||||
const getPayPrice = (p) => {
|
||||
if (!p) return 0
|
||||
if (p.price === 0) return 0
|
||||
if (p.discountPrice > 0 && p.discountPrice < p.price) return p.discountPrice
|
||||
return p.price
|
||||
}
|
||||
|
||||
const unitPrice = computed(() => getPayPrice(product.value))
|
||||
const isFree = computed(() => unitPrice.value === 0)
|
||||
const renderMarkdown = (content) => md.render(content || '')
|
||||
|
||||
const galleryImages = computed(() => {
|
||||
if (!product.value) return []
|
||||
const images = []
|
||||
const seen = new Set()
|
||||
const appendImage = (url, label) => {
|
||||
const trimmed = (url || '').trim()
|
||||
if (!trimmed || seen.has(trimmed)) return
|
||||
seen.add(trimmed)
|
||||
images.push({ url: trimmed, label })
|
||||
}
|
||||
appendImage(product.value.coverUrl, '商品封面')
|
||||
const screenshots = Array.isArray(product.value.screenshotUrls) ? product.value.screenshotUrls : []
|
||||
screenshots.forEach((url, index) => { appendImage(url, `商品截图 ${index + 1}`) })
|
||||
return images
|
||||
})
|
||||
|
||||
const loggedIn = computed(() => isLoggedIn())
|
||||
const inWishlist = computed(() => product.value ? isInWishlist(product.value.id) : false)
|
||||
const onToggleWishlist = () => { if (product.value) toggleWishlist(product.value.id) }
|
||||
|
||||
const goBack = () => { router.push('/') }
|
||||
const goCheckout = () => { if (!product.value) return; router.push(`/checkout/${product.value.id}`) }
|
||||
|
||||
const prevImage = () => {
|
||||
if (!galleryImages.value.length) return
|
||||
currentImageIndex.value = (currentImageIndex.value - 1 + galleryImages.value.length) % galleryImages.value.length
|
||||
}
|
||||
const nextImage = () => {
|
||||
if (!galleryImages.value.length) return
|
||||
currentImageIndex.value = (currentImageIndex.value + 1) % galleryImages.value.length
|
||||
}
|
||||
|
||||
watch(galleryImages, (images) => {
|
||||
if (!images.length || currentImageIndex.value >= images.length) currentImageIndex.value = 0
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const list = await fetchProducts()
|
||||
product.value = list.find((item) => item.id === route.params.id) || null
|
||||
if (product.value) {
|
||||
try {
|
||||
const result = await recordProductView(product.value.id)
|
||||
if (typeof result.viewCount === 'number') {
|
||||
product.value = { ...product.value, viewCount: result.viewCount }
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('record product view failed', error)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<section v-if="!loading && product">
|
||||
<div class="w-full max-w-[1180px] mx-auto">
|
||||
<div class="grid gap-4 lg:gap-6 lg:grid-cols-[minmax(0,1fr)_340px] items-start">
|
||||
<div class="min-w-0 flex flex-col gap-4 lg:gap-5">
|
||||
<div class="rounded-[16px] border border-white/40 bg-[rgba(255,252,248,0.46)] shadow-[0_18px_44px_rgba(42,28,22,0.08)] backdrop-blur-[16px] p-4 sm:p-5 lg:p-6">
|
||||
<div class="flex flex-col gap-4 sm:gap-5">
|
||||
<div class="flex items-start justify-between gap-3 sm:gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-[28px] leading-tight sm:text-[34px] lg:text-[38px] font-bold text-apptext">{{ product.name }}</h3>
|
||||
</div>
|
||||
|
||||
<div class="lg:hidden shrink-0 rounded-[14px] border border-[rgba(184,109,92,0.18)] bg-[linear-gradient(160deg,rgba(255,252,248,0.95),rgba(248,232,223,0.78))] px-4 py-3 text-right shadow-[0_10px_24px_rgba(184,109,92,0.12)] min-w-[116px]">
|
||||
<p class="text-[11px] tracking-[0.08em] text-muted mb-1">当前价格</p>
|
||||
<p class="text-[28px] leading-none font-bold text-accent2">
|
||||
<span v-if="isFree" class="text-[#3a9a68] font-black text-[24px]">免费</span>
|
||||
<span v-else>¥ {{ unitPrice.toFixed(2) }}</span>
|
||||
</p>
|
||||
<p
|
||||
v-if="hasDiscount"
|
||||
class="text-[12px] text-muted line-through mt-2"
|
||||
>原价 ¥ {{ product.price.toFixed(2) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="galleryImages.length" class="rounded-[16px] border border-white/40 bg-[rgba(255,252,248,0.44)] shadow-[0_16px_38px_rgba(42,28,22,0.07)] backdrop-blur-[14px] p-4 sm:p-5 lg:p-6">
|
||||
<div class="flex items-center justify-between gap-3 mb-3 sm:mb-4">
|
||||
<div>
|
||||
<h3 class="text-[19px] sm:text-[21px] font-bold text-apptext">商品预览</h3>
|
||||
</div>
|
||||
<span class="shrink-0 rounded-full border border-white/45 bg-white/55 px-3 py-1 text-[12px] sm:text-[13px] font-semibold text-muted">
|
||||
{{ activeGalleryIndex + 1 }} / {{ galleryImages.length }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="rounded-[15px] border border-white/35 bg-[linear-gradient(180deg,rgba(255,255,255,0.24),rgba(255,250,245,0.14))] p-3 sm:p-4">
|
||||
<div class="aspect-[4/3] sm:aspect-[16/10] rounded-[12px] border border-white/35 bg-[rgba(255,255,255,0.38)] overflow-hidden flex items-center justify-center">
|
||||
<img
|
||||
:src="activeGalleryImage.url"
|
||||
:alt="activeGalleryImage.label"
|
||||
class="w-full h-full object-contain p-2 sm:p-3"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="galleryImages.length > 1"
|
||||
class="mt-3 sm:mt-4 flex gap-2 overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
<button
|
||||
v-for="(img, index) in galleryImages"
|
||||
:key="`${img.url}-${index}`"
|
||||
type="button"
|
||||
class="group shrink-0 w-[78px] sm:w-[92px] rounded-[12px] border bg-white/45 p-1.5 transition"
|
||||
:class="index === activeGalleryIndex ? 'border-accent2 shadow-[0_10px_20px_rgba(184,109,92,0.16)] bg-[rgba(255,247,242,0.82)]' : 'border-white/35 hover:border-accent2/35'"
|
||||
@click="activeGalleryIndex = index"
|
||||
>
|
||||
<div class="aspect-square rounded-[10px] overflow-hidden bg-white/60">
|
||||
<img :src="img.url" :alt="img.label" class="w-full h-full object-cover" loading="lazy" />
|
||||
</div>
|
||||
<p class="text-[11px] text-muted mt-1 truncate">{{ img.label }}</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-[12px] sm:text-[13px] text-muted mt-3 text-center">{{ activeGalleryImage.label }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-[16px] border border-white/40 bg-[rgba(255,252,248,0.46)] shadow-[0_16px_38px_rgba(42,28,22,0.07)] backdrop-blur-[14px] overflow-hidden">
|
||||
<div class="px-4 py-3 sm:px-5 sm:py-4 lg:px-6 border-b border-white/25 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-[19px] sm:text-[21px] font-bold text-apptext">商品介绍</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-4 pb-4 sm:px-5 sm:pb-5 lg:px-6 lg:pb-6 pt-4">
|
||||
<div class="relative">
|
||||
<div
|
||||
class="markdown markdown-detail text-[14px] sm:text-[15px] leading-7 text-apptext max-w-none"
|
||||
:class="descriptionExpanded ? '' : 'max-h-[220px] overflow-hidden'"
|
||||
v-html="renderMarkdown(product.description)"
|
||||
/>
|
||||
<div
|
||||
v-if="showDescToggle && !descriptionExpanded"
|
||||
class="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-[rgba(255,250,245,0.98)] to-transparent"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div v-if="showDescToggle" class="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full sm:w-auto rounded-full border border-white/40 bg-white/55 px-4 py-2 text-[13px] sm:text-[14px] font-semibold text-accent2 hover:bg-white/75 transition"
|
||||
@click="descriptionExpanded = !descriptionExpanded"
|
||||
>
|
||||
{{ descriptionExpanded ? '收起介绍' : '展开完整介绍' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:hidden rounded-[16px] border border-white/40 bg-[rgba(255,252,248,0.52)] shadow-[0_14px_34px_rgba(42,28,22,0.07)] backdrop-blur-[14px] p-4 sm:p-5">
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<button class="buy-button w-full !py-3 text-[17px]" type="button" @click="goCheckout">立即下单</button>
|
||||
<button
|
||||
v-if="loggedIn"
|
||||
class="ghost w-full !py-3 !text-[15px]"
|
||||
:class="inWishlist ? 'text-[#e8826a] border-[rgba(232,130,106,0.35)] bg-[rgba(255,240,235,0.7)]' : 'text-muted'"
|
||||
type="button"
|
||||
@click="onToggleWishlist"
|
||||
>{{ inWishlist ? '★ 已收藏' : '☆ 加入收藏夹' }}</button>
|
||||
<button class="ghost w-full !py-3 !text-[15px]" type="button" @click="goBack">返回商店</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="hidden lg:flex flex-col gap-4 sticky top-[86px]">
|
||||
<div class="rounded-[18px] border border-[rgba(184,109,92,0.16)] bg-[linear-gradient(180deg,rgba(255,252,248,0.96),rgba(247,235,227,0.82))] shadow-[0_22px_48px_rgba(42,28,22,0.1)] backdrop-blur-[18px] p-5">
|
||||
<div v-if="coverMiniUrl" class="rounded-[14px] border border-white/45 bg-white/58 p-3 mb-4">
|
||||
<div class="aspect-[4/3] rounded-[12px] overflow-hidden bg-[rgba(255,255,255,0.62)] flex items-center justify-center">
|
||||
<img :src="coverMiniUrl" alt="封面" class="w-full h-full object-contain p-2" loading="lazy" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-[38px] leading-none font-bold text-accent2">
|
||||
<span v-if="isFree" class="text-[#3a9a68] font-black text-[34px]">免费</span>
|
||||
<span v-else>¥ {{ unitPrice.toFixed(2) }}</span>
|
||||
</p>
|
||||
<p v-if="hasDiscount" class="text-[14px] text-muted line-through mt-3">原价 ¥ {{ product.price.toFixed(2) }}</p>
|
||||
|
||||
<div class="grid grid-cols-1 gap-2 mt-5">
|
||||
<div class="rounded-xl border border-white/40 bg-white/45 px-3.5 py-3 flex items-center justify-between gap-3">
|
||||
<span class="text-[13px] text-muted">库存</span>
|
||||
<span class="text-[15px] font-semibold text-apptext">{{ stockLabel(product) }}</span>
|
||||
</div>
|
||||
<div class="rounded-xl border border-white/40 bg-white/45 px-3.5 py-3 flex items-center justify-between gap-3">
|
||||
<span class="text-[13px] text-muted">已售</span>
|
||||
<span class="text-[15px] font-semibold text-apptext">{{ totalSoldDisplay }}</span>
|
||||
</div>
|
||||
<div class="rounded-xl border border-white/40 bg-white/45 px-3.5 py-3 flex items-center justify-between gap-3">
|
||||
<span class="text-[13px] text-muted">浏览量</span>
|
||||
<span class="text-[15px] font-semibold text-apptext">{{ product.viewCount || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5 mt-5">
|
||||
<button class="buy-button w-full !py-3 text-[17px]" type="button" @click="goCheckout">立即下单</button>
|
||||
<button
|
||||
v-if="loggedIn"
|
||||
class="ghost w-full !py-3"
|
||||
:class="inWishlist ? 'text-[#e8826a] border-[rgba(232,130,106,0.35)] bg-[rgba(255,240,235,0.7)]' : 'text-muted'"
|
||||
type="button"
|
||||
@click="onToggleWishlist"
|
||||
>{{ inWishlist ? '★ 已收藏' : '☆ 加入收藏夹' }}</button>
|
||||
<button class="ghost w-full !py-3" type="button" @click="goBack">返回商店</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-[15px] border border-white/35 bg-[rgba(255,252,248,0.44)] shadow-[0_12px_30px_rgba(42,28,22,0.06)] backdrop-blur-[12px] p-4">
|
||||
<p class="text-[15px] font-semibold text-apptext">下单提示</p>
|
||||
<p class="text-[13px] text-muted leading-6 mt-2">
|
||||
下单后会进入订单页处理流程,数字商品通常会在短时间内完成交付。
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="!loading && !product">
|
||||
<h2>未找到该商品</h2>
|
||||
<p class="tag mt-2">请返回商店查看其他商品。</p>
|
||||
<div class="flex gap-3 mt-4">
|
||||
<button class="ghost" @click="goBack">返回商店首页</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else>
|
||||
<div class="py-4 text-muted text-[15px]">加载中...</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { fetchProducts, recordProductView } from '../shared/api'
|
||||
import { isLoggedIn } from '../shared/auth'
|
||||
import { stockLabel } from '../shared/fulfillment'
|
||||
import { isInWishlist, toggleWishlist } from '../shared/useWishlist'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const md = new MarkdownIt()
|
||||
const loading = ref(true)
|
||||
const product = ref(null)
|
||||
const activeGalleryIndex = ref(0)
|
||||
const descriptionExpanded = ref(false)
|
||||
|
||||
const getPayPrice = (p) => {
|
||||
if (!p) return 0
|
||||
if (p.price === 0) return 0
|
||||
if (p.discountPrice > 0 && p.discountPrice < p.price) return p.discountPrice
|
||||
return p.price
|
||||
}
|
||||
|
||||
const unitPrice = computed(() => getPayPrice(product.value))
|
||||
const isFree = computed(() => unitPrice.value === 0)
|
||||
const hasDiscount = computed(() => {
|
||||
if (!product.value) return false
|
||||
return product.value.discountPrice > 0 && product.value.discountPrice < product.value.price
|
||||
})
|
||||
const totalSoldDisplay = computed(() => {
|
||||
const n = product.value?.totalSold
|
||||
return typeof n === 'number' && !Number.isNaN(n) ? n : 0
|
||||
})
|
||||
const renderMarkdown = (content) => md.render(content || '')
|
||||
|
||||
const showDescToggle = computed(() => {
|
||||
const t = product.value?.description || ''
|
||||
return t.replace(/\s/g, '').length > 120
|
||||
})
|
||||
|
||||
const galleryImages = computed(() => {
|
||||
if (!product.value) return []
|
||||
const images = []
|
||||
const seen = new Set()
|
||||
const appendImage = (url, label) => {
|
||||
const trimmed = (url || '').trim()
|
||||
if (!trimmed || seen.has(trimmed)) return
|
||||
seen.add(trimmed)
|
||||
images.push({ url: trimmed, label })
|
||||
}
|
||||
appendImage(product.value.coverUrl,)
|
||||
const screenshots = Array.isArray(product.value.screenshotUrls) ? product.value.screenshotUrls : []
|
||||
screenshots.forEach((url, index) => { appendImage(url,) })
|
||||
return images
|
||||
})
|
||||
|
||||
const activeGalleryImage = computed(() => {
|
||||
return galleryImages.value[activeGalleryIndex.value] || galleryImages.value[0] || { url: '', label: '' }
|
||||
})
|
||||
|
||||
const coverMiniUrl = computed(() => {
|
||||
const c = (product.value?.coverUrl || '').trim()
|
||||
return c || galleryImages.value[0]?.url || ''
|
||||
})
|
||||
|
||||
watch(galleryImages, (images) => {
|
||||
if (!images.length) {
|
||||
activeGalleryIndex.value = 0
|
||||
return
|
||||
}
|
||||
if (activeGalleryIndex.value >= images.length) activeGalleryIndex.value = 0
|
||||
}, { immediate: true })
|
||||
|
||||
watch(() => product.value?.id, () => {
|
||||
descriptionExpanded.value = false
|
||||
activeGalleryIndex.value = 0
|
||||
})
|
||||
|
||||
const loggedIn = computed(() => isLoggedIn())
|
||||
const inWishlist = computed(() => product.value ? isInWishlist(product.value.id) : false)
|
||||
const onToggleWishlist = () => {
|
||||
if (product.value) toggleWishlist(product.value.id)
|
||||
}
|
||||
|
||||
const goBack = () => { router.push('/') }
|
||||
const goCheckout = () => {
|
||||
if (!product.value) return
|
||||
router.push(`/checkout/${product.value.id}`)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const list = await fetchProducts()
|
||||
product.value = list.find((item) => item.id === route.params.id) || null
|
||||
if (product.value) {
|
||||
try {
|
||||
const result = await recordProductView(product.value.id)
|
||||
if (typeof result.viewCount === 'number') {
|
||||
product.value = { ...product.value, viewCount: result.viewCount }
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('record product view failed', error)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<section class="page-card">
|
||||
<div>
|
||||
<!-- Hero / filters row -->
|
||||
<div class="flex items-center gap-4 flex-wrap mb-5 max-md:flex-col max-md:items-start max-md:gap-2.5 max-md:mb-3">
|
||||
<h2 class="text-[26px] max-md:text-xl">所有商品</h2>
|
||||
<div class="flex gap-3 flex-wrap max-md:flex-nowrap max-md:overflow-x-auto max-md:gap-2 max-md:w-full max-md:pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<div class="flex items-end gap-3 flex-wrap mb-4 max-md:flex-col max-md:items-stretch max-md:gap-2 max-md:mb-3">
|
||||
<h2 class="text-lg md:text-xl font-bold shrink-0">所有商品</h2>
|
||||
<div class="flex gap-2 flex-wrap flex-1 min-w-0 max-md:flex-nowrap max-md:overflow-x-auto max-md:gap-1.5 max-md:w-full max-md:pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<button
|
||||
v-for="opt in VIEW_OPTIONS"
|
||||
:key="opt.value"
|
||||
class="px-3.5 py-2 rounded-full border border-white/35 bg-white/60 text-apptext text-[15px] font-bold transition hover:-translate-y-px max-md:flex-shrink-0 max-md:px-2.5 max-md:py-1.5 max-md:text-[13px]"
|
||||
:class="opt.value === viewMode ? 'bg-gradient-to-br from-accent to-accent2 !border-transparent !text-white shadow-[0_10px_30px_rgba(145,168,208,0.35)]' : ''"
|
||||
class="px-2.5 py-1 rounded-full border border-white/35 bg-white/60 text-apptext text-[13px] font-bold transition hover:-translate-y-px max-md:flex-shrink-0 max-md:px-2 max-md:py-1 max-md:text-[12px]"
|
||||
:class="opt.value === viewMode ? 'bg-gradient-to-br from-accent to-accent2 !border-transparent !text-white shadow-[0_6px_18px_rgba(184,109,92,0.28)]' : ''"
|
||||
type="button"
|
||||
@click="setViewMode(opt.value)"
|
||||
>
|
||||
@@ -17,7 +17,7 @@
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
class="w-[320px] max-w-full rounded-full border border-white/35 bg-white/70 px-3.5 py-2.5 text-[16px] outline-none focus:border-accent focus:shadow-[0_0_0_3px_rgba(180,154,203,0.18)] transition max-md:w-full max-md:text-sm max-md:py-2"
|
||||
class="w-[240px] max-w-full shrink-0 rounded-full border border-white/35 bg-white/70 px-3 py-2 text-[14px] outline-none focus:border-accent focus:shadow-[0_0_0_3px_rgba(217,168,120,0.22)] transition max-md:w-full max-md:text-sm"
|
||||
style="font-family: var(--font-serif);"
|
||||
type="text"
|
||||
placeholder="搜索商品/标签"
|
||||
@@ -35,7 +35,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="totalPages > 1" class="flex items-center justify-center gap-2 mt-[22px] flex-wrap">
|
||||
<div v-if="totalPages > 1" class="flex items-center justify-center gap-2 mt-4 flex-wrap">
|
||||
<button class="ghost rounded-full px-3.5 py-1.5 text-sm min-w-[60px] disabled:opacity-40 disabled:cursor-not-allowed" :disabled="page === 1" @click="page = 1">首页</button>
|
||||
<button class="ghost rounded-full px-3.5 py-1.5 text-sm min-w-[60px] disabled:opacity-40 disabled:cursor-not-allowed" :disabled="page === 1" @click="page--">上一页</button>
|
||||
<span class="text-sm text-muted px-1.5">第 {{ page }} / {{ totalPages }} 页</span>
|
||||
@@ -43,7 +43,7 @@
|
||||
<button class="ghost rounded-full px-3.5 py-1.5 text-sm min-w-[60px] disabled:opacity-40 disabled:cursor-not-allowed" :disabled="page === totalPages" @click="page = totalPages">末页</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<div>
|
||||
<template v-if="needPayChannel">
|
||||
<template v-if="isMengyaPendingPay">
|
||||
<p class="tag mb-3 text-[14px] sm:text-[15px] text-apptext max-w-[40rem] mx-auto sm:mx-auto text-left sm:text-center">
|
||||
请扫下方收款码支付,金额须与顶栏<strong>应付金额</strong>一致;到账后将自动完成。
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="text-apptext text-base sm:text-lg font-bold mb-2 text-left sm:text-center">
|
||||
应付金额
|
||||
<span class="text-accent2">¥ {{ paidAmountSnapshot.toFixed(2) }}</span>
|
||||
</p>
|
||||
</template>
|
||||
<div
|
||||
v-if="showPaymentCountdown"
|
||||
class="rounded-2xl border-2 px-4 py-4 sm:px-6 sm:py-5 mb-4 text-center shadow-[0_8px_30px_rgba(21,101,192,0.12)] max-w-md mx-auto"
|
||||
:class="paymentCountdownUrgent
|
||||
? 'border-[#e53935] bg-gradient-to-br from-[#fff5f5] to-[#ffebee]'
|
||||
: 'border-[#d4a574] bg-gradient-to-br from-white to-[#fff4ec]'"
|
||||
role="timer"
|
||||
:aria-live="paymentCountdownUrgent ? 'assertive' : 'polite'"
|
||||
>
|
||||
<p class="text-[12px] sm:text-[13px] font-bold uppercase tracking-[0.2em] text-apptext/85 mb-2 m-0">支付剩余时间</p>
|
||||
<p
|
||||
class="font-mono font-black tabular-nums leading-none tracking-tight select-none"
|
||||
:class="paymentCountdownUrgent
|
||||
? 'text-[clamp(2.25rem,10vw,3.5rem)] text-[#b71c1c]'
|
||||
: 'text-[clamp(2rem,8vw,3rem)] text-[#7a3d2e]'"
|
||||
>{{ paymentCountdownDisplay }}</p>
|
||||
<p v-if="paymentCountdownExpired" class="mt-3 mb-0 text-[14px] sm:text-[15px] font-bold text-[#c62828]">支付窗口已结束,订单即将或已自动取消</p>
|
||||
</div>
|
||||
|
||||
<div v-if="isMengyaPendingPay && mengyaQrUrls.length" class="flex flex-col gap-2 w-full max-w-md mx-auto mb-4 sm:max-w-none">
|
||||
<div
|
||||
v-if="mengyaQrUrls.length > 1"
|
||||
class="flex md:hidden flex-wrap gap-2 justify-start sm:justify-center"
|
||||
role="tablist"
|
||||
aria-label="商家收款渠道"
|
||||
>
|
||||
<button
|
||||
v-for="(url, idx) in mengyaQrUrls"
|
||||
:key="idx"
|
||||
type="button"
|
||||
role="tab"
|
||||
class="px-4 py-2 rounded-xl border text-[14px] font-semibold touch-manipulation"
|
||||
:class="mengyaQrIndex === idx
|
||||
? 'border-accent2 bg-white/90 text-accent2 shadow-sm ring-2 ring-accent2/25'
|
||||
: 'border-white/45 bg-white/55 text-apptext'"
|
||||
:aria-selected="mengyaQrIndex === idx"
|
||||
@click="mengyaQrIndex = idx"
|
||||
>
|
||||
渠道 {{ idx + 1 }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-center my-3"
|
||||
:class="mengyaQrUrls.length > 1 ? 'md:hidden' : ''"
|
||||
>
|
||||
<img
|
||||
:src="mengyaQrUrls[mengyaQrIndex]"
|
||||
:alt="'商家收款码 ' + (mengyaQrIndex + 1)"
|
||||
class="max-w-[min(300px,88vw)] w-full rounded-xl border border-white/40 shadow-[0_8px_28px_rgba(33,33,40,0.12)] bg-white/50"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="mengyaQrUrls.length > 1"
|
||||
class="hidden md:flex md:flex-wrap md:justify-center md:gap-5 md:my-5"
|
||||
aria-label="商家收款渠道(全部)"
|
||||
>
|
||||
<div
|
||||
v-for="(url, idx) in mengyaQrUrls"
|
||||
:key="`desktop-${idx}`"
|
||||
class="flex flex-col items-center w-[260px] rounded-2xl overflow-hidden border border-white/60 bg-white/92 shadow-[0_6px_28px_rgba(33,33,40,0.13)]"
|
||||
>
|
||||
<div class="w-full py-2.5 px-4 text-center border-b border-black/[0.06] bg-white/60">
|
||||
<p class="text-[11px] font-bold uppercase tracking-[0.22em] text-apptext/50 m-0">渠道 {{ idx + 1 }}</p>
|
||||
</div>
|
||||
<div class="w-full p-3">
|
||||
<img
|
||||
:src="url"
|
||||
:alt="'商家收款码 ' + (idx + 1)"
|
||||
class="w-full rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="orderResultQrUrl && !isMengyaPendingPay">
|
||||
<p class="tag mb-3 opacity-95">
|
||||
<span class="sr-only">系统订单凭证</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-accent2 underline-offset-2 hover:underline bg-transparent border-none cursor-pointer p-0 font-inherit text-[13px]"
|
||||
@click="showOrderTokenQr = !showOrderTokenQr"
|
||||
>{{ showOrderTokenQr ? '收起' : '展开' }}订单系统二维码(可选)</button>
|
||||
</p>
|
||||
<div v-show="showOrderTokenQr" class="flex justify-center mb-2">
|
||||
<img
|
||||
:src="orderResultQrUrl"
|
||||
alt="订单凭证二维码"
|
||||
class="max-w-[160px] w-full rounded-lg border border-white/35 opacity-90"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="tag mb-2">免费订单已生成,可保存下方凭证</p>
|
||||
<img
|
||||
v-if="orderResultQrUrl"
|
||||
:src="orderResultQrUrl"
|
||||
alt="订单凭证"
|
||||
class="max-w-[320px] mx-auto w-full rounded-lg border border-white/35 my-3"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col items-stretch gap-2.5 my-4 max-w-xl mx-auto w-full">
|
||||
<div class="flex flex-row flex-wrap justify-center gap-3 w-full">
|
||||
<button
|
||||
type="button"
|
||||
class="primary flex-1 min-w-[10rem] px-5 py-2 sm:max-w-[min(100%,240px)]"
|
||||
:disabled="confirming || cancelling"
|
||||
@click="emit('confirm')"
|
||||
>
|
||||
{{ confirming ? '确认中...' : confirmButtonLabel }}
|
||||
</button>
|
||||
<button
|
||||
v-if="isMengyaPendingPay"
|
||||
type="button"
|
||||
class="primary flex-1 min-w-[10rem] px-5 py-2 sm:max-w-[min(100%,240px)]"
|
||||
:disabled="cancelling || confirming"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ cancelling ? '取消中...' : '取消订单' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="tag text-center text-[13px] sm:text-[15px]">{{ confirmHint }}</p>
|
||||
</div>
|
||||
<p class="text-[#d64848] text-[15px]" v-if="confirmError">{{ confirmError }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
orderResult: { type: Object, required: true },
|
||||
needPayChannel: { type: Boolean, required: true },
|
||||
isMengyaPendingPay: { type: Boolean, required: true },
|
||||
paidAmountSnapshot: { type: Number, required: true },
|
||||
mengyaQrUrls: { type: Array, default: () => [] },
|
||||
mengyaQrIndex: { type: Number, default: 0 },
|
||||
showPaymentCountdown: { type: Boolean, default: false },
|
||||
paymentCountdownUrgent: { type: Boolean, default: false },
|
||||
paymentCountdownDisplay: { type: String, default: '--:--' },
|
||||
paymentCountdownExpired: { type: Boolean, default: false },
|
||||
/** v-model:showOrderTokenQr — optional system QR when not mengya-pending */
|
||||
showOrderTokenQr: { type: Boolean, default: false },
|
||||
confirming: { type: Boolean, default: false },
|
||||
cancelling: { type: Boolean, default: false },
|
||||
confirmButtonLabel: { type: String, required: true },
|
||||
confirmHint: { type: String, required: true },
|
||||
confirmError: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['confirm', 'cancel', 'update:mengyaQrIndex', 'update:showOrderTokenQr'])
|
||||
|
||||
const orderResultQrUrl = computed(() => props.orderResult?.qrCodeUrl || '')
|
||||
|
||||
const showOrderTokenQr = computed({
|
||||
get: () => props.showOrderTokenQr,
|
||||
set: (v) => emit('update:showOrderTokenQr', v)
|
||||
})
|
||||
|
||||
const mengyaQrIndex = computed({
|
||||
get: () => props.mengyaQrIndex,
|
||||
set: (v) => emit('update:mengyaQrIndex', v)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<span class="inline-block px-4 py-1.5 rounded-full bg-gradient-to-br from-accent to-accent2 text-white text-[15px] font-semibold mb-3">订单已完成</span>
|
||||
<div v-if="deliveredCodes.length" class="my-3 flex flex-col items-center gap-2">
|
||||
<textarea
|
||||
class="w-[min(520px,100%)] min-h-[120px] px-4 py-3.5 rounded-lg border border-white/35 bg-white/90 text-apptext text-base leading-[1.7] resize-none"
|
||||
style="font-family: var(--font-serif);"
|
||||
readonly
|
||||
:value="deliveredCodes.join('\n')"
|
||||
></textarea>
|
||||
</div>
|
||||
<div v-else-if="isManualDelivery" class="flex items-start gap-3 p-4 bg-[rgba(90,180,120,0.08)] border border-[rgba(90,180,120,0.25)] rounded-lg text-apptext max-w-lg mx-auto text-left">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
||||
<div>
|
||||
<p class="text-base font-semibold text-[#3a9a68] mb-1">等待发货中</p>
|
||||
<p class="tag">管理员将尽快处理您的订单并进行发货,请关注您的邮箱或联系方式。</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="tag mt-3">感谢购买!如有问题请联系售后邮箱。</p>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
deliveredCodes: { type: Array, default: () => [] },
|
||||
isManualDelivery: { type: Boolean, default: false }
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div v-if="product.requireLogin && !loggedIn" class="flex flex-col gap-3 items-center p-5 text-center border border-white/35 rounded-lg bg-white/60">
|
||||
<p class="text-base font-semibold text-apptext">该商品需要登录后才能购买</p>
|
||||
<a class="primary inline-block no-underline px-5 py-2 rounded-lg text-[15px] font-bold cursor-pointer" :href="loginUrl">立即登录</a>
|
||||
</div>
|
||||
|
||||
<form v-else class="flex flex-col gap-1.5 min-w-0 w-full" @submit.prevent="emit('submit')">
|
||||
<p class="tag text-accent" v-if="product.maxPerAccount > 0">
|
||||
该商品每个账户最多可购买 {{ product.maxPerAccount }} 个
|
||||
</p>
|
||||
|
||||
<div class="form-field !mb-1">
|
||||
<label>备注(选填)</label>
|
||||
<textarea v-model="form.note" class="!min-h-[72px]" placeholder="填写您的备注信息…" rows="2"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="form-field !mb-1">
|
||||
<label>手机号(选填)</label>
|
||||
<input v-model="form.contactPhone" type="tel" placeholder="138xxxx0000" />
|
||||
</div>
|
||||
<div class="form-field !mb-1">
|
||||
<label>邮箱(选填)</label>
|
||||
<input v-model="form.contactEmail" type="email" placeholder="you@example.com" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="tag leading-snug" v-if="!loggedIn">
|
||||
<a class="text-accent2 no-underline font-semibold hover:underline" :href="loginUrl">登录萌芽账号</a> 后购买可享受:历史订单记录、专属优惠商品及更多购买权益
|
||||
</p>
|
||||
|
||||
<button class="primary w-full py-2" type="submit" :disabled="submitting">
|
||||
{{ submitting ? '生成中...' : '生成二维码下单' }}
|
||||
</button>
|
||||
<p class="text-[#d64848] text-[15px]" v-if="error">{{ error }}</p>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
product: { type: Object, required: true },
|
||||
form: { type: Object, required: true },
|
||||
loggedIn: { type: Boolean, required: true },
|
||||
loginUrl: { type: String, required: true },
|
||||
submitting: { type: Boolean, default: false },
|
||||
error: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['submit'])
|
||||
</script>
|
||||
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<h4 class="text-base sm:text-lg font-bold mb-3 break-words leading-snug m-0 text-left sm:text-center">
|
||||
<template v-if="isMengyaPendingPay">等待支付到账</template>
|
||||
<template v-else>订单已创建</template>
|
||||
</h4>
|
||||
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2 justify-start sm:justify-center">
|
||||
<span class="font-mono text-[12px] sm:text-[13px] text-muted break-all text-left max-w-full sm:max-w-[min(100%,28rem)]">
|
||||
{{ orderId }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="checkout-copy-id shrink-0 text-[13px] font-semibold px-3 py-1.5 rounded-lg border border-white/50 bg-white/70 text-apptext hover:bg-white/90 active:scale-[0.98] transition-colors"
|
||||
@click="emit('copy-order-id')"
|
||||
>{{ copyHint || '复制订单号' }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
orderId: { type: String, required: true },
|
||||
isMengyaPendingPay: { type: Boolean, required: true },
|
||||
copyHint: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['copy-order-id'])
|
||||
</script>
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="flex flex-row items-start gap-2.5 bg-white/80 rounded-[var(--radius-default)] border border-white/35 p-2.5 min-w-0 sm:gap-3 sm:p-3">
|
||||
<img
|
||||
:src="product.coverUrl"
|
||||
:alt="product.name"
|
||||
class="w-[92px] h-[92px] object-cover rounded-lg shrink-0 sm:w-[112px] sm:h-[112px]"
|
||||
/>
|
||||
<div class="flex-1 flex flex-col gap-1 min-w-0 w-full">
|
||||
<h3 class="text-base leading-tight sm:text-[17px]">{{ product.name }}</h3>
|
||||
<p class="tag">库存:{{ stockLabel(product) }}</p>
|
||||
<p class="tag">浏览量:{{ product.viewCount || 0 }}</p>
|
||||
<p class="font-semibold text-accent2 leading-tight">
|
||||
<span v-if="isFree" class="text-[#3a9a68] font-black">免费</span>
|
||||
<span v-else-if="product.discountPrice > 0 && product.discountPrice < product.price">
|
||||
<span class="line-through text-muted mr-1.5 text-[15px]">¥ {{ product.price.toFixed(2) }}</span>
|
||||
¥ {{ product.discountPrice.toFixed(2) }}
|
||||
</span>
|
||||
<span v-else>¥ {{ product.price.toFixed(2) }}</span>
|
||||
</p>
|
||||
<p class="markdown break-words overflow-x-auto max-w-full text-[13px] leading-snug [&_p]:my-0.5 [&_p:last-child]:mb-0" v-html="renderMarkdown(product.description)"></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { computed } from 'vue'
|
||||
import { stockLabel } from '../../shared/fulfillment'
|
||||
|
||||
const props = defineProps({
|
||||
product: { type: Object, required: true }
|
||||
})
|
||||
|
||||
const md = new MarkdownIt()
|
||||
const renderMarkdown = (content) => md.render(content || '')
|
||||
|
||||
function getPayPrice(p) {
|
||||
if (!p) return 0
|
||||
if (p.price === 0) return 0
|
||||
if (p.discountPrice > 0 && p.discountPrice < p.price) return p.discountPrice
|
||||
return p.price
|
||||
}
|
||||
|
||||
const isFree = computed(() => getPayPrice(props.product) === 0)
|
||||
</script>
|
||||
@@ -1,108 +1,118 @@
|
||||
<template>
|
||||
<div
|
||||
class="block no-underline text-inherit h-full"
|
||||
:class="isSoldOut ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'"
|
||||
@click="$emit('click', item)"
|
||||
>
|
||||
<article class="flex flex-col flex-1 gap-3 h-full bg-white/72 border border-white/35 rounded-lg p-[18px] shadow-[0_12px_30px_rgba(33,33,40,0.1)] max-md:p-2.5">
|
||||
<!-- Cover image -->
|
||||
<div class="relative rounded-md overflow-hidden">
|
||||
<img
|
||||
:src="item.coverUrl"
|
||||
:alt="item.name"
|
||||
class="w-full h-[140px] object-cover block max-md:h-[100px]"
|
||||
/>
|
||||
<div
|
||||
v-if="isSoldOut"
|
||||
class="absolute inset-0 flex items-center justify-center bg-[rgba(20,18,22,0.52)] backdrop-blur-sm text-white text-[22px] font-semibold tracking-[4px] pointer-events-none"
|
||||
style="font-family: var(--font-serif);"
|
||||
>已售空</div>
|
||||
<div
|
||||
v-else-if="item.requireLogin"
|
||||
class="absolute top-2 right-2 px-2.5 py-0.5 rounded-full bg-accent/85 text-white text-[13px] font-bold pointer-events-none backdrop-blur-sm tracking-[0.5px]"
|
||||
>需登录</div>
|
||||
<button
|
||||
v-if="loggedIn"
|
||||
class="absolute bottom-2 right-2 p-0 w-auto h-auto border-none bg-transparent shadow-none text-xl leading-none text-white/90 [text-shadow:0_1px_4px_rgba(0,0,0,0.5)] transition-transform hover:scale-125"
|
||||
:class="inWishlist ? 'text-[#ffb347] [text-shadow:0_1px_6px_rgba(255,140,0,0.6)]' : ''"
|
||||
type="button"
|
||||
@click="onWishlistClick"
|
||||
:title="inWishlist ? '取消收藏' : '加入收藏'"
|
||||
>{{ inWishlist ? '★' : '☆' }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Name + price -->
|
||||
<div class="flex items-baseline justify-between gap-3 max-md:flex-row max-md:items-start max-md:gap-1">
|
||||
<h3 class="text-[18px] font-bold text-apptext leading-[1.2] max-md:text-sm max-md:leading-[1.3] max-md:flex-1 max-md:min-w-0 max-md:break-all">{{ item.name }}</h3>
|
||||
<div class="flex items-center justify-end max-md:flex-shrink-0 max-md:text-[13px]">
|
||||
<span v-if="isFree" class="text-[#3a9a68] font-black max-md:text-[13px]">免费</span>
|
||||
<span v-else-if="item.discountPrice > 0 && item.discountPrice < item.price" class="text-accent2 font-semibold">
|
||||
<span class="line-through text-muted mr-1.5 text-[15px] max-md:text-[11px] max-md:mr-0.5">¥ {{ item.price.toFixed(2) }}</span>
|
||||
¥ {{ item.discountPrice.toFixed(2) }}
|
||||
</span>
|
||||
<span v-else class="text-accent2 font-semibold">¥ {{ item.price.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="flex-1 flex items-center justify-center text-center">
|
||||
<div
|
||||
class="markdown text-center text-[15px] text-muted leading-relaxed line-clamp-3 overflow-hidden max-md:text-xs max-md:line-clamp-2"
|
||||
v-html="renderedDescription"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Tags + meta -->
|
||||
<div class="flex flex-col gap-1.5 items-start max-md:gap-1 max-md:text-xs">
|
||||
<div v-if="item.tags && item.tags.length" class="flex flex-wrap gap-2 mt-2 max-md:gap-1 max-md:mt-1">
|
||||
<span
|
||||
v-for="tag in item.tags"
|
||||
:key="tag"
|
||||
class="px-2.5 py-1 rounded-full text-sm font-bold text-accent2 bg-[rgba(145,168,208,0.08)] border border-[rgba(145,168,208,0.18)] max-md:text-[11px] max-md:px-1.5 max-md:py-0.5"
|
||||
>{{ tag }}</span>
|
||||
</div>
|
||||
<div class="flex gap-2.5 items-center flex-wrap">
|
||||
<div class="text-sm text-muted max-md:text-[11px]">库存:{{ stockLabel(item) }}</div>
|
||||
<div class="text-sm text-muted max-md:text-[11px]">浏览量:{{ item.viewCount || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { isLoggedIn } from '../../shared/auth'
|
||||
import { isSoldOutProduct, stockLabel } from '../../shared/fulfillment'
|
||||
import { isInWishlist, toggleWishlist } from '../../shared/useWishlist'
|
||||
|
||||
const md = new MarkdownIt()
|
||||
|
||||
const props = defineProps({
|
||||
item: { type: Object, required: true }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['click'])
|
||||
|
||||
const inWishlist = computed(() => isInWishlist(props.item.id))
|
||||
const loggedIn = computed(() => isLoggedIn())
|
||||
|
||||
const onWishlistClick = (e) => {
|
||||
e.stopPropagation()
|
||||
toggleWishlist(props.item.id)
|
||||
}
|
||||
|
||||
const payPrice = computed(() => {
|
||||
if (!props.item) return 0
|
||||
if (props.item.price === 0) return 0
|
||||
if (props.item.discountPrice > 0 && props.item.discountPrice < props.item.price) {
|
||||
return props.item.discountPrice
|
||||
}
|
||||
return props.item.price
|
||||
})
|
||||
|
||||
const isFree = computed(() => payPrice.value === 0)
|
||||
const isSoldOut = computed(() => isSoldOutProduct(props.item))
|
||||
const renderedDescription = computed(() => md.render(props.item.description || ''))
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="block no-underline text-inherit h-full"
|
||||
:class="isSoldOut ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'"
|
||||
@click="$emit('click', item)"
|
||||
>
|
||||
<article class="product-card flex flex-col flex-1 gap-3 h-full bg-white/72 border border-white/35 rounded-xl p-4 shadow-[0_8px_22px_rgba(33,33,40,0.08)] max-md:gap-2.5 max-md:rounded-lg max-md:p-3">
|
||||
<div class="relative rounded-lg overflow-hidden h-[168px] sm:h-[188px] md:h-[208px] max-md:h-[128px] max-md:rounded-md">
|
||||
<img
|
||||
:src="item.coverUrl"
|
||||
:alt="item.name"
|
||||
class="w-full h-full object-cover block"
|
||||
/>
|
||||
<div
|
||||
v-if="isSoldOut"
|
||||
class="absolute inset-0 flex items-center justify-center bg-[rgba(20,18,22,0.52)] backdrop-blur-sm text-white text-[17px] font-semibold tracking-[3px] pointer-events-none"
|
||||
style="font-family: var(--font-serif);"
|
||||
>已售空</div>
|
||||
<div
|
||||
v-else-if="item.requireLogin"
|
||||
class="absolute top-1.5 right-1.5 px-2 py-0.5 rounded-full bg-accent/85 text-white text-[11px] font-bold pointer-events-none backdrop-blur-sm"
|
||||
>需登录</div>
|
||||
<button
|
||||
v-if="loggedIn"
|
||||
class="absolute bottom-1.5 right-1.5 p-0 w-auto h-auto border-none bg-transparent shadow-none text-lg leading-none text-white/90 [text-shadow:0_1px_4px_rgba(0,0,0,0.5)] transition-transform hover:scale-125"
|
||||
:class="inWishlist ? 'text-[#ffb347] [text-shadow:0_1px_6px_rgba(255,140,0,0.6)]' : ''"
|
||||
type="button"
|
||||
@click="onWishlistClick"
|
||||
:title="inWishlist ? '取消收藏' : '加入收藏'"
|
||||
>{{ inWishlist ? '★' : '☆' }}</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start justify-between gap-2 min-h-0">
|
||||
<h3 class="text-[17px] sm:text-[18px] font-bold text-apptext leading-tight line-clamp-2 flex-1 min-w-0 max-md:text-[14px]">{{ item.name }}</h3>
|
||||
<div class="flex items-center justify-end shrink-0 text-[16px] max-md:text-[13px]">
|
||||
<span v-if="isFree" class="text-[#3a9a68] font-black whitespace-nowrap">免费</span>
|
||||
<span v-else-if="item.discountPrice > 0 && item.discountPrice < item.price" class="text-accent2 font-semibold whitespace-nowrap text-right">
|
||||
<span class="line-through text-muted mr-1 text-[12px] max-md:text-[10px]">¥{{ item.price.toFixed(2) }}</span>
|
||||
¥{{ item.discountPrice.toFixed(2) }}
|
||||
</span>
|
||||
<span v-else class="text-accent2 font-semibold whitespace-nowrap">¥{{ item.price.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="product-card-description text-left text-[14px] text-muted leading-snug max-md:text-[12px]"
|
||||
:title="descriptionText"
|
||||
>{{ descriptionText || '暂无商品介绍' }}</p>
|
||||
|
||||
<div class="mt-auto flex flex-col gap-1.5 pt-0.5 border-t border-white/20">
|
||||
<div v-if="item.tags && item.tags.length" class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="tag in item.tags"
|
||||
:key="tag"
|
||||
class="px-1.5 py-0.5 rounded-full text-[11px] font-semibold text-accent2 bg-[rgba(217,168,120,0.12)] border border-[rgba(184,109,92,0.22)] max-md:text-[10px]"
|
||||
>{{ tag }}</span>
|
||||
</div>
|
||||
<p class="text-[13px] text-muted m-0 max-md:text-[10px]">
|
||||
库存 {{ stockLabel(item) }}<span class="text-muted/70"> · </span>已售 {{ totalSoldDisplay }}<span class="text-muted/70"> · </span>浏览 {{ item.viewCount || 0 }}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { isLoggedIn } from '../../shared/auth'
|
||||
import { isSoldOutProduct, stockLabel } from '../../shared/fulfillment'
|
||||
import { isInWishlist, toggleWishlist } from '../../shared/useWishlist'
|
||||
|
||||
const md = new MarkdownIt()
|
||||
|
||||
const props = defineProps({
|
||||
item: { type: Object, required: true }
|
||||
})
|
||||
|
||||
defineEmits(['click'])
|
||||
|
||||
const inWishlist = computed(() => isInWishlist(props.item.id))
|
||||
const loggedIn = computed(() => isLoggedIn())
|
||||
|
||||
const onWishlistClick = (e) => {
|
||||
e.stopPropagation()
|
||||
toggleWishlist(props.item.id)
|
||||
}
|
||||
|
||||
const payPrice = computed(() => {
|
||||
if (!props.item) return 0
|
||||
if (props.item.price === 0) return 0
|
||||
if (props.item.discountPrice > 0 && props.item.discountPrice < props.item.price) {
|
||||
return props.item.discountPrice
|
||||
}
|
||||
return props.item.price
|
||||
})
|
||||
|
||||
const isFree = computed(() => payPrice.value === 0)
|
||||
const isSoldOut = computed(() => isSoldOutProduct(props.item))
|
||||
const totalSoldDisplay = computed(() => {
|
||||
const n = props.item?.totalSold
|
||||
return typeof n === 'number' && !Number.isNaN(n) ? n : 0
|
||||
})
|
||||
|
||||
const descriptionText = computed(() => {
|
||||
const html = md.render(props.item.description || '')
|
||||
return html
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const DEFAULT_MS = 2000
|
||||
|
||||
/**
|
||||
* Clipboard copy with short-lived button hint ("已复制" / "复制失败").
|
||||
*/
|
||||
export function useClipboardFeedback(resetMs = DEFAULT_MS) {
|
||||
const hint = ref('')
|
||||
let timer = null
|
||||
|
||||
const copy = async (text) => {
|
||||
if (!text) return
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} else {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
ta.setAttribute('readonly', '')
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.left = '-9999px'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
hint.value = '已复制'
|
||||
} catch {
|
||||
hint.value = '复制失败'
|
||||
}
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
hint.value = ''
|
||||
}, resetMs)
|
||||
}
|
||||
|
||||
const dispose = () => {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
return { hint, copy, dispose }
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { onUnmounted, watch } from 'vue'
|
||||
import { fetchOrderPaymentStatus } from '../../shared/api'
|
||||
|
||||
const POLL_MS = 3000
|
||||
|
||||
/**
|
||||
* Polls payment status for mengya pending_payment until completed/cancelled or unmount.
|
||||
*/
|
||||
export function useMengyaPaymentPolling(orderResult, { onCompleted, onCancelled }) {
|
||||
let pollTimer = null
|
||||
|
||||
const stop = () => {
|
||||
if (pollTimer != null) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const start = () => {
|
||||
stop()
|
||||
if (!orderResult.value?.orderId) return
|
||||
if (!(orderResult.value.paymentMethod === 'mengya' && orderResult.value.status === 'pending_payment')) {
|
||||
return
|
||||
}
|
||||
|
||||
pollTimer = setInterval(async () => {
|
||||
const id = orderResult.value?.orderId
|
||||
if (!id) return
|
||||
try {
|
||||
const st = await fetchOrderPaymentStatus(id)
|
||||
if (!st?.status) return
|
||||
|
||||
orderResult.value = { ...orderResult.value, status: st.status }
|
||||
|
||||
if (st.status === 'completed') {
|
||||
stop()
|
||||
await onCompleted()
|
||||
} else if (st.status === 'cancelled') {
|
||||
stop()
|
||||
onCancelled()
|
||||
}
|
||||
} catch {
|
||||
/* ignore poll errors */
|
||||
}
|
||||
}, POLL_MS)
|
||||
}
|
||||
|
||||
watch(
|
||||
orderResult,
|
||||
(v) => {
|
||||
if (!v) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
if (v.paymentMethod === 'mengya' && v.status === 'pending_payment') {
|
||||
start()
|
||||
} else {
|
||||
stop()
|
||||
}
|
||||
},
|
||||
{ deep: false }
|
||||
)
|
||||
|
||||
onUnmounted(stop)
|
||||
|
||||
return { stopPollingPayment: stop }
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
function formatCountdown(ms) {
|
||||
if (ms <= 0) return '00:00'
|
||||
const totalSec = Math.floor(ms / 1000)
|
||||
const h = Math.floor(totalSec / 3600)
|
||||
const m = Math.floor((totalSec % 3600) / 60)
|
||||
const s = totalSec % 60
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
if (h > 0) return `${pad(h)}:${pad(m)}:${pad(s)}`
|
||||
return `${pad(m)}:${pad(s)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Live countdown for mengya pending_payment orders with paymentExpiresAt.
|
||||
*/
|
||||
export function usePaymentCountdown(orderResult, isMengyaPendingPay) {
|
||||
const paymentNowTick = ref(Date.now())
|
||||
let intervalId = null
|
||||
|
||||
const stop = () => {
|
||||
if (intervalId != null) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
}
|
||||
|
||||
const start = () => {
|
||||
stop()
|
||||
const v = orderResult.value
|
||||
if (!v?.paymentExpiresAt || v.paymentMethod !== 'mengya' || v.status !== 'pending_payment') return
|
||||
paymentNowTick.value = Date.now()
|
||||
intervalId = setInterval(() => {
|
||||
paymentNowTick.value = Date.now()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const paymentCountdownMs = computed(() => {
|
||||
paymentNowTick.value
|
||||
const exp = orderResult.value?.paymentExpiresAt
|
||||
if (!exp || !isMengyaPendingPay.value) return null
|
||||
const end = new Date(exp).getTime()
|
||||
if (Number.isNaN(end)) return null
|
||||
return Math.max(0, end - Date.now())
|
||||
})
|
||||
|
||||
const showPaymentCountdown = computed(
|
||||
() => Boolean(orderResult.value?.paymentExpiresAt) && isMengyaPendingPay.value
|
||||
)
|
||||
|
||||
const paymentCountdownDisplay = computed(() => {
|
||||
const ms = paymentCountdownMs.value
|
||||
if (ms === null) return '--:--'
|
||||
return formatCountdown(ms)
|
||||
})
|
||||
|
||||
const paymentCountdownExpired = computed(() => {
|
||||
const ms = paymentCountdownMs.value
|
||||
return ms !== null && ms <= 0
|
||||
})
|
||||
|
||||
const paymentCountdownUrgent = computed(() => {
|
||||
const ms = paymentCountdownMs.value
|
||||
return ms !== null && ms > 0 && ms < 60_000
|
||||
})
|
||||
|
||||
watch(
|
||||
orderResult,
|
||||
(v) => {
|
||||
if (!v) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
if (v.paymentMethod === 'mengya' && v.status === 'pending_payment') {
|
||||
start()
|
||||
} else {
|
||||
stop()
|
||||
}
|
||||
},
|
||||
{ deep: false }
|
||||
)
|
||||
|
||||
onUnmounted(stop)
|
||||
|
||||
return {
|
||||
paymentCountdownMs,
|
||||
showPaymentCountdown,
|
||||
paymentCountdownDisplay,
|
||||
paymentCountdownExpired,
|
||||
paymentCountdownUrgent,
|
||||
startPaymentCountdown: start,
|
||||
stopPaymentCountdown: stop
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
<template>
|
||||
<section class="page-card" v-if="!loggedIn">
|
||||
<section v-if="!loggedIn">
|
||||
<div class="flex flex-col items-center gap-4 py-12 text-center">
|
||||
<h2>请先登录</h2>
|
||||
<p class="tag">登录萌芽账号后即可查看你的历史订单记录。</p>
|
||||
<a
|
||||
class="inline-flex items-center px-[18px] py-[10px] rounded-lg bg-gradient-to-br from-accent to-accent2 text-white no-underline text-[17px] shadow-[0_10px_30px_rgba(145,168,208,0.35)] hover:-translate-y-px transition-transform"
|
||||
class="inline-flex items-center px-[18px] py-[10px] rounded-lg bg-gradient-to-br from-accent to-accent2 text-white no-underline text-[17px] shadow-[0_10px_30px_rgba(184,109,92,0.32)] hover:-translate-y-px transition-transform"
|
||||
:href="loginUrl"
|
||||
>使用萌芽账号登录</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="page-card" v-else>
|
||||
<section v-else>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between gap-3.5 mb-[22px] flex-wrap max-md:gap-2.5">
|
||||
<div>
|
||||
@@ -19,7 +19,7 @@
|
||||
{{ authState.username || authState.account }}
|
||||
<span
|
||||
v-if="orders.length"
|
||||
class="ml-1.5 text-[13px] text-muted bg-[rgba(180,154,203,0.12)] px-2 py-0.5 rounded-full"
|
||||
class="ml-1.5 text-[13px] text-muted bg-[rgba(217,168,120,0.14)] px-2 py-0.5 rounded-full"
|
||||
>共 {{ orders.length }} 笔</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -56,7 +56,7 @@
|
||||
<div
|
||||
class="px-2.5 py-0.5 rounded-full text-xs font-semibold flex-shrink-0"
|
||||
:class="order.deliveryMode === 'manual' && !order.deliveredCodes?.length
|
||||
? 'bg-[rgba(145,168,208,0.15)] text-[#5a7db0]'
|
||||
? 'bg-[rgba(217,168,120,0.16)] text-[#8b5038]'
|
||||
: 'bg-[rgba(100,185,140,0.15)] text-[#3a9a68]'"
|
||||
>
|
||||
{{ order.deliveryMode === 'manual' && !order.deliveredCodes?.length ? '等待发货' : '已完成' }}
|
||||
@@ -97,7 +97,7 @@
|
||||
<!-- Manual delivery pending -->
|
||||
<div
|
||||
v-else-if="order.deliveryMode === 'manual'"
|
||||
class="flex items-center gap-2 mx-4 my-3 px-3.5 py-2.5 bg-[rgba(145,168,208,0.1)] rounded-md border-l-[3px] border-accent2 text-[13px] text-[#5a7db0]"
|
||||
class="flex items-center gap-2 mx-4 my-3 px-3.5 py-2.5 bg-[rgba(217,168,120,0.1)] rounded-md border-l-[3px] border-accent2 text-[13px] text-[#8b5038]"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
订单已提交,等待人工发货,请耐心等候
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<section class="page-card">
|
||||
<div>
|
||||
<div class="flex items-baseline gap-3 mb-5">
|
||||
<h2>我的收藏夹</h2>
|
||||
<p class="tag">共 {{ wishlistItems.length }} 件商品</p>
|
||||
@@ -23,7 +23,7 @@
|
||||
@click="handleClick"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
@@ -9,7 +9,7 @@ export default defineConfig({
|
||||
vue(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon.ico', 'logo.png', 'apple-touch-icon-180x180.png'],
|
||||
includeAssets: ['favicon.ico', 'apple-touch-icon-180x180.png'],
|
||||
manifest: {
|
||||
name: '萌芽小店',
|
||||
short_name: '萌芽小店',
|
||||
|
||||
Reference in New Issue
Block a user