Update mengyastore

This commit is contained in:
2026-05-13 12:11:46 +08:00
parent ad54b1eb7e
commit 37983f3b60
156 changed files with 10064 additions and 5681 deletions

View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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"
>&times;</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

View File

@@ -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;
}
}

View File

@@ -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')

View File

@@ -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"
>

View File

@@ -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">

View File

@@ -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 -->

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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) || {}
}

View File

@@ -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"
>&times;</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>

View 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>

View 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>

View File

@@ -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 () => {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, ' ')
.trim()
})
</script>

View File

@@ -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 }
}

View File

@@ -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 }
}

View File

@@ -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
}
}

View File

@@ -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>
订单已提交等待人工发货请耐心等候

View File

@@ -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>

View File

@@ -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: '萌芽小店',