feat: fulfillment modes, MQ, admin status, docs; scrub compose secrets

Made-with: Cursor
This commit is contained in:
2026-04-03 16:26:38 +08:00
parent 1f16966174
commit b4c5103fb3
56 changed files with 4088 additions and 3742 deletions

View File

@@ -1,167 +0,0 @@
# 萌芽小店 · 前端
基于 **Vue 3 + Vite** 构建的数字商品销售平台前端,采用组件化模块架构。
## 技术依赖
| 包 | 版本 | 用途 |
|----|------|------|
| vue | ^3.x | 核心框架 |
| vite | ^5.x | 构建工具 |
| vue-router | ^4.x | 路由管理 |
| pinia | ^2.x | 状态管理 |
| axios | ^1.6 | HTTP 请求 |
| markdown-it | ^14 | Markdown 渲染 |
## 目录结构
```
src/
├── assets/
│ └── styles.css # 全局 CSS 变量与公共样式
├── router/
│ └── index.js # 路由配置(含维护模式守卫)
├── modules/
│ ├── shared/
│ │ ├── api.js # 所有 API 请求封装
│ │ ├── auth.js # 认证状态Pinia store
│ │ └── useWishlist.js # 收藏夹 Composable
│ ├── store/
│ │ ├── StorePage.vue # 商品列表页
│ │ ├── ProductDetail.vue # 商品详情页
│ │ ├── CheckoutPage.vue # 结算页
│ │ └── components/
│ │ └── ProductCard.vue # 商品卡片组件
│ ├── admin/
│ │ ├── AdminPage.vue # 管理后台(编排层)
│ │ └── components/
│ │ ├── AdminTokenRow.vue # 令牌输入
│ │ ├── AdminMaintenanceRow.vue # 维护模式开关
│ │ ├── AdminProductTable.vue # 商品列表表格
│ │ ├── AdminProductModal.vue # 商品编辑弹窗
│ │ ├── AdminOrderTable.vue # 订单记录表格
│ │ └── AdminChatPanel.vue # 用户聊天管理
│ ├── user/
│ │ └── MyOrdersPage.vue # 我的订单
│ ├── wishlist/
│ │ └── WishlistPage.vue # 收藏夹页面
│ ├── maintenance/
│ │ └── MaintenancePage.vue # 站点维护页面
│ └── chat/
│ └── ChatWidget.vue # 用户悬浮聊天窗口
└── App.vue # 根组件(全局布局 + 导航)
```
## 路由列表
| 路径 | 组件 | 说明 |
|------|------|------|
| `/` | `StorePage` | 商品列表 |
| `/product/:id` | `ProductDetail` | 商品详情 |
| `/product/:id/checkout` | `CheckoutPage` | 结算页 |
| `/my/orders` | `MyOrdersPage` | 我的订单(需登录)|
| `/wishlist` | `WishlistPage` | 收藏夹(需登录)|
| `/admin` | `AdminPage` | 管理后台(需令牌)|
| `/maintenance` | `MaintenancePage` | 维护中页面 |
### 路由守卫
- **维护模式**`beforeEach` 检查 `GET /api/site/maintenance`,若站点维护中则重定向到 `/maintenance`(管理员访问 `/admin` 时豁免)
- 豁免路径:`/maintenance``/wishlist``/admin`
## 认证流程
使用 SproutGate OAuth 服务:
1. 未登录用户点击「萌芽账号登录」,跳转到 `https://auth.shumengya.top/login?callback=<当前页>`
2. 登录成功后回调携带 token存入 localStorage
3. `authState`reactive 对象)全局共享 `token``account``username``avatarUrl`
4. 所有需要认证的 API 请求在 `Authorization: Bearer <token>` 头部携带 token
## 主要功能模块
### 商品列表StorePage
- 视图模式(`viewMode`):全部 / 免费 / 新上架 / 最多购买 / 最多浏览 / 价格最高 / 价格最低
- 搜索:实时过滤商品名称
- 分页:桌面 20 条/页4×5手机 10 条/页5×2
- 响应式布局:`window.innerWidth <= 900` 切换为双列
### 结算页CheckoutPage
- 展示商品信息与价格
- 数量输入,最大值受 `product.maxPerAccount` 限制
- 若商品开启 `showNote`:展示备注文本框
- 若商品开启 `showContact`:展示手机号和邮箱输入框
- 手动发货商品:展示蓝色提示条,确认后显示"等待发货中"
- 自动发货商品:确认后展示卡密内容
### 收藏夹useWishlist Composable
```js
import { wishlistIds, wishlistCount, inWishlist, loadWishlist, toggleWishlist } from './useWishlist'
```
- 数据存储在后端,通过 API 同步
- `App.vue` 在登录状态变化时自动调用 `loadWishlist()`
- 收藏状态通过 `wishlistSet`computed Set提供 O(1) 查找
### 客服聊天ChatWidget
- 仅已登录用户显示(右下角悬浮按钮)
- 每 5 秒轮询新消息
- 客户端和服务端均限制每秒最多发送 1 条消息
## 开发启动
```bash
npm install
npm run dev # 开发服务器 :5173
npm run build # 生产构建 → dist/
npm run preview # 预览构建结果
```
### 环境变量
在项目根目录创建 `.env.local`
```env
VITE_API_BASE_URL=http://localhost:8080
```
生产部署时设置实际 API 地址。
## 全局样式
`src/assets/styles.css` 定义了全局 CSS 变量:
```css
:root {
--accent: #91a8d0; /* 主题色 */
--accent-2: #b8c8e4; /* 渐变色 */
--text: #2c2c3a; /* 文字色 */
--muted: #8e8e9e; /* 次要文字 */
--line: rgba(0,0,0,0.08); /* 边框色 */
--radius: 8px; /* 圆角 */
}
```
字体使用楷体KaiTi / STKaitifallback 到系统衬线字体。
## 构建与部署
```bash
npm run build
```
产物在 `dist/` 目录,为静态文件,部署到 Nginx / CDN 时需配置:
```nginx
location / {
try_files $uri $uri/ /index.html; # SPA 路由支持
}
location /api/ {
proxy_pass http://localhost:8080; # 反向代理到后端
}
```

View File

@@ -15,60 +15,149 @@
<!-- PWA Splash Screen -->
<SplashScreen :visible="showSplash" />
<!-- Top navigation bar -->
<header class="flex items-center justify-between gap-5 px-[5vw] py-4 glass-strong border-b border-white/35 shadow-[0_4px_24px_rgba(33,33,40,0.08)] sticky top-0 z-[100] max-md:flex-row max-md:flex-nowrap max-md:px-[3vw] max-md:py-2 max-md:gap-1.5">
<!-- Brand -->
<div class="flex gap-4 items-center cursor-pointer select-none max-md:gap-2 max-md:flex-shrink-0" @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 max-md:w-8 max-md:h-8">
<img src="/logo.png" alt="萌芽小店" draggable="false" class="w-full h-full object-contain" />
</div>
<div>
<h1 class="text-[28px] tracking-wide max-md:text-base max-md:whitespace-nowrap">萌芽小店</h1>
</div>
</div>
<!-- 移动端菜单遮罩 -->
<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"
/>
<!-- Actions -->
<div class="flex gap-3 items-center max-md:gap-1 max-md:flex-shrink-0 max-md:overflow-hidden">
<button class="ghost" @click="goHome">商店</button>
<template v-if="loggedIn">
<button class="ghost relative inline-flex items-center gap-1" @click="goWishlist">
<span class="max-md:text-xs"></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 max-md:min-w-[14px] max-md:h-[14px] max-md:text-[9px]"
>{{ wishlistCount }}</span>
</button>
<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 max-md:px-1.5 max-md:gap-1"
@click="goProfile"
>
<img
v-if="authState.avatarUrl"
class="w-7 h-7 rounded-full object-cover border border-white/35 max-md:w-5 max-md:h-5"
: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 max-md:w-5 max-md:h-5 max-md:text-xs"
>
{{ (authState.username || authState.account || '?').charAt(0) }}
</div>
<span class="text-[17px] font-bold text-apptext max-w-[100px] overflow-hidden text-ellipsis whitespace-nowrap max-md:text-xs max-md:max-w-[50px]">
{{ authState.username || authState.account }}
</span>
<!-- 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>
<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 max-md:text-xs max-md:px-2 max-md:py-1.5 max-md:whitespace-nowrap"
:href="loginUrl"
>萌芽账号登录</a>
</div>
</header>
<div class="min-w-0">
<h1 class="text-[28px] tracking-wide truncate max-md:text-[15px] max-md:font-semibold">萌芽小店</h1>
</div>
</div>
<!-- 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>
</div>
<!-- Admin modal -->
<Teleport to="body">
@@ -175,7 +264,7 @@
</template>
<script setup>
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { verifyAdminToken, fetchStats, recordSiteVisit } from './modules/shared/api'
import { authState, isLoggedIn, clearAuth, getLoginUrl } from './modules/shared/auth'
@@ -255,6 +344,44 @@ const goProfile = () => {
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 () => {
@@ -283,6 +410,13 @@ 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 = ''
})
watch(() => authState.token, async (newToken) => {
@@ -291,3 +425,37 @@ 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.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

View File

@@ -132,6 +132,10 @@
.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);

View File

@@ -42,19 +42,45 @@
</div>
<div class="form-field">
<div class="flex items-center gap-3 flex-wrap">
<label>商品库存</label>
<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>
<label>发货方式</label>
<div class="flex flex-col gap-2 rounded-lg border border-white/35 bg-white/70 p-3">
<label class="flex items-start gap-2.5 cursor-pointer font-semibold text-apptext m-0">
<input v-model="form.fulfillmentType" class="mt-1 w-auto accent-accent" type="radio" value="card" />
<span>
卡密 / 逐条库存
<span class="block text-[13px] font-normal text-muted">每条库存单独添加下单按条扣减售完不可再买</span>
</span>
</label>
<label class="flex items-start gap-2.5 cursor-pointer font-semibold text-apptext m-0">
<input v-model="form.fulfillmentType" class="mt-1 w-auto accent-accent" type="radio" value="fixed" />
<span>
固定内容不限库存
<span class="block text-[13px] font-normal text-muted">同一篇内容反复发货如网盘链接统一说明无需维护条数</span>
</span>
</label>
</div>
<p class="tag">每个输入框保存一条可发放内容购买后会直接展示给用户</p>
</div>
<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">
<div class="flex items-center gap-3 flex-wrap">
<label>卡密 / 库存条目</label>
<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
:ref="(el) => setInventoryInputRef(el, index)"
v-model="form.inventoryItems[index]"
class="flex-1"
placeholder="库存内容,可填卡密、下载链接等"
placeholder="单条卡密或可发放内容"
/>
<button
v-if="form.inventoryItems.length > 1"
@@ -176,6 +202,7 @@ const normalizeScreenshotUrls = (values = []) =>
const makeEmptyForm = () => ({
id: '', name: '', price: 0, discountPrice: 0, tagsText: '', coverUrl: '',
screenshotUrls: createScreenshotSlots(), inventoryItems: createInventoryItems(),
fulfillmentType: 'card', fixedContent: '',
description: '', active: true, requireLogin: false, maxPerAccount: 0,
deliveryMode: 'auto', showNote: true, showContact: true
})
@@ -183,14 +210,18 @@ const makeEmptyForm = () => ({
const form = reactive(makeEmptyForm())
const fillForm = (item) => {
const ft = (item?.fulfillmentType || 'card').toLowerCase() === 'fixed' ? 'fixed' : 'card'
Object.assign(form, {
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 || []),
inventoryItems: createInventoryItems(item?.codes || []),
inventoryItems: ft === 'fixed' ? [''] : createInventoryItems(item?.codes || []),
fulfillmentType: ft,
fixedContent: item?.fixedContent || '',
description: item?.description || '', active: item?.active ?? true,
requireLogin: item?.requireLogin ?? false, maxPerAccount: item?.maxPerAccount ?? 0,
deliveryMode: 'auto', showNote: item?.showNote ?? true, showContact: item?.showContact ?? true
deliveryMode: item?.deliveryMode || 'auto',
showNote: item?.showNote ?? true, showContact: item?.showContact ?? true
})
}
@@ -220,13 +251,16 @@ const removeInventoryItem = async (index) => {
}
const handleSubmit = () => {
const ft = form.fulfillmentType === 'fixed' ? 'fixed' : 'card'
emit('submit', {
id: form.id, name: form.name, price: form.price, discountPrice: form.discountPrice || 0,
tags: form.tagsText, coverUrl: form.coverUrl,
codes: normalizeInventoryItems(form.inventoryItems),
codes: ft === 'fixed' ? [] : normalizeInventoryItems(form.inventoryItems),
fulfillmentType: ft,
fixedContent: ft === 'fixed' ? (form.fixedContent || '').trim() : '',
screenshotUrls: normalizeScreenshotUrls(form.screenshotUrls),
description: form.description, active: form.active, requireLogin: form.requireLogin,
maxPerAccount: form.maxPerAccount || 0, deliveryMode: 'auto',
maxPerAccount: form.maxPerAccount || 0, deliveryMode: form.deliveryMode || 'auto',
showNote: form.showNote, showContact: form.showContact
})
}

View File

@@ -5,7 +5,7 @@
<tr class="bg-white/70 border-b-2 border-white/35">
<th class="px-4 py-3 text-sm font-semibold uppercase tracking-[0.6px] text-muted max-md:px-2.5 max-md:py-2 max-md:text-xs">商品</th>
<th class="px-4 py-3 text-sm font-semibold uppercase tracking-[0.6px] text-muted max-md:px-2.5 max-md:py-2 max-md:text-xs">价格</th>
<th class="px-4 py-3 text-sm font-semibold uppercase tracking-[0.6px] text-muted text-center max-md:px-2.5 max-md:py-2 max-md:text-xs">库存</th>
<th class="px-4 py-3 text-sm font-semibold uppercase tracking-[0.6px] text-muted text-center max-md:px-2.5 max-md:py-2 max-md:text-xs">库存 / 方式</th>
<th class="px-4 py-3 text-sm font-semibold uppercase tracking-[0.6px] text-muted text-center max-md:px-2.5 max-md:py-2 max-md:text-xs">浏览量</th>
<th class="px-4 py-3 text-sm font-semibold uppercase tracking-[0.6px] text-muted max-md:px-2.5 max-md:py-2 max-md:text-xs">状态</th>
<th class="px-4 py-3 text-sm font-semibold uppercase tracking-[0.6px] text-muted max-md:px-2.5 max-md:py-2 max-md:text-xs">操作</th>
@@ -40,9 +40,10 @@
</td>
<td class="px-4 py-3.5 text-center align-middle max-md:px-2.5 max-md:py-2">
<span
class="inline-block px-2.5 py-0.5 rounded-full text-[15px] font-semibold"
:class="item.quantity === 0 ? 'bg-[rgba(201,90,106,0.12)] text-[#c95a6a]' : 'bg-[rgba(100,185,140,0.15)] text-[#3a9a68]'"
>{{ item.quantity }}</span>
class="inline-block px-2.5 py-0.5 rounded-full text-[13px] font-semibold"
:class="stockBadgeClass(item)"
>{{ stockLabel(item) }}</span>
<div class="text-[11px] text-muted mt-1">{{ fulfillmentHint(item) }}</div>
</td>
<td class="px-4 py-3.5 text-center text-[15px] text-muted align-middle max-md:px-2.5 max-md:py-2">{{ item.viewCount || 0 }}</td>
<td class="px-4 py-3.5 align-middle max-md:px-2.5 max-md:py-2">
@@ -77,6 +78,19 @@
</template>
<script setup>
import { isFixedFulfillment, stockLabel as stockLabelText } from '../../shared/fulfillment'
defineProps({ products: { type: Array, default: () => [] } })
defineEmits(['edit', 'toggle', 'remove'])
const stockLabel = (item) => stockLabelText(item)
const stockBadgeClass = (item) => {
if (isFixedFulfillment(item)) return 'bg-[rgba(100,185,140,0.15)] text-[#3a9a68]'
return (item.quantity ?? 0) === 0
? 'bg-[rgba(201,90,106,0.12)] text-[#c95a6a]'
: 'bg-[rgba(100,185,140,0.15)] text-[#3a9a68]'
}
const fulfillmentHint = (item) => (isFixedFulfillment(item) ? '固定内容' : '卡密')
</script>

View File

@@ -0,0 +1,254 @@
<template>
<div>
<div class="flex items-center justify-between mb-4">
<p class="text-[13px] text-muted">后端 APIMySQLRedisRabbitMQ 探活与配置摘要需管理 Token</p>
<button
class="ghost inline-flex items-center gap-1.5 text-sm px-3.5 py-1.5"
:class="loading ? 'opacity-50 pointer-events-none' : ''"
type="button"
@click="load"
>
<svg
width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
:class="loading ? 'animate-spin' : ''"
>
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
<path d="M21 3v5h-5"/>
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
<path d="M8 16H3v5"/>
</svg>
{{ loading ? '检测中…' : '刷新' }}
</button>
</div>
<div
v-if="error"
class="mb-4 px-4 py-3 rounded-lg text-sm text-[#c95a6a] bg-[rgba(201,90,106,0.08)] border border-[rgba(201,90,106,0.2)]"
>
{{ error }}
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- 后端 API -->
<div class="px-[18px] py-4 bg-white/50 border border-white/35 rounded-xl max-md:px-3">
<div class="flex items-center justify-between flex-wrap gap-2 mb-3">
<div class="flex items-center gap-2.5">
<svg class="text-accent opacity-80" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
<span class="text-[15px] font-semibold text-apptext">后端 API</span>
</div>
<div class="flex items-center gap-1.5">
<span class="inline-block w-2 h-2 rounded-full flex-shrink-0" :class="dotClass(be?.status, loading)" :title="labelText(be?.status, loading)" />
<span class="text-[13px] font-medium" :class="textClass(be?.status)">{{ labelText(be?.status, loading) }}</span>
</div>
</div>
<div class="border-t border-white/35 mb-3" />
<template v-if="be">
<div class="grid gap-2 text-[13px]">
<div class="flex justify-between gap-2"><span class="text-muted">应用环境</span><span class="font-medium text-apptext text-right">{{ be.appEnv === 'production' ? '生产' : '开发' }}</span></div>
<div class="flex justify-between gap-2"><span class="text-muted">Gin 模式</span><span class="font-medium text-apptext text-right">{{ be.ginMode || '—' }}</span></div>
<div class="flex justify-between gap-2"><span class="text-muted">当前请求 Host</span><span class="font-medium text-apptext text-right break-all">{{ be.requestHost || '—' }}</span></div>
<div class="flex justify-between gap-2"><span class="text-muted">进程监听</span><span class="font-medium text-apptext text-right break-all">{{ be.listenAddr || '—' }}</span></div>
<div class="flex justify-between gap-2"><span class="text-muted">对外基址</span><span class="font-medium text-apptext text-right break-all">{{ be.publicBaseUrl || '—' }}</span></div>
<div class="flex justify-between gap-2"><span class="text-muted">运行时长</span><span class="font-medium text-apptext text-right">{{ formatUptime(be.uptimeSeconds) }}</span></div>
<div class="flex justify-between gap-2"><span class="text-muted">认证 API</span><span class="font-medium text-apptext text-right">{{ be.authApiConfigured ? '已配置' : '未配置' }}</span></div>
<div class="flex justify-between gap-2"><span class="text-muted">RabbitMQ 功能</span><span class="font-medium text-apptext text-right">{{ be.rabbitmqEnabled ? '开' : '关' }}</span></div>
<div class="flex justify-between gap-2"><span class="text-muted">Redis 功能</span><span class="font-medium text-apptext text-right">{{ be.redisEnabled ? '开' : '关' }}</span></div>
</div>
</template>
</div>
<!-- MySQL -->
<div class="px-[18px] py-4 bg-white/50 border border-white/35 rounded-xl max-md:px-3">
<div class="flex items-center justify-between flex-wrap gap-2 mb-3">
<div class="flex items-center gap-2.5">
<svg class="text-accent opacity-80" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<ellipse cx="12" cy="5" rx="9" ry="3"/>
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/>
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
</svg>
<span class="text-[15px] font-semibold text-apptext">MySQL</span>
</div>
<div class="flex items-center gap-1.5">
<span class="inline-block w-2 h-2 rounded-full flex-shrink-0" :class="dotClass(mysql?.status, loading)" :title="labelText(mysql?.status, loading)" />
<span class="text-[13px] font-medium" :class="textClass(mysql?.status)">{{ labelText(mysql?.status, loading) }}</span>
</div>
</div>
<div class="border-t border-white/35 mb-3" />
<template v-if="mysql">
<div class="grid gap-2 text-[13px]">
<div v-if="mysql.host" class="flex justify-between gap-2"><span class="text-muted">地址</span><span class="font-medium text-apptext text-right break-all">{{ mysql.host }}{{ mysql.port ? ':' + mysql.port : '' }}</span></div>
<div v-if="mysql.socket" class="flex justify-between gap-2"><span class="text-muted">Socket</span><span class="font-medium text-apptext text-right break-all">{{ mysql.socket }}</span></div>
<div v-if="mysql.database" class="flex justify-between gap-2"><span class="text-muted">库名</span><span class="font-medium text-apptext text-right break-all">{{ mysql.database }}</span></div>
<div v-if="mysql.user" class="flex justify-between gap-2"><span class="text-muted">用户</span><span class="font-medium text-apptext text-right">{{ mysql.user }}</span></div>
<div v-if="mysql.status === 'ok' && mysql.version" class="flex justify-between gap-2"><span class="text-muted">版本</span><span class="font-medium text-apptext text-right break-all">{{ mysql.version }}</span></div>
<div v-if="mysql.status === 'ok'" class="flex justify-between gap-2"><span class="text-muted">连接池</span><span class="font-medium text-apptext text-right"> {{ mysql.openConnections }} / {{ mysql.inUse }} / {{ mysql.idle }}</span></div>
</div>
<p v-if="mysql.message" class="mt-3 text-[12px] text-[#c95a6a] leading-relaxed break-all">{{ mysql.message }}</p>
<p v-if="mysql.dsnParseError" class="mt-2 text-[12px] text-[#b8860b] break-all">DSN 解析{{ mysql.dsnParseError }}</p>
</template>
</div>
<!-- Redis -->
<div class="px-[18px] py-4 bg-white/50 border border-white/35 rounded-xl max-md:px-3">
<div class="flex items-center justify-between flex-wrap gap-2 mb-3">
<div class="flex items-center gap-2.5">
<svg class="text-accent opacity-80" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2v20"/>
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
</svg>
<span class="text-[15px] font-semibold text-apptext">Redis</span>
</div>
<div class="flex items-center gap-1.5">
<span class="inline-block w-2 h-2 rounded-full flex-shrink-0" :class="dotClass(rd?.status, loading)" :title="labelText(rd?.status, loading)" />
<span class="text-[13px] font-medium" :class="textClass(rd?.status)">{{ labelText(rd?.status, loading) }}</span>
</div>
</div>
<div class="border-t border-white/35 mb-3" />
<template v-if="rd">
<div class="grid gap-2 text-[13px]">
<div class="flex justify-between gap-2"><span class="text-muted">功能开关</span><span class="font-medium text-apptext text-right">{{ rd.enabled ? '已启用' : '未启用' }}</span></div>
<div v-if="rd.enabled" class="flex justify-between gap-2"><span class="text-muted">逻辑环境</span><span class="font-medium text-apptext text-right">{{ rd.env === 'prod' ? '生产 (prod)' : '开发 (dev)' }}</span></div>
<div v-if="rd.host" class="flex justify-between gap-2"><span class="text-muted">地址</span><span class="font-medium text-apptext text-right break-all">{{ rd.host }}:{{ rd.port || '6379' }}</span></div>
<div v-if="rd.enabled" class="flex justify-between gap-2"><span class="text-muted">DB 编号</span><span class="font-medium text-apptext text-right">{{ rd.dbIndex ?? '' }}</span></div>
<div v-if="rd.status === 'ok' && rd.redisVersion" class="flex justify-between gap-2"><span class="text-muted">版本</span><span class="font-medium text-apptext text-right">{{ rd.redisVersion }}</span></div>
<div v-if="rd.status === 'ok' && rd.usedMemoryHuman" class="flex justify-between gap-2"><span class="text-muted">占用内存</span><span class="font-medium text-apptext text-right">{{ rd.usedMemoryHuman }}</span></div>
<div v-if="rd.status === 'ok'" class="flex justify-between gap-2"><span class="text-muted">Key 约数</span><span class="font-medium text-apptext text-right">{{ rd.keysApprox ?? '' }}</span></div>
</div>
<p v-if="rd.message" class="mt-3 text-[12px] text-[#c95a6a] leading-relaxed break-all">{{ rd.message }}</p>
</template>
</div>
<!-- RabbitMQ -->
<div class="px-[18px] py-4 bg-white/50 border border-white/35 rounded-xl max-md:px-3">
<div class="flex items-center justify-between flex-wrap gap-2 mb-3">
<div class="flex items-center gap-2.5">
<svg class="text-accent opacity-80" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 12h16"/>
<path d="M4 8h12"/>
<path d="M4 16h8"/>
<circle cx="18" cy="8" r="2"/>
<circle cx="14" cy="16" r="2"/>
</svg>
<span class="text-[15px] font-semibold text-apptext">RabbitMQ</span>
</div>
<div class="flex items-center gap-1.5">
<span class="inline-block w-2 h-2 rounded-full flex-shrink-0" :class="dotClass(rb?.status, loading)" :title="labelText(rb?.status, loading)" />
<span class="text-[13px] font-medium" :class="textClass(rb?.status)">{{ labelText(rb?.status, loading) }}</span>
</div>
</div>
<div class="border-t border-white/35 mb-3" />
<template v-if="rb">
<div class="grid gap-2 text-[13px]">
<div class="flex justify-between gap-2"><span class="text-muted">功能开关</span><span class="font-medium text-apptext text-right">{{ rb.enabled ? '已启用' : '未启用' }}</span></div>
<div v-if="rb.enabled" class="flex justify-between gap-2"><span class="text-muted">逻辑环境</span><span class="font-medium text-apptext text-right">{{ rb.env === 'prod' ? '生产 (prod)' : '开发 (dev)' }}</span></div>
<div v-if="rb.brokerHost" class="flex justify-between gap-2"><span class="text-muted">Broker 地址</span><span class="font-medium text-apptext text-right break-all">{{ rb.brokerHost }}:{{ rb.brokerPort }}</span></div>
<div v-if="rb.vhost != null && rb.vhost !== ''" class="flex justify-between gap-2"><span class="text-muted">vhost</span><span class="font-medium text-apptext text-right break-all">{{ rb.vhost }}</span></div>
<div v-if="rb.brokerUser" class="flex justify-between gap-2"><span class="text-muted">连接用户</span><span class="font-medium text-apptext text-right">{{ rb.brokerUser }}</span></div>
<div class="flex justify-between gap-2"><span class="text-muted">交换机</span><span class="font-medium text-apptext text-right break-all">{{ rb.exchange || '—' }}</span></div>
<div class="flex justify-between gap-2"><span class="text-muted">队列</span><span class="font-medium text-apptext text-right break-all">{{ rb.queue || '—' }}</span></div>
<div class="flex justify-between gap-2"><span class="text-muted">路由键</span><span class="font-medium text-apptext text-right break-all">{{ rb.routingKey || '—' }}</span></div>
<div v-if="rb.status === 'ok'" class="flex justify-between gap-2"><span class="text-muted">待投递消息数</span><span class="font-medium text-apptext text-right">{{ rb.messagesReady ?? '' }}</span></div>
<div v-if="rb.status === 'ok'" class="flex justify-between gap-2"><span class="text-muted">消费者数</span><span class="font-medium text-apptext text-right">{{ rb.consumers ?? '' }}</span></div>
</div>
<p v-if="rb.message" class="mt-3 text-[12px] text-[#c95a6a] leading-relaxed break-all">{{ rb.message }}</p>
</template>
</div>
</div>
<p v-if="updatedAt" class="mt-3 text-[12px] text-muted text-right">最后检测{{ updatedAt }}</p>
</div>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { fetchSystemStatus } from '../../shared/api'
const props = defineProps({
adminToken: { type: String, default: '' }
})
const data = ref(null)
const loading = ref(false)
const error = ref('')
const updatedAt = ref('')
const be = computed(() => data.value?.backend || null)
const mysql = computed(() => data.value?.mysql || null)
const rd = computed(() => data.value?.redis || null)
const rb = computed(() => data.value?.rabbitmq || null)
function formatUptime(sec) {
if (sec == null || sec < 0) return '—'
const s = Math.floor(sec)
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const r = s % 60
if (h > 0) return `${h} 小时 ${m}`
if (m > 0) return `${m}${r}`
return `${r}`
}
function labelText(status, isLoading) {
if (status == null && isLoading) return '检测中…'
if (status == null) return '未加载'
if (status === 'ok') return '正常'
if (status === 'disabled') return '未启用'
if (status === 'misconfigured') return '配置不全'
if (status === 'degraded') return '部分可用'
if (status === 'error') return '异常'
return '未知'
}
function dotClass(status, isLoading) {
if (status === 'ok') return 'bg-[#3a9a68]'
if (status === 'disabled') return 'bg-[#c0bcc4]'
if (status === 'degraded' || status === 'misconfigured') return 'bg-[#c9a227]'
if (status == null && isLoading) return 'bg-[#b49acb] animate-pulse'
return 'bg-[#c95a6a]'
}
function textClass(status) {
if (status === 'ok') return 'text-[#3a9a68]'
if (status === 'disabled') return 'text-muted'
if (status === 'degraded' || status === 'misconfigured') return 'text-[#b8860b]'
return 'text-[#c95a6a]'
}
const load = async () => {
if (!props.adminToken) {
error.value = '请先输入管理 Token'
return
}
loading.value = true
error.value = ''
try {
data.value = await fetchSystemStatus(props.adminToken)
updatedAt.value = new Date().toLocaleTimeString('zh-CN')
} catch (e) {
error.value = e?.response?.status === 401
? 'Token 无效,无权查看'
: '获取失败:' + (e?.message || '网络错误')
} finally {
loading.value = false
}
}
onMounted(() => {
if (props.adminToken) load()
})
watch(
() => props.adminToken,
(v) => {
if (v) load()
}
)
defineExpose({ load })
</script>

View File

@@ -1,202 +1,208 @@
import axios from 'axios'
const apiBaseURL =
import.meta.env.VITE_API_BASE_URL ||
import.meta.env.VITE_API_BASE ||
(typeof window !== 'undefined' ? window.location.origin : 'http://localhost:8080')
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'
})
export const fetchProducts = async () => {
const { data } = await api.get('/api/products')
return data.data || []
}
export const recordProductView = async (id) => {
const { data } = await api.post(`/api/products/${id}/view`)
return data.data || {}
}
export const createOrder = async (payload, authToken) => {
const headers = authToken ? { Authorization: `Bearer ${authToken}` } : {}
const { data } = await api.post('/api/checkout', payload, { headers })
return data.data || {}
}
export const confirmOrder = async (orderId) => {
const { data } = await api.post(`/api/orders/${orderId}/confirm`)
return data.data || {}
}
export const fetchStats = async () => {
const { data } = await api.get('/api/stats')
return data.data || {}
}
export const recordSiteVisit = async () => {
const { data } = await api.post('/api/site/visit')
return data.data || {}
}
export const verifySproutGateToken = async (token) => {
const { data } = await authApi.post('/api/auth/verify', { token })
return data
}
export const fetchSproutGateUser = async (token) => {
const { data } = await authApi.get('/api/auth/me', {
headers: { Authorization: `Bearer ${token}` }
})
return data
}
export const fetchMyOrders = async (authToken) => {
const { data } = await api.get('/api/orders', {
headers: { Authorization: `Bearer ${authToken}` }
})
return data.data || []
}
export const verifyAdminToken = async (token) => {
const { data } = await api.post('/api/admin/verify', { token })
return data.valid === true
}
export const fetchAdminProducts = async (token) => {
const { data } = await api.get('/api/admin/products', { headers: adminHeaders(token) })
return data.data || []
}
export const createProduct = async (token, payload) => {
const { data } = await api.post('/api/admin/products', payload, {
headers: adminHeaders(token)
})
return data.data
}
export const updateProduct = async (token, id, payload) => {
const { data } = await api.put(`/api/admin/products/${id}`, payload, {
headers: adminHeaders(token)
})
return data.data
}
export const toggleProduct = async (token, id, active) => {
const { data } = await api.patch(`/api/admin/products/${id}/status`, { active }, {
headers: adminHeaders(token)
})
return data.data
}
export const deleteProduct = async (token, id) => {
const { data } = await api.delete(`/api/admin/products/${id}`, {
headers: adminHeaders(token)
})
return data
}
export const fetchWishlist = async (token) => {
const { data } = await api.get('/api/wishlist', {
headers: { Authorization: `Bearer ${token}` }
})
return data.data?.items || []
}
export const addToWishlist = async (token, productId) => {
const { data } = await api.post('/api/wishlist', { productId }, {
headers: { Authorization: `Bearer ${token}` }
})
return data.data?.items || []
}
export const removeFromWishlist = async (token, productId) => {
const { data } = await api.delete(`/api/wishlist/${productId}`, {
headers: { Authorization: `Bearer ${token}` }
})
return data.data?.items || []
}
export const fetchAdminOrders = async (token) => {
const { data } = await api.get('/api/admin/orders', { headers: adminHeaders(token) })
return data.data || []
}
export const deleteAdminOrder = async (token, orderId) => {
await api.delete(`/api/admin/orders/${orderId}`, { headers: adminHeaders(token) })
}
export const fetchSiteMaintenance = async () => {
const { data } = await api.get('/api/site/maintenance')
return data.data || { maintenance: false, reason: '' }
}
export const setSiteMaintenance = async (token, maintenance, reason) => {
const { data } = await api.post('/api/admin/site/maintenance', { maintenance, reason }, {
headers: adminHeaders(token)
})
return data.data || {}
}
// ---- SMTP Config ----
export const fetchSMTPConfig = async (token) => {
const { data } = await api.get('/api/admin/site/smtp', { headers: adminHeaders(token) })
return data.data || {}
}
export const setSMTPConfig = async (token, cfg) => {
const { data } = await api.post('/api/admin/site/smtp', cfg, { headers: adminHeaders(token) })
return data.data
}
// ---- Chat (user) ----
export const fetchMyChatMessages = async (userToken) => {
const { data } = await api.get('/api/chat/messages', {
headers: { Authorization: `Bearer ${userToken}` }
})
return data.data?.messages || []
}
export const sendChatMessage = async (userToken, content) => {
const { data } = await api.post('/api/chat/messages', { content }, {
headers: { Authorization: `Bearer ${userToken}` }
})
return data.data?.message || null
}
// ---- Chat (admin) ----
export const fetchAdminAllConversations = async (adminToken) => {
const { data } = await api.get('/api/admin/chat', { headers: adminHeaders(adminToken) })
return data.data?.conversations || {}
}
export const fetchAdminConversation = async (adminToken, account) => {
const { data } = await api.get(`/api/admin/chat/${encodeURIComponent(account)}`, {
headers: adminHeaders(adminToken)
})
return data.data?.messages || []
}
export const adminSendChatReply = async (adminToken, account, content) => {
const { data } = await api.post(
`/api/admin/chat/${encodeURIComponent(account)}`,
{ content },
{ headers: adminHeaders(adminToken) }
)
return data.data?.message || null
}
export const adminClearConversation = async (adminToken, account) => {
await api.delete(`/api/admin/chat/${encodeURIComponent(account)}`, {
headers: adminHeaders(adminToken)
})
}
import axios from 'axios'
const apiBaseURL =
import.meta.env.VITE_API_BASE_URL ||
import.meta.env.VITE_API_BASE ||
(typeof window !== 'undefined' ? window.location.origin : 'http://localhost:8080')
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'
})
export const fetchProducts = async () => {
const { data } = await api.get('/api/products')
return data.data || []
}
export const recordProductView = async (id) => {
const { data } = await api.post(`/api/products/${id}/view`)
return data.data || {}
}
export const createOrder = async (payload, authToken) => {
const headers = authToken ? { Authorization: `Bearer ${authToken}` } : {}
const { data } = await api.post('/api/checkout', payload, { headers })
return data.data || {}
}
export const confirmOrder = async (orderId) => {
const { data } = await api.post(`/api/orders/${orderId}/confirm`)
return data.data || {}
}
export const fetchStats = async () => {
const { data } = await api.get('/api/stats')
return data.data || {}
}
export const recordSiteVisit = async () => {
const { data } = await api.post('/api/site/visit')
return data.data || {}
}
export const verifySproutGateToken = async (token) => {
const { data } = await authApi.post('/api/auth/verify', { token })
return data
}
export const fetchSproutGateUser = async (token) => {
const { data } = await authApi.get('/api/auth/me', {
headers: { Authorization: `Bearer ${token}` }
})
return data
}
export const fetchMyOrders = async (authToken) => {
const { data } = await api.get('/api/orders', {
headers: { Authorization: `Bearer ${authToken}` }
})
return data.data || []
}
export const verifyAdminToken = async (token) => {
const { data } = await api.post('/api/admin/verify', { token })
return data.valid === true
}
export const fetchAdminProducts = async (token) => {
const { data } = await api.get('/api/admin/products', { headers: adminHeaders(token) })
return data.data || []
}
export const createProduct = async (token, payload) => {
const { data } = await api.post('/api/admin/products', payload, {
headers: adminHeaders(token)
})
return data.data
}
export const updateProduct = async (token, id, payload) => {
const { data } = await api.put(`/api/admin/products/${id}`, payload, {
headers: adminHeaders(token)
})
return data.data
}
export const toggleProduct = async (token, id, active) => {
const { data } = await api.patch(`/api/admin/products/${id}/status`, { active }, {
headers: adminHeaders(token)
})
return data.data
}
export const deleteProduct = async (token, id) => {
const { data } = await api.delete(`/api/admin/products/${id}`, {
headers: adminHeaders(token)
})
return data
}
export const fetchWishlist = async (token) => {
const { data } = await api.get('/api/wishlist', {
headers: { Authorization: `Bearer ${token}` }
})
return data.data?.items || []
}
export const addToWishlist = async (token, productId) => {
const { data } = await api.post('/api/wishlist', { productId }, {
headers: { Authorization: `Bearer ${token}` }
})
return data.data?.items || []
}
export const removeFromWishlist = async (token, productId) => {
const { data } = await api.delete(`/api/wishlist/${productId}`, {
headers: { Authorization: `Bearer ${token}` }
})
return data.data?.items || []
}
export const fetchAdminOrders = async (token) => {
const { data } = await api.get('/api/admin/orders', { headers: adminHeaders(token) })
return data.data || []
}
export const deleteAdminOrder = async (token, orderId) => {
await api.delete(`/api/admin/orders/${orderId}`, { headers: adminHeaders(token) })
}
export const fetchSiteMaintenance = async () => {
const { data } = await api.get('/api/site/maintenance')
return data.data || { maintenance: false, reason: '' }
}
export const setSiteMaintenance = async (token, maintenance, reason) => {
const { data } = await api.post('/api/admin/site/maintenance', { maintenance, reason }, {
headers: adminHeaders(token)
})
return data.data || {}
}
// ---- SMTP Config ----
export const fetchSMTPConfig = async (token) => {
const { data } = await api.get('/api/admin/site/smtp', { headers: adminHeaders(token) })
return data.data || {}
}
export const setSMTPConfig = async (token, cfg) => {
const { data } = await api.post('/api/admin/site/smtp', cfg, { headers: adminHeaders(token) })
return data.data
}
// ---- Chat (user) ----
export const fetchMyChatMessages = async (userToken) => {
const { data } = await api.get('/api/chat/messages', {
headers: { Authorization: `Bearer ${userToken}` }
})
return data.data?.messages || []
}
export const sendChatMessage = async (userToken, content) => {
const { data } = await api.post('/api/chat/messages', { content }, {
headers: { Authorization: `Bearer ${userToken}` }
})
return data.data?.message || null
}
// ---- Chat (admin) ----
export const fetchAdminAllConversations = async (adminToken) => {
const { data } = await api.get('/api/admin/chat', { headers: adminHeaders(adminToken) })
return data.data?.conversations || {}
}
export const fetchAdminConversation = async (adminToken, account) => {
const { data } = await api.get(`/api/admin/chat/${encodeURIComponent(account)}`, {
headers: adminHeaders(adminToken)
})
return data.data?.messages || []
}
export const adminSendChatReply = async (adminToken, account, content) => {
const { data } = await api.post(
`/api/admin/chat/${encodeURIComponent(account)}`,
{ content },
{ headers: adminHeaders(adminToken) }
)
return data.data?.message || null
}
export const adminClearConversation = async (adminToken, account) => {
await api.delete(`/api/admin/chat/${encodeURIComponent(account)}`, {
headers: adminHeaders(adminToken)
})
}
// ---- 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 || {}
}

View File

@@ -0,0 +1,18 @@
/** 与后端 fulfillmentType 一致card=卡密库存fixed=固定内容(不限库存) */
export function isFixedFulfillment(item) {
if (!item) return false
return String(item.fulfillmentType || 'card').toLowerCase() === 'fixed'
}
export function stockLabel(item) {
if (!item) return '—'
return isFixedFulfillment(item) ? '不限' : String(item.quantity ?? 0)
}
/** 是否视为售罄(不可下单) */
export function isSoldOutProduct(item) {
if (!item || !item.active) return true
if (isFixedFulfillment(item)) return false
return (item.quantity ?? 0) === 0
}

View File

@@ -1,26 +1,22 @@
<template>
<section class="page-card" v-if="!loading && product">
<!-- Header -->
<div class="flex items-center justify-between gap-4 mb-5">
<div>
<h2>立即下单 · {{ product.name }}</h2>
<p class="tag">一个扫码流程即可完成订单</p>
</div>
<button class="ghost" @click="goBack">返回商品</button>
<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>
</div>
<!-- Grid: summary + form -->
<div class="grid gap-6 mb-5 max-md:grid-cols-1" style="grid-template-columns: minmax(280px, 1fr) 320px;">
<!-- 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 gap-[18px] bg-white/80 rounded-[var(--radius-default)] border border-white/35 p-[18px] max-md:flex-col max-md:items-center">
<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-[140px] h-[140px] object-cover rounded-lg max-md:w-full max-md:h-[200px]"
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 class="flex-1 flex flex-col gap-2">
<div class="flex-1 flex flex-col gap-2 min-w-0 w-full">
<h3>{{ product.name }}</h3>
<p class="tag">库存{{ product.quantity }}</p>
<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>
@@ -30,7 +26,7 @@
</span>
<span v-else>¥ {{ product.price.toFixed(2) }}</span>
</p>
<p class="markdown" v-html="renderMarkdown(product.description)"></p>
<p class="markdown break-words overflow-x-auto max-w-full" v-html="renderMarkdown(product.description)"></p>
</div>
</div>
@@ -41,13 +37,13 @@
</div>
<!-- Order form -->
<form v-else-if="!orderResult" class="flex flex-col gap-3" @submit.prevent="submitOrder">
<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="product.maxPerAccount > 0 ? product.maxPerAccount : undefined"
:max="maxOrderQty"
type="number"
/>
</div>
@@ -76,7 +72,7 @@
<a class="text-accent2 no-underline font-semibold hover:underline" :href="loginUrl">登录萌芽账号</a> 后购买可享受历史订单记录专属优惠商品及更多购买权益
</p>
<button class="primary" type="submit" :disabled="submitting">
<button class="primary w-full" type="submit" :disabled="submitting">
{{ submitting ? '生成中...' : '生成二维码下单' }}
</button>
<p class="text-[#d64848] text-[15px]" v-if="error">{{ error }}</p>
@@ -84,17 +80,103 @@
</div>
<!-- Order result -->
<section v-if="orderResult" class="mt-4 text-center">
<h4 class="text-lg font-bold mb-2">订单 {{ orderResult.orderId }} 已创建</h4>
<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>
<template v-if="!confirmed">
<p class="tag mb-2">请扫描下方二维码完成付款</p>
<img :src="orderResult.qrCodeUrl" alt="下单二维码" class="max-w-[320px] mx-auto w-full rounded-lg border border-white/35 my-3" />
<div class="flex flex-col items-center gap-2.5 my-4">
<button class="primary" :disabled="confirming" @click="doConfirm">
{{ confirming ? '确认中...' : '我已完成扫码付款' }}
<!-- 付费真实收款码模拟资产见 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">扫码完成后点击上方按钮获取购买内容</p>
<p class="tag text-center text-[13px] sm:text-[15px]">{{ needPayChannel ? '完成支付后点击上方按钮获取购买内容' : '点击确认以查看购买内容' }}</p>
</div>
<p class="text-[#d64848] text-[15px]" v-if="confirmError">{{ confirmError }}</p>
</template>
@@ -136,11 +218,15 @@
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
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 { 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'
const route = useRoute()
const router = useRouter()
@@ -154,6 +240,46 @@ const confirmed = ref(false)
const confirming = 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 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 loggedIn = computed(() => isLoggedIn())
const loginUrl = computed(() => getLoginUrl())
@@ -178,6 +304,33 @@ const totalPrice = computed(() => {
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 currentPayQrAlt = computed(() => {
const ch = payChannels.find((c) => c.id === payChannel.value)
return ch ? `${ch.label}收款码` : '收款码'
})
const goBack = () => router.push('/')
const submitOrder = async () => {
@@ -187,6 +340,11 @@ const submitOrder = async () => {
orderResult.value = null
confirmed.value = false
deliveredCodes.value = []
paidAmountSnapshot.value = totalPrice.value
payChannel.value = 'alipay'
showOrderTokenQr.value = false
orderIdCopyHint.value = ''
clearTimeout(orderIdCopyTimer)
try {
const payload = {
productId: product.value.id,
@@ -222,6 +380,10 @@ const doConfirm = async () => {
}
}
onUnmounted(() => {
clearTimeout(orderIdCopyTimer)
})
onMounted(async () => {
try {
const list = await fetchProducts()

View File

@@ -4,7 +4,7 @@
<!-- Summary -->
<div class="flex flex-col gap-2">
<h2>{{ product.name }}</h2>
<p class="tag">库存{{ product.quantity }}</p>
<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>
@@ -100,6 +100,7 @@ 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()

View File

@@ -50,6 +50,7 @@
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { fetchProducts } from '../shared/api'
import { isSoldOutProduct } from '../shared/fulfillment'
import ProductCard from './components/ProductCard.vue'
const router = useRouter()
@@ -84,7 +85,7 @@ const getPayPrice = (item) => {
}
const isFree = (item) => getPayPrice(item) === 0
const isSoldOut = (item) => item && item.quantity === 0
const isSoldOut = (item) => isSoldOutProduct(item)
const matchesSearch = (item) => {
const q = (searchQuery.value || '').trim().toLowerCase()

View File

@@ -62,7 +62,7 @@
>{{ tag }}</span>
</div>
<div class="flex gap-2.5 items-center flex-wrap">
<div class="text-sm text-muted max-md:text-[11px]">库存{{ item.quantity }}</div>
<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>
@@ -74,6 +74,7 @@
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()
@@ -102,6 +103,6 @@ const payPrice = computed(() => {
})
const isFree = computed(() => payPrice.value === 0)
const isSoldOut = computed(() => props.item && props.item.quantity === 0)
const isSoldOut = computed(() => isSoldOutProduct(props.item))
const renderedDescription = computed(() => md.render(props.item.description || ''))
</script>

View File

@@ -19,7 +19,7 @@
v-for="item in wishlistItems"
:key="item.id"
:item="item"
:class="{ 'opacity-50': !item.active || item.quantity === 0 }"
:class="{ 'opacity-50': isUnavailable(item) }"
@click="handleClick"
/>
</div>
@@ -30,6 +30,7 @@
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { fetchProducts } from '../shared/api'
import { isSoldOutProduct } from '../shared/fulfillment'
import { getWishlistProducts, loadWishlist } from '../shared/useWishlist'
import ProductCard from '../store/components/ProductCard.vue'
@@ -37,18 +38,19 @@ const router = useRouter()
const allProducts = ref([])
const loading = ref(true)
const isUnavailable = (item) => !item.active || isSoldOutProduct(item)
const wishlistItems = computed(() => {
const items = getWishlistProducts(allProducts.value)
return items.sort((a, b) => {
const aOk = a.active && a.quantity > 0 ? 0 : 1
const bOk = b.active && b.quantity > 0 ? 0 : 1
const aOk = a.active && !isSoldOutProduct(a) ? 0 : 1
const bOk = b.active && !isSoldOutProduct(b) ? 0 : 1
return aOk - bOk
})
})
const handleClick = (item) => {
if (!item) return
if (!item.active || item.quantity === 0) return
if (!item || isUnavailable(item)) return
router.push(`/product/${item.id}`)
}

View File

@@ -0,0 +1,143 @@
# 萌芽小店 · 前端mengyastore-frontend
基于 **Vue 3 + Vite** + **Tailwind CSSVite 插件)** 的数字商品前端商店、结算含扫码收款演示、管理后台、收藏、订单、聊天、PWA。
## 技术依赖
| 包 | 用途 |
|----|------|
| vue ^3.x | 框架 |
| vite ^5.x | 构建 |
| @tailwindcss/vite | 工具类样式 |
| vue-router ^4.x | 路由 |
| pinia ^2.x | 状态(与 reactive 并存) |
| axios ^1.6 | HTTP |
| markdown-it ^14 | 商品描述 |
| vite-plugin-pwa | PWA |
## 目录结构
```
src/
├── assets/
│ ├── styles.css # 全局样式、.ghost / .page-card / . form-field 等
│ └── payment/ # 支付宝/微信收款码 PNGimport 进 CheckoutPage
├── router/index.js # 路由;维护模式 beforeEach
├── modules/
│ ├── shared/
│ │ ├── api.js # API 封装(含 fetchSystemStatus
│ │ ├── auth.js # 登录态、getLoginUrl
│ │ ├── fulfillment.js # isFixedFulfillment、stockLabel、isSoldOutProduct
│ │ └── useWishlist.js
│ ├── store/
│ │ ├── StorePage.vue
│ │ ├── ProductDetail.vue
│ │ ├── CheckoutPage.vue # 响应式布局、支付渠道 Tab、订单号复制、收款图
│ │ └── components/ProductCard.vue
│ ├── admin/
│ │ ├── AdminPage.vue # 侧边栏:商品/订单/聊天/设置/系统状态
│ │ └── components/
│ │ ├── AdminProductModal.vue # 发货方式:卡密 | 固定内容
│ │ ├── AdminProductTable.vue
│ │ ├── AdminOrderTable.vue
│ │ ├── AdminChatPanel.vue
│ │ ├── AdminMaintenanceRow.vue
│ │ ├── AdminSMTPRow.vue
│ │ └── AdminSystemStatusPanel.vue
│ ├── user/MyOrdersPage.vue
│ ├── wishlist/WishlistPage.vue
│ ├── maintenance/MaintenancePage.vue
│ ├── auth/AuthCallback.vue
│ └── chat/ChatWidget.vue
└── App.vue # 顶栏:桌面横排导航;移动端汉堡菜单 + 下拉面板
```
## 路由
| 路径 | 组件 | 说明 |
|------|------|------|
| `/` | StorePage | 商品列表 |
| `/product/:id` | ProductDetail | 详情 |
| `/checkout/:id` | CheckoutPage | 结算(路径参数为商品 id |
| `/my/orders` | MyOrdersPage | 我的订单(建议登录) |
| `/wishlist` | WishlistPage | 收藏 |
| `/admin` | AdminPage | 管理后台token |
| `/auth/callback` | AuthCallback | OAuth 回调 |
| `/maintenance` | MaintenancePage | 维护中 |
### 路由守卫
- 请求 `GET /api/site/maintenance`;维护中时跳转 `/maintenance`
- 豁免:`/maintenance``/wishlist``/admin``/auth/callback`(与 `router/index.js` 保持一致)。
## 认证
1. 「萌芽账号登录」→ SproutGate 登录页,`callback` 回指本站。
2. Token 存 `localStorage``authState` 共享用户信息。
3. 需登录接口带 `Authorization: Bearer <token>`
## 功能要点
### 商品与库存展示
- API 字段 **`fulfillmentType`**`card` | `fixed`
- **`fixed`**:列表/详情/卡片显示库存为 **「不限」**,不按 `quantity===0` 售罄。
- 工具函数:`modules/shared/fulfillment.js`
### 管理后台 · 商品
- **卡密**:多行输入,逐条对应 `codes`
- **固定内容**:大文本 `fixedContent`;保存后无需维护条数。
### 结算页CheckoutPage
- 栅格与表单单列适配窄屏。
- 下单成功后:应付金额、支付宝/微信/赞赏 **本地图片** 切换、`订单号` **一键复制**、可选展开服务端 `qrCodeUrl`
- 数量上限:卡密=min(库存, 每账号限购);固定内容=仅每账号限购(若设置)。
### 顶栏App.vue
- `md` 及以上:横向按钮。
- 以下:汉堡按钮 + 全宽大标题项 + 遮罩Escape 关闭;打开时锁 body 滚动。
### 系统状态
- `AdminSystemStatusPanel` 调用 `GET /api/admin/system-status`,展示后端/API、MySQL、Redis、RabbitMQ需管理 Token
### PWA
- `vite-plugin-pwa``registerType: 'autoUpdate'`,构建后生成 SW根组件有更新 Toast 与 `SplashScreen`
## 开发命令
```bash
npm install
npm run dev # :5173
npm run build # dist/
npm run preview
```
### 环境变量
开发示例(`.env.development`
```env
VITE_API_BASE_URL=http://localhost:8080
```
生产部署设置实际 API 基地址(或同源反代可不设)。
## 部署(静态资源)
```nginx
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://127.0.0.1:8080;
}
```
---
字体与色板以 `src/assets/styles.css` 及 Tailwind 主题为准;文档若与代码不一致,以仓库当前实现为准。