feat: fulfillment modes, MQ, admin status, docs; scrub compose secrets
Made-with: Cursor
This commit is contained in:
@@ -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 / STKaiti),fallback 到系统衬线字体。
|
||||
|
||||
## 构建与部署
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
产物在 `dist/` 目录,为静态文件,部署到 Nginx / CDN 时需配置:
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html; # SPA 路由支持
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://localhost:8080; # 反向代理到后端
|
||||
}
|
||||
```
|
||||
@@ -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>
|
||||
|
||||
BIN
mengyastore-frontend/src/assets/payment/微信个人收款码.png
Normal file
BIN
mengyastore-frontend/src/assets/payment/微信个人收款码.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
BIN
mengyastore-frontend/src/assets/payment/微信赞赏码.png
Normal file
BIN
mengyastore-frontend/src/assets/payment/微信赞赏码.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
BIN
mengyastore-frontend/src/assets/payment/支付宝个人收款码.png
Normal file
BIN
mengyastore-frontend/src/assets/payment/支付宝个人收款码.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 238 KiB |
@@ -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);
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<p class="text-[13px] text-muted">后端 API、MySQL、Redis、RabbitMQ 探活与配置摘要(需管理 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>
|
||||
@@ -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 || {}
|
||||
}
|
||||
|
||||
18
mengyastore-frontend/src/modules/shared/fulfillment.js
Normal file
18
mengyastore-frontend/src/modules/shared/fulfillment.js
Normal 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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
|
||||
143
mengyastore-frontend/前端文档.md
Normal file
143
mengyastore-frontend/前端文档.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# 萌芽小店 · 前端(mengyastore-frontend)
|
||||
|
||||
基于 **Vue 3 + Vite** + **Tailwind CSS(Vite 插件)** 的数字商品前端:商店、结算(含扫码收款演示)、管理后台、收藏、订单、聊天、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/ # 支付宝/微信收款码 PNG(import 进 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 主题为准;文档若与代码不一致,以仓库当前实现为准。
|
||||
Reference in New Issue
Block a user