feat: sync project

This commit is contained in:
2026-03-20 20:58:24 +08:00
parent 04bb11dfff
commit 9a6ebe80c5
32 changed files with 3613 additions and 156 deletions

View File

@@ -4,6 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>萌芽小店</title>
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<link rel="shortcut icon" href="/favicon.ico" />
</head>
<body>
<div id="app"></div>

View File

@@ -1,45 +1,174 @@
<template>
<div class="app">
<header class="top-bar">
<div class="brand">
<div class="brand-mark"></div>
<div class="brand" @click="onLogoClick">
<div class="brand-mark">
<img src="/logo.png" alt="萌芽小店" draggable="false" />
</div>
<div>
<h1>萌芽小店</h1>
</div>
</div>
<div class="top-actions">
<button class="ghost" @click="goHome">商店</button>
<button class="primary" @click="goAdmin">管理员后台</button>
<template v-if="loggedIn">
<button class="ghost" @click="goMyOrders">我的订单</button>
<div class="user-badge" @click="goProfile">
<img
v-if="authState.avatarUrl"
class="user-avatar"
:src="authState.avatarUrl"
:alt="authState.username"
/>
<div v-else class="user-avatar user-avatar-placeholder">
{{ (authState.username || authState.account || '?').charAt(0) }}
</div>
<span class="user-name">{{ authState.username || authState.account }}</span>
</div>
<button class="ghost" @click="logout">退出</button>
</template>
<a v-else class="login-btn" :href="loginUrl">萌芽账号登录</a>
</div>
</header>
<Teleport to="body">
<Transition name="modal">
<div v-if="showAdminModal" class="admin-modal-overlay" @click.self="showAdminModal = false">
<div class="admin-modal">
<button class="admin-modal-close" @click="showAdminModal = false">&times;</button>
<div class="admin-modal-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
</div>
<h3>管理员验证</h3>
<p class="admin-modal-hint">请输入管理员令牌以访问后台</p>
<form @submit.prevent="submitAdminToken">
<input
ref="tokenInputRef"
v-model="tokenInput"
type="password"
class="admin-modal-input"
placeholder="输入 Token…"
autocomplete="off"
/>
<p v-if="tokenError" class="admin-modal-error">{{ tokenError }}</p>
<button type="submit" class="primary admin-modal-btn" :disabled="!tokenInput.trim()">
进入后台
</button>
</form>
</div>
</div>
</Transition>
</Teleport>
<div class="app-body">
<main class="main-content">
<router-view />
</main>
<footer class="footer">
<span>萌芽小店 · @2025-2026</span>
<a class="footer-mail" href="mailto:mail@smyhub.com">邮箱售后联系: mail@smyhub.com</a>
<div class="footer-inner">
<div class="footer-brand">
<img src="/logo.png" alt="萌芽小店" class="footer-logo" />
<span class="footer-title">萌芽小店</span>
</div>
<div class="footer-divider"></div>
<div class="footer-links">
<a class="footer-mail" href="mailto:mail@smyhub.com">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>
mail@smyhub.com
</a>
<span v-if="statsLoaded" class="footer-stat">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20V10"/><path d="M18 20V4"/><path d="M6 20v-4"/></svg>
累计下单 {{ stats.totalOrders }}
</span>
<span v-if="statsLoaded" class="footer-stat footer-stat-visit">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>
累计访问 {{ stats.totalVisits }}
</span>
</div>
<p class="footer-copy">© 2025 2026 萌芽小店 · All rights reserved</p>
</div>
</footer>
</div>
</div>
</template>
<script setup>
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { fetchAdminToken } from './modules/shared/api'
import { fetchAdminToken, fetchStats, recordSiteVisit } from './modules/shared/api'
import { authState, isLoggedIn, clearAuth, getLoginUrl } from './modules/shared/auth'
const router = useRouter()
const stats = reactive({ totalOrders: 0, totalVisits: 0 })
const statsLoaded = ref(false)
const goHome = () => {
router.push('/')
const loggedIn = computed(() => isLoggedIn())
const loginUrl = computed(() => getLoginUrl())
const showAdminModal = ref(false)
const tokenInput = ref('')
const tokenError = ref('')
const tokenInputRef = ref(null)
let logoClickCount = 0
let logoClickTimer = null
const CLICK_THRESHOLD = 5
const CLICK_WINDOW_MS = 2000
const onLogoClick = () => {
logoClickCount++
clearTimeout(logoClickTimer)
if (logoClickCount >= CLICK_THRESHOLD) {
logoClickCount = 0
tokenInput.value = ''
tokenError.value = ''
showAdminModal.value = true
nextTick(() => tokenInputRef.value?.focus())
return
}
logoClickTimer = setTimeout(() => { logoClickCount = 0 }, CLICK_WINDOW_MS)
}
const goAdmin = async () => {
const submitAdminToken = async () => {
const input = tokenInput.value.trim()
if (!input) return
tokenError.value = ''
try {
const token = await fetchAdminToken()
router.push(`/admin?token=${encodeURIComponent(token)}`)
} catch (error) {
router.push('/admin')
const correctToken = await fetchAdminToken()
if (input === correctToken) {
showAdminModal.value = false
router.push(`/admin?token=${encodeURIComponent(correctToken)}`)
} else {
tokenError.value = '令牌错误,请重试'
}
} catch {
tokenError.value = '验证失败,无法连接服务器'
}
}
const goHome = () => { router.push('/') }
const goMyOrders = () => { router.push('/my/orders') }
const goProfile = () => {
if (authState.account) {
window.open(`https://auth.shumengya.top/user/${authState.account}`, '_blank')
}
}
const logout = () => { clearAuth() }
onMounted(async () => {
try {
recordSiteVisit().then((result) => {
if (result.totalVisits) stats.totalVisits = result.totalVisits
}).catch(() => {})
const data = await fetchStats()
stats.totalOrders = data.totalOrders || 0
stats.totalVisits = data.totalVisits || 0
statsLoaded.value = true
} catch (e) {
statsLoaded.value = false
}
})
</script>

View File

@@ -45,7 +45,6 @@ p {
display: flex;
flex-direction: column;
min-height: 100vh;
padding: 28px 5vw 36px;
}
.top-bar {
@@ -53,21 +52,160 @@ p {
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 20px 26px;
background: var(--glass);
border-radius: var(--radius);
border: 1px solid var(--line);
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
padding: 16px 5vw;
background: var(--glass-strong);
border-bottom: 1px solid var(--line);
box-shadow: 0 4px 24px rgba(33, 33, 40, 0.08);
backdrop-filter: blur(20px);
position: sticky;
top: 20px;
z-index: 10;
top: 0;
z-index: 100;
}
.app-body {
flex: 1;
display: flex;
flex-direction: column;
padding: 28px 5vw 36px;
}
.brand {
display: flex;
gap: 16px;
align-items: center;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
}
.admin-modal-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(44, 43, 45, 0.35);
backdrop-filter: blur(8px);
}
.admin-modal {
position: relative;
width: 360px;
max-width: 90vw;
padding: 36px 32px 28px;
background: var(--glass-strong);
border: 1px solid var(--line);
border-radius: 20px;
box-shadow: 0 24px 60px rgba(33, 33, 40, 0.22);
backdrop-filter: blur(20px);
text-align: center;
}
.admin-modal-close {
position: absolute;
top: 12px;
right: 16px;
background: none;
border: none;
font-size: 22px;
color: var(--muted);
cursor: pointer;
padding: 4px 8px;
border-radius: 8px;
line-height: 1;
transition: color 0.2s ease;
}
.admin-modal-close:hover {
color: var(--text);
transform: none;
}
.admin-modal-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 16px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
color: white;
margin-bottom: 16px;
}
.admin-modal h3 {
font-size: 20px;
margin-bottom: 6px;
}
.admin-modal-hint {
font-size: 13px;
color: var(--muted);
margin-bottom: 20px;
}
.admin-modal-input {
width: 100%;
padding: 12px 16px;
border-radius: 12px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.65);
font-family: 'Source Sans 3', sans-serif;
font-size: 15px;
text-align: center;
letter-spacing: 2px;
outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.admin-modal-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(180, 154, 203, 0.18);
}
.admin-modal-error {
font-size: 13px;
color: #d4566a;
margin-top: 8px;
}
.admin-modal-btn {
width: 100%;
margin-top: 16px;
padding: 12px;
font-size: 15px;
}
.admin-modal-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.25s ease;
}
.modal-enter-active .admin-modal,
.modal-leave-active .admin-modal {
transition: transform 0.25s ease, opacity 0.25s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-from .admin-modal {
transform: scale(0.92) translateY(10px);
opacity: 0;
}
.modal-leave-to .admin-modal {
transform: scale(0.95);
opacity: 0;
}
.brand h1 {
@@ -84,13 +222,87 @@ p {
width: 46px;
height: 46px;
border-radius: 16px;
background: linear-gradient(140deg, var(--accent), var(--accent-2));
box-shadow: inset 0 0 18px rgba(255, 255, 255, 0.6);
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.6);
background: var(--glass-strong);
box-shadow: 0 12px 24px rgba(33, 33, 40, 0.18);
display: flex;
align-items: center;
justify-content: center;
}
.brand-mark img {
width: 100%;
height: 100%;
object-fit: contain;
}
.top-actions {
display: flex;
gap: 12px;
align-items: center;
}
.user-badge {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px 6px 6px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.6);
border: 1px solid var(--line);
cursor: pointer;
transition: background 0.2s ease;
}
.user-badge:hover {
background: rgba(255, 255, 255, 0.85);
}
.user-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
border: 1px solid var(--line);
}
.user-avatar-placeholder {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
color: white;
font-size: 13px;
font-weight: 600;
}
.user-name {
font-size: 13px;
color: var(--text);
font-weight: 500;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.login-btn {
display: inline-flex;
align-items: center;
padding: 10px 18px;
border-radius: 12px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
color: white;
text-decoration: none;
font-family: 'Source Sans 3', sans-serif;
font-size: 14px;
box-shadow: 0 10px 30px rgba(145, 168, 208, 0.35);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.login-btn:hover {
transform: translateY(-1px);
}
button {
@@ -121,23 +333,106 @@ button.ghost {
.main-content {
flex: 1;
padding: 28px 0;
min-height: 0;
}
.footer {
margin-top: 12px;
padding: 24px 26px;
background: var(--glass);
border: 1px solid var(--line);
border-radius: var(--radius);
backdrop-filter: blur(16px);
box-shadow: 0 -4px 30px rgba(33, 33, 40, 0.06);
}
.footer-inner {
display: flex;
justify-content: space-between;
color: var(--muted);
font-size: 13px;
flex-direction: column;
align-items: center;
gap: 14px;
}
.footer-brand {
display: flex;
align-items: center;
gap: 10px;
}
.footer-logo {
width: 28px;
height: 28px;
border-radius: 8px;
object-fit: contain;
border: 1px solid rgba(255, 255, 255, 0.5);
box-shadow: 0 4px 12px rgba(33, 33, 40, 0.1);
}
.footer-title {
font-family: 'Playfair Display', serif;
font-size: 17px;
font-weight: 600;
color: var(--text);
letter-spacing: 0.5px;
}
.footer-divider {
width: 40px;
height: 2px;
border-radius: 2px;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
opacity: 0.5;
}
.footer-links {
display: flex;
align-items: center;
gap: 24px;
flex-wrap: wrap;
justify-content: center;
}
.footer-mail {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--accent-2);
text-decoration: none;
font-weight: 600;
font-size: 13px;
font-weight: 500;
padding: 5px 12px;
border-radius: 8px;
background: rgba(145, 168, 208, 0.08);
transition: background 0.2s ease, color 0.2s ease;
}
.footer-mail:hover {
text-decoration: underline;
background: rgba(145, 168, 208, 0.18);
color: var(--text);
}
.footer-stat {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
color: var(--accent);
padding: 5px 12px;
border-radius: 8px;
background: rgba(180, 154, 203, 0.08);
}
.footer-stat-visit {
color: var(--accent-2);
background: rgba(145, 168, 208, 0.08);
}
.footer-copy {
font-size: 12px;
color: var(--muted);
opacity: 0.7;
letter-spacing: 0.3px;
}
.page-card {
@@ -186,6 +481,26 @@ button.ghost {
color: var(--accent-2);
}
.product-actions {
margin-top: 12px;
}
.secondary-link {
display: inline-flex;
justify-content: center;
padding: 8px 14px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.6);
background: rgba(255, 255, 255, 0.6);
color: var(--text);
font-size: 13px;
transition: background 0.2s ease;
}
.secondary-link:hover {
background: rgba(255, 255, 255, 0.85);
}
.badge {
padding: 4px 10px;
border-radius: 10px;
@@ -284,14 +599,15 @@ button.ghost {
}
@media (max-width: 900px) {
.app {
.app-body {
padding: 18px 4vw 28px;
}
.top-bar {
flex-direction: column;
align-items: flex-start;
padding: 16px 18px;
padding: 14px 4vw;
gap: 12px;
}
.grid {
@@ -312,8 +628,12 @@ button.ghost {
}
.footer {
padding: 20px 18px;
}
.footer-links {
flex-direction: column;
gap: 8px;
gap: 10px;
}
.page-card {

View File

@@ -3,56 +3,94 @@
<div class="hero">
<div>
<h2>管理员后台</h2>
<p class="tag">默认地址/admin?token=shumengya520</p>
<p class="tag"> {{ products.length }} 件商品</p>
</div>
<div class="admin-actions">
<button class="primary" @click="openCreate">添加新商品</button>
<button class="ghost" @click="refresh">刷新列表</button>
<button class="primary" @click="autoGetToken">自动获取 Token</button>
<div class="hero-actions">
<button class="ghost" @click="refresh">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="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>
刷新
</button>
<button class="primary" @click="openCreate">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
添加商品
</button>
</div>
</div>
<div class="token-row">
<div class="token-row" v-if="!token || message">
<div class="form-field token-field">
<label>管理 Token</label>
<input v-model="token" placeholder="请输入 token" />
<div class="token-input-wrap">
<input v-model="token" placeholder="粘贴 token 后自动加载…" />
<button class="ghost small" @click="autoGetToken">自动获取</button>
</div>
</div>
<p class="tag" v-if="message">{{ message }}</p>
<p class="msg-tag" :class="{ error: message.includes('失败') || message.includes('错误') }" v-if="message">{{ message }}</p>
</div>
<p v-if="message && token" class="msg-inline" :class="{ error: message.includes('失败') || message.includes('错误') }">{{ message }}</p>
<table class="table">
<thead>
<tr>
<th>商品</th>
<th>价格</th>
<th>库存</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in products" :key="item.id">
<td>
<div>{{ item.name }}</div>
<div class="tag">{{ item.id }}</div>
</td>
<td>¥ {{ item.price.toFixed(2) }}</td>
<td>{{ item.quantity }}</td>
<td>
<span class="badge">{{ item.active ? '上架' : '下架' }}</span>
</td>
<td>
<div class="admin-actions">
<button class="ghost" @click="openEdit(item)">编辑</button>
<button class="ghost" @click="toggle(item)">
{{ item.active ? '下架' : '上架' }}
</button>
<button class="ghost" @click="remove(item)">删除</button>
</div>
</td>
</tr>
</tbody>
</table>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>商品</th>
<th>价格</th>
<th class="col-num">库存</th>
<th class="col-num">浏览量</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in products" :key="item.id" :class="{ 'row-inactive': !item.active }">
<td>
<div class="admin-product-cell">
<img class="admin-product-thumb" :src="item.coverUrl" :alt="item.name" />
<div class="admin-product-info">
<span class="product-name">{{ item.name }}</span>
<span class="product-id">{{ item.id }}</span>
</div>
</div>
</td>
<td>
<div v-if="item.price === 0" class="price-cell">
<span class="price-free">免费</span>
</div>
<div
v-else-if="item.discountPrice > 0 && item.discountPrice < item.price"
class="price-cell"
>
<span class="price-original">¥{{ item.price.toFixed(2) }}</span>
<span class="price-discount">¥{{ item.discountPrice.toFixed(2) }}</span>
</div>
<span v-else class="price-normal">¥{{ item.price.toFixed(2) }}</span>
</td>
<td class="col-num">
<span :class="['stock-badge', item.quantity === 0 ? 'stock-empty' : 'stock-ok']">
{{ item.quantity }}
</span>
</td>
<td class="col-num">
<span class="view-count">{{ item.viewCount || 0 }}</span>
</td>
<td>
<span :class="['status-badge', item.active ? 'status-on' : 'status-off']">
{{ item.active ? '上架' : '下架' }}
</span>
</td>
<td>
<div class="row-actions">
<button class="act-edit" @click="openEdit(item)">编辑</button>
<button class="act-toggle" @click="toggle(item)">
{{ item.active ? '下架' : '上架' }}
</button>
<button class="act-delete" @click="remove(item)">删除</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<div v-if="editorOpen" class="modal-mask" @click.self="closeEditor">
@@ -60,7 +98,7 @@
<div class="modal-header">
<div>
<h3>{{ form.id ? '编辑商品' : '添加新商品' }}</h3>
<p class="tag">封面图和最多 10 张商品截图共用这一套编辑表单</p>
<p class="tag">封面图和最多 5 张商品截图共用这一套编辑表单</p>
</div>
<button class="ghost" @click="closeEditor">关闭</button>
</div>
@@ -72,12 +110,13 @@
</div>
<div class="form-row">
<div class="form-field">
<label></label>
<label></label>
<input v-model.number="form.price" type="number" step="0.01" />
</div>
<div class="form-field">
<label>库存数量</label>
<input v-model.number="form.quantity" type="number" />
<label>折扣价可选</label>
<input v-model.number="form.discountPrice" type="number" step="0.01" />
<p class="tag">留空或不小于原价时将不启用折扣</p>
</div>
</div>
<div class="form-field">
@@ -85,7 +124,32 @@
<input v-model="form.coverUrl" placeholder="http://..." />
</div>
<div class="form-field">
<label>商品截图链接最多 10 </label>
<div class="field-head">
<label>商品库存</label>
<span class="tag"> {{ form.inventoryItems.length }} </span>
<button class="ghost small" type="button" @click="addInventoryItem">添加商品库存</button>
</div>
<p class="tag">每个输入框保存一条可发放内容购买后会直接展示给用户</p>
<div class="inventory-list">
<div v-for="(item, index) in form.inventoryItems" :key="index" class="inventory-row">
<input
:ref="(el) => setInventoryInputRef(el, index)"
v-model="form.inventoryItems[index]"
placeholder="库存内容,可填卡密、下载链接等"
/>
<button
v-if="form.inventoryItems.length > 1"
class="ghost small"
type="button"
@click="removeInventoryItem(index)"
>
删除
</button>
</div>
</div>
</div>
<div class="form-field">
<label>商品截图链接最多 5 </label>
<div class="screenshot-grid">
<input
v-for="(_, index) in screenshotInputSlots"
@@ -99,6 +163,11 @@
<label>商品介绍Markdown</label>
<textarea v-model="form.description"></textarea>
</div>
<div class="form-field">
<label>商品标签英文逗号分隔</label>
<input v-model="form.tagsText" placeholder="例如: chatgpt, linux, 域名" />
<p class="tag">用于前端搜索与筛选多个标签用英文逗号分开</p>
</div>
<div class="form-field">
<label>是否上架</label>
<select v-model="form.active">
@@ -117,7 +186,7 @@
</template>
<script setup>
import { onMounted, reactive, ref, watch } from 'vue'
import { nextTick, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
fetchAdminProducts,
@@ -134,12 +203,28 @@ const token = ref(route.query.token || '')
const products = ref([])
const message = ref('')
const editorOpen = ref(false)
const MAX_SCREENSHOT_URLS = 10
const inventoryInputRefs = ref([])
const MAX_SCREENSHOT_URLS = 5
const screenshotInputSlots = Array.from({ length: MAX_SCREENSHOT_URLS })
const MAX_INVENTORY_ITEMS = 500
const createScreenshotSlots = (values = []) =>
Array.from({ length: MAX_SCREENSHOT_URLS }, (_, index) => values[index] || '')
const createInventoryItems = (values = []) => {
const items = values
.map((value) => (value || '').trim())
.filter((value) => value)
.slice(0, MAX_INVENTORY_ITEMS)
return items.length ? items : ['']
}
const normalizeInventoryItems = (values = []) =>
values
.map((item) => (item || '').trim())
.filter((item) => item)
.slice(0, MAX_INVENTORY_ITEMS)
const normalizeScreenshotUrls = (values = []) =>
values.map((item) => item.trim()).filter(Boolean).slice(0, MAX_SCREENSHOT_URLS)
@@ -147,9 +232,11 @@ const emptyForm = () => ({
id: '',
name: '',
price: 0,
quantity: 0,
discountPrice: 0,
tagsText: '',
coverUrl: '',
screenshotUrls: createScreenshotSlots(),
inventoryItems: createInventoryItems(),
description: '',
active: true
})
@@ -192,8 +279,10 @@ const submit = async () => {
await updateProduct(token.value, form.id, {
name: form.name,
price: form.price,
quantity: form.quantity,
discountPrice: form.discountPrice || 0,
tags: form.tagsText,
coverUrl: form.coverUrl,
codes: normalizeInventoryItems(form.inventoryItems),
screenshotUrls: normalizeScreenshotUrls(form.screenshotUrls),
description: form.description,
active: form.active
@@ -203,8 +292,10 @@ const submit = async () => {
await createProduct(token.value, {
name: form.name,
price: form.price,
quantity: form.quantity,
discountPrice: form.discountPrice || 0,
tags: form.tagsText,
coverUrl: form.coverUrl,
codes: normalizeInventoryItems(form.inventoryItems),
screenshotUrls: normalizeScreenshotUrls(form.screenshotUrls),
description: form.description,
active: form.active
@@ -224,9 +315,11 @@ const fillForm = (item) => {
id: item?.id || '',
name: item?.name || '',
price: item?.price || 0,
quantity: item?.quantity || 0,
discountPrice: item?.discountPrice || 0,
tagsText: (item?.tags || []).join(','),
coverUrl: item?.coverUrl || '',
screenshotUrls: createScreenshotSlots(item?.screenshotUrls || []),
inventoryItems: createInventoryItems(item?.codes || []),
description: item?.description || '',
active: item?.active ?? true
})
@@ -246,6 +339,41 @@ const closeEditor = () => {
editorOpen.value = false
}
const setInventoryInputRef = (el, index) => {
if (!el) {
return
}
inventoryInputRefs.value[index] = el
}
const addInventoryItem = async () => {
form.inventoryItems.push('')
await nextTick()
const lastIndex = form.inventoryItems.length - 1
const input = inventoryInputRefs.value[lastIndex]
if (input?.focus) {
input.focus()
}
if (input?.scrollIntoView) {
input.scrollIntoView({ block: 'center', behavior: 'smooth' })
}
}
const removeInventoryItem = async (index) => {
if (form.inventoryItems.length <= 1) {
form.inventoryItems[0] = ''
return
}
form.inventoryItems.splice(index, 1)
inventoryInputRefs.value.splice(index, 1)
await nextTick()
const targetIndex = Math.min(index, form.inventoryItems.length - 1)
const input = inventoryInputRefs.value[targetIndex]
if (input?.focus) {
input.focus()
}
}
const toggle = async (item) => {
if (!token.value) {
message.value = '请先输入 token'
@@ -280,31 +408,298 @@ onMounted(async () => {
</script>
<style scoped>
/* ── Header ── */
.hero {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 22px;
gap: 16px;
margin-bottom: 20px;
}
.hero-actions {
display: flex;
gap: 10px;
align-items: center;
}
.hero-actions button {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 14px;
}
/* ── Token row ── */
.token-row {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
gap: 14px;
margin-bottom: 16px;
padding: 16px 18px;
background: rgba(255, 255, 255, 0.5);
border: 1px solid var(--line);
border-radius: 12px;
}
.token-field {
width: min(420px, 100%);
flex: 1;
max-width: 460px;
margin-bottom: 0;
}
.token-input-wrap {
display: flex;
gap: 8px;
align-items: center;
}
.token-input-wrap input {
flex: 1;
}
.msg-tag {
font-size: 13px;
color: var(--accent-2);
padding: 4px 10px;
border-radius: 8px;
background: rgba(145, 168, 208, 0.1);
white-space: nowrap;
}
.msg-inline {
font-size: 13px;
color: var(--accent-2);
margin-bottom: 12px;
}
.msg-tag.error,
.msg-inline.error {
color: #c95a6a;
background: rgba(201, 90, 106, 0.08);
}
/* ── Table ── */
.table-wrap {
border-radius: 14px;
overflow: hidden;
border: 1px solid var(--line);
}
.table {
width: 100%;
border-collapse: collapse;
background: rgba(255, 255, 255, 0.45);
}
.table thead tr {
background: rgba(255, 255, 255, 0.7);
border-bottom: 2px solid var(--line);
}
.table th {
padding: 12px 16px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--muted);
}
.table td {
padding: 14px 16px;
font-size: 14px;
vertical-align: middle;
}
.table tbody tr {
border-bottom: 1px solid var(--line);
transition: background 0.15s ease;
}
.table tbody tr:last-child {
border-bottom: none;
}
.table tbody tr:hover {
background: rgba(255, 255, 255, 0.7);
}
.table tbody tr.row-inactive {
opacity: 0.55;
}
.col-num {
text-align: center;
}
/* ── Product cell ── */
.admin-product-cell {
display: flex;
align-items: center;
gap: 12px;
}
.admin-product-thumb {
width: 48px;
height: 48px;
border-radius: 10px;
object-fit: cover;
border: 1px solid var(--line);
flex-shrink: 0;
box-shadow: 0 4px 10px rgba(33, 33, 40, 0.1);
}
.admin-product-info {
display: flex;
flex-direction: column;
gap: 3px;
}
.product-name {
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.product-id {
font-size: 11px;
color: var(--muted);
font-family: monospace;
}
/* ── Price ── */
.price-cell {
display: flex;
flex-direction: column;
gap: 2px;
}
.price-original {
text-decoration: line-through;
color: var(--muted);
font-size: 12px;
}
.price-discount {
font-weight: 700;
color: #e8826a;
font-size: 15px;
}
.price-free {
font-weight: 900;
color: #3a9a68;
font-size: 15px;
}
.price-normal {
font-weight: 600;
color: var(--text);
}
/* ── Stock badge ── */
.stock-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
}
.stock-ok {
background: rgba(100, 185, 140, 0.15);
color: #3a9a68;
}
.stock-empty {
background: rgba(201, 90, 106, 0.12);
color: #c95a6a;
}
/* ── View count ── */
.view-count {
color: var(--muted);
font-size: 13px;
}
/* ── Status badge ── */
.status-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.3px;
}
.status-on {
background: rgba(100, 185, 140, 0.15);
color: #3a9a68;
}
.status-off {
background: rgba(140, 140, 145, 0.12);
color: var(--muted);
}
/* ── Row action buttons ── */
.row-actions {
display: flex;
gap: 6px;
align-items: center;
}
.act-edit,
.act-toggle,
.act-delete {
padding: 6px 12px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
border: none;
cursor: pointer;
transition: opacity 0.15s ease, transform 0.15s ease;
font-family: 'Source Sans 3', sans-serif;
}
.act-edit {
background: rgba(145, 168, 208, 0.15);
color: var(--accent-2);
}
.act-edit:hover {
background: rgba(145, 168, 208, 0.28);
}
.act-toggle {
background: rgba(180, 154, 203, 0.12);
color: var(--accent);
}
.act-toggle:hover {
background: rgba(180, 154, 203, 0.24);
}
.act-delete {
background: rgba(201, 90, 106, 0.1);
color: #c95a6a;
}
.act-delete:hover {
background: rgba(201, 90, 106, 0.2);
}
/* ── Form / Modal ── */
select {
border-radius: 14px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.7);
padding: 10px 12px;
font-family: 'Source Sans 3', sans-serif;
font-size: 14px;
}
.form-row {
@@ -313,12 +708,41 @@ select {
gap: 12px;
}
.field-head {
display: flex;
align-items: center;
gap: 12px;
}
.small {
padding: 7px 13px;
font-size: 13px;
border-radius: 999px;
}
.inventory-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.inventory-row {
display: flex;
align-items: center;
gap: 8px;
}
.inventory-row input {
flex: 1;
}
.screenshot-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
/* ── Modal ── */
.modal-mask {
position: fixed;
inset: 0;
@@ -335,11 +759,11 @@ select {
width: min(920px, 100%);
max-height: calc(100vh - 48px);
overflow: auto;
padding: 24px;
padding: 28px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.92);
background: rgba(255, 255, 255, 0.95);
border: 1px solid var(--line);
box-shadow: 0 24px 60px rgba(33, 33, 40, 0.18);
box-shadow: 0 24px 60px rgba(33, 33, 40, 0.2);
}
.modal-header {
@@ -347,7 +771,9 @@ select {
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
margin-bottom: 22px;
padding-bottom: 16px;
border-bottom: 1px solid var(--line);
}
.modal-body {
@@ -360,22 +786,42 @@ select {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 16px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--line);
}
/* ── Responsive ── */
@media (max-width: 900px) {
.hero,
.token-row,
.modal-header {
flex-direction: column;
align-items: stretch;
}
.token-row {
flex-direction: column;
align-items: stretch;
}
.token-input-wrap {
flex-direction: column;
}
.form-row,
.screenshot-grid {
grid-template-columns: 1fr;
}
.field-head {
flex-wrap: wrap;
}
.inventory-row {
flex-direction: column;
align-items: stretch;
}
.modal-mask {
padding: 12px;
}
@@ -384,5 +830,10 @@ select {
padding: 18px;
max-height: calc(100vh - 24px);
}
.row-actions {
flex-direction: column;
gap: 4px;
}
}
</style>

View File

@@ -0,0 +1,109 @@
<template>
<section class="page-card">
<div class="auth-callback">
<div v-if="status === 'loading'" class="auth-status">
<h2>正在验证登录...</h2>
<p class="tag">请稍候正在与萌芽认证中心确认身份</p>
</div>
<div v-else-if="status === 'success'" class="auth-status">
<h2>登录成功</h2>
<p class="tag">欢迎回来{{ displayName }}正在跳转...</p>
</div>
<div v-else class="auth-status">
<h2>登录失败</h2>
<p class="tag">{{ errorMessage }}</p>
<button class="primary" @click="goHome">返回商店</button>
</div>
</div>
</section>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { setAuth } from '../shared/auth'
import { verifySproutGateToken, fetchSproutGateUser } from '../shared/api'
const router = useRouter()
const status = ref('loading')
const displayName = ref('')
const errorMessage = ref('')
const goHome = () => router.push('/')
const parseFragment = () => {
const hash = window.location.hash.slice(1)
const params = new URLSearchParams(hash)
return {
token: params.get('token') || '',
account: params.get('account') || '',
username: params.get('username') || ''
}
}
onMounted(async () => {
const { token, account, username } = parseFragment()
if (!token) {
status.value = 'error'
errorMessage.value = '未获取到登录令牌,请重试'
return
}
try {
const verifyData = await verifySproutGateToken(token)
if (!verifyData.valid) {
status.value = 'error'
errorMessage.value = '令牌验证失败,请重新登录'
return
}
let avatarUrl = verifyData.user?.avatarUrl || ''
let finalAccount = verifyData.user?.account || account
let finalUsername = verifyData.user?.username || username
if (!avatarUrl) {
try {
const meData = await fetchSproutGateUser(token)
avatarUrl = meData.user?.avatarUrl || ''
finalAccount = meData.user?.account || finalAccount
finalUsername = meData.user?.username || finalUsername
} catch {
// /me failed, proceed with what we have
}
}
setAuth({
token,
account: finalAccount,
username: finalUsername,
avatarUrl
})
displayName.value = finalUsername || finalAccount
status.value = 'success'
setTimeout(() => router.push('/'), 1000)
} catch {
setAuth({ token, account, username, avatarUrl: '' })
displayName.value = username || account
status.value = 'success'
setTimeout(() => router.push('/'), 1000)
}
})
</script>
<style scoped>
.auth-callback {
display: flex;
justify-content: center;
padding: 48px 0;
}
.auth-status {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
</style>

View File

@@ -1,7 +1,13 @@
import axios from 'axios'
const apiBaseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080'
const api = axios.create({
baseURL: 'http://localhost:8080'
baseURL: apiBaseURL
})
const authApi = axios.create({
baseURL: 'https://auth.api.shumengya.top'
})
export const fetchProducts = async () => {
@@ -9,6 +15,51 @@ export const fetchProducts = async () => {
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 fetchAdminToken = async () => {
const { data } = await api.get('/api/admin/token')
return data.token || ''

View File

@@ -0,0 +1,54 @@
import { reactive } from 'vue'
const AUTH_KEY = 'mengyastore_auth'
const loadAuth = () => {
try {
const raw = localStorage.getItem(AUTH_KEY)
if (!raw) return null
return JSON.parse(raw)
} catch {
return null
}
}
const saved = loadAuth()
export const authState = reactive({
token: saved?.token || '',
account: saved?.account || '',
username: saved?.username || '',
avatarUrl: saved?.avatarUrl || ''
})
export const isLoggedIn = () => !!authState.token
export const setAuth = (info) => {
authState.token = info.token || ''
authState.account = info.account || ''
authState.username = info.username || ''
authState.avatarUrl = info.avatarUrl || ''
localStorage.setItem(
AUTH_KEY,
JSON.stringify({
token: authState.token,
account: authState.account,
username: authState.username,
avatarUrl: authState.avatarUrl
})
)
}
export const clearAuth = () => {
authState.token = ''
authState.account = ''
authState.username = ''
authState.avatarUrl = ''
localStorage.removeItem(AUTH_KEY)
}
export const getLoginUrl = () => {
const redirectUri = `${window.location.origin}/auth/callback`
const state = Math.random().toString(36).slice(2)
return `https://auth.shumengya.top/?redirect_uri=${encodeURIComponent(redirectUri)}&state=${state}`
}

View File

@@ -0,0 +1,325 @@
<template>
<section class="page-card" v-if="!loading && product">
<div class="checkout-header">
<div>
<h2>立即下单 · {{ product.name }}</h2>
<p class="tag">一个扫码流程即可完成订单</p>
</div>
<button class="ghost" @click="goBack">返回商品</button>
</div>
<div class="checkout-grid">
<div class="checkout-summary">
<img :src="product.coverUrl" :alt="product.name" />
<div class="summary-content">
<h3>{{ product.name }}</h3>
<p class="tag">库存{{ product.quantity }}</p>
<p class="tag">浏览量{{ product.viewCount || 0 }}</p>
<p class="product-price">
<span v-if="isFree" class="free-price">免费</span>
<span
v-else-if="product.discountPrice > 0 && product.discountPrice < product.price"
>
<span class="price-original">¥ {{ product.price.toFixed(2) }}</span>
<span class="price-discount">¥ {{ product.discountPrice.toFixed(2) }}</span>
</span>
<span v-else>¥ {{ product.price.toFixed(2) }}</span>
</p>
<p class="markdown" v-html="renderMarkdown(product.description)"></p>
</div>
</div>
<form class="checkout-form" @submit.prevent="submitOrder" v-if="!orderResult">
<div class="form-field">
<label>下单数量</label>
<input v-model.number="form.quantity" min="1" type="number" />
</div>
<p class="tag">预计总价¥ {{ totalPrice.toFixed(2) }}</p>
<p class="tag login-hint" v-if="!loggedIn">
提示<a :href="loginUrl">登录萌芽账号</a> 后下单可查看历史订单记录
</p>
<button class="primary" type="submit" :disabled="submitting">
{{ submitting ? '生成中...' : '生成二维码下单' }}
</button>
<p class="error" v-if="error">{{ error }}</p>
</form>
</div>
<section class="order-result" v-if="orderResult">
<h4>订单 {{ orderResult.orderId }} 已创建</h4>
<template v-if="!confirmed">
<p class="tag">请扫描下方二维码完成付款</p>
<img :src="orderResult.qrCodeUrl" alt="下单二维码" />
<div class="confirm-area">
<button class="primary" :disabled="confirming" @click="doConfirm">
{{ confirming ? '确认中...' : '我已完成扫码付款' }}
</button>
<p class="tag">扫码完成后点击上方按钮获取购买内容</p>
</div>
<p class="error" v-if="confirmError">{{ confirmError }}</p>
</template>
<template v-else>
<p class="tag confirmed-badge">订单已完成</p>
<div class="result-content" v-if="deliveredCodes.length">
<p class="tag">购买后内容</p>
<textarea class="delivery-box" readonly :value="deliveredCodes.join('\n')"></textarea>
</div>
<p class="tag">感谢购买如有问题请联系售后邮箱</p>
</template>
</section>
</section>
<section class="page-card" v-else-if="!loading">
<h2>未找到该商品</h2>
<p class="tag">该商品无法下单请返回商店页</p>
<div class="detail-actions">
<button class="ghost" @click="goBack">返回商品</button>
</div>
</section>
<section class="page-card" v-else>
<div class="status">加载中...</div>
</section>
</template>
<script setup>
import { computed, onMounted, 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'
const route = useRoute()
const router = useRouter()
const md = new MarkdownIt()
const product = ref(null)
const loading = ref(true)
const submitting = ref(false)
const error = ref('')
const orderResult = ref(null)
const confirmed = ref(false)
const confirming = ref(false)
const confirmError = ref('')
const deliveredCodes = ref([])
const loggedIn = computed(() => isLoggedIn())
const loginUrl = computed(() => getLoginUrl())
const form = reactive({
quantity: 1
})
const renderMarkdown = (content) => md.render(content || '')
const getPayPrice = (p) => {
if (!p) return 0
if (p.price === 0) return 0
if (p.discountPrice > 0 && p.discountPrice < p.price) return p.discountPrice
return p.price
}
const unitPrice = computed(() => getPayPrice(product.value))
const isFree = computed(() => unitPrice.value === 0)
const totalPrice = computed(() => {
if (!product.value) {
return 0
}
const qty = form.quantity && form.quantity > 0 ? form.quantity : 1
return unitPrice.value * qty
})
const goBack = () => router.push('/')
const submitOrder = async () => {
if (!product.value) {
return
}
error.value = ''
submitting.value = true
orderResult.value = null
confirmed.value = false
deliveredCodes.value = []
try {
const payload = {
productId: product.value.id,
quantity: form.quantity
}
const token = isLoggedIn() ? authState.token : null
const response = await createOrder(payload, token)
orderResult.value = response
} catch (err) {
error.value = err?.response?.data?.error || '下单失败,请稍候再试'
} finally {
submitting.value = false
}
}
const doConfirm = async () => {
if (!orderResult.value?.orderId) {
return
}
confirmError.value = ''
confirming.value = true
try {
const result = await confirmOrder(orderResult.value.orderId)
deliveredCodes.value = result.deliveredCodes || []
confirmed.value = true
} catch (err) {
confirmError.value = err?.response?.data?.error || '确认失败,请重试'
} finally {
confirming.value = false
}
}
onMounted(async () => {
try {
const list = await fetchProducts()
product.value = list.find((item) => item.id === route.params.id) || null
} finally {
loading.value = false
}
})
</script>
<style scoped>
.checkout-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.checkout-grid {
display: grid;
grid-template-columns: minmax(280px, 1fr) 320px;
gap: 24px;
margin-bottom: 20px;
}
.checkout-summary {
background: rgba(255, 255, 255, 0.8);
border-radius: var(--radius);
border: 1px solid var(--line);
padding: 18px;
display: flex;
gap: 18px;
}
.checkout-summary img {
width: 140px;
height: 140px;
object-fit: cover;
border-radius: 12px;
}
.summary-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.checkout-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.login-hint a {
color: var(--accent-2);
text-decoration: none;
font-weight: 600;
}
.login-hint a:hover {
text-decoration: underline;
}
.order-result {
margin-top: 16px;
text-align: center;
}
.order-result img {
max-width: 320px;
margin: 12px auto;
width: 100%;
border-radius: 14px;
border: 1px solid var(--line);
}
.confirm-area {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
margin: 16px 0;
}
.confirmed-badge {
display: inline-block;
padding: 6px 16px;
border-radius: 999px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
color: white;
font-size: 13px;
font-weight: 600;
margin-bottom: 12px;
}
.result-content {
margin: 12px 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.delivery-box {
width: min(520px, 100%);
min-height: 120px;
padding: 14px 16px;
border-radius: 14px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.9);
color: var(--text);
font-size: 14px;
line-height: 1.7;
resize: none;
}
.error {
color: #d64848;
font-size: 13px;
}
.status {
padding: 24px 0;
color: var(--muted);
}
.free-price {
color: #3a9a68;
font-weight: 900;
}
@media (max-width: 900px) {
.checkout-grid {
grid-template-columns: 1fr;
}
.checkout-summary {
flex-direction: column;
align-items: center;
}
.checkout-summary img {
width: 100%;
height: 200px;
}
}
</style>

View File

@@ -4,7 +4,17 @@
<div class="detail-summary">
<h2>{{ product.name }}</h2>
<p class="tag">库存{{ product.quantity }}</p>
<p class="product-price">¥ {{ product.price.toFixed(2) }}</p>
<p class="tag">浏览量{{ product.viewCount || 0 }}</p>
<p class="product-price">
<span v-if="isFree" class="free-price">免费</span>
<span
v-else-if="product.discountPrice > 0 && product.discountPrice < product.price"
>
<span class="price-original">¥ {{ product.price.toFixed(2) }}</span>
<span class="price-discount">¥ {{ product.discountPrice.toFixed(2) }}</span>
</span>
<span v-else>¥ {{ product.price.toFixed(2) }}</span>
</p>
</div>
<div class="markdown detail-description" v-html="renderMarkdown(product.description)"></div>
@@ -57,7 +67,7 @@
</div>
<div class="detail-actions">
<button class="buy-button" @click="buy">立即购买</button>
<button class="buy-button" @click="goCheckout">立即下单</button>
<button class="ghost" @click="goBack">返回商店首页</button>
</div>
</div>
@@ -80,7 +90,7 @@
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import MarkdownIt from 'markdown-it'
import { fetchProducts } from '../shared/api'
import { fetchProducts, recordProductView } from '../shared/api'
const route = useRoute()
const router = useRouter()
@@ -89,6 +99,16 @@ const loading = ref(true)
const product = ref(null)
const currentImageIndex = ref(0)
const getPayPrice = (p) => {
if (!p) return 0
if (p.price === 0) return 0
if (p.discountPrice > 0 && p.discountPrice < p.price) return p.discountPrice
return p.price
}
const unitPrice = computed(() => getPayPrice(product.value))
const isFree = computed(() => unitPrice.value === 0)
const renderMarkdown = (content) => md.render(content || '')
const galleryImages = computed(() => {
@@ -124,8 +144,11 @@ const goBack = () => {
router.push('/')
}
const buy = () => {
alert('已加入购买清单(示例)')
const goCheckout = () => {
if (!product.value) {
return
}
router.push(`/checkout/${product.value.id}`)
}
const prevImage = () => {
@@ -157,6 +180,19 @@ onMounted(async () => {
try {
const list = await fetchProducts()
product.value = list.find((item) => item.id === route.params.id) || null
if (product.value) {
try {
const result = await recordProductView(product.value.id)
if (typeof result.viewCount === 'number') {
product.value = {
...product.value,
viewCount: result.viewCount
}
}
} catch (error) {
console.warn('record product view failed', error)
}
}
} finally {
loading.value = false
}
@@ -269,6 +305,11 @@ onMounted(async () => {
color: var(--muted);
}
.free-price {
color: #3a9a68;
font-weight: 900;
}
@media (max-width: 900px) {
.detail-thumb {
padding: 6px;

View File

@@ -3,28 +3,83 @@
<div class="hero">
<div>
<h2>所有商品</h2>
<div class="filter-search-row">
<div class="filters">
<button
class="filter-btn"
:class="{ active: filter === 'all' }"
type="button"
@click="setFilter('all')"
>
全部
</button>
<button
class="filter-btn"
:class="{ active: filter === 'free' }"
type="button"
@click="setFilter('free')"
>
免费
</button>
</div>
<div class="search-row">
<input
v-model="searchQuery"
class="search-input"
type="text"
placeholder="搜索商品/标签"
/>
</div>
</div>
</div>
</div>
<div v-if="loading" class="status">加载中...</div>
<div v-else>
<div class="grid">
<router-link
<div
v-for="item in pagedProducts"
:key="item.id"
:to="`/product/${item.id}`"
class="product-link"
:class="['product-link', { 'is-disabled': isSoldOut(item) }]"
@click="handleCardClick(item)"
>
<article class="product-card">
<img :src="item.coverUrl" :alt="item.name" />
<div class="product-meta">
<h3>{{ item.name }}</h3>
<span class="product-price">¥ {{ item.price.toFixed(2) }}</span>
<div class="cover-wrap">
<img :src="item.coverUrl" :alt="item.name" />
<div v-if="isSoldOut(item)" class="soldout-badge">已售空</div>
</div>
<div class="card-top">
<h3 class="card-name">{{ item.name }}</h3>
<div class="card-price">
<span v-if="isFree(item)" class="product-price free-price">免费</span>
<span
v-else-if="item.discountPrice > 0 && item.discountPrice < item.price"
class="product-price"
>
<span class="price-original">¥ {{ item.price.toFixed(2) }}</span>
<span class="price-discount">¥ {{ item.discountPrice.toFixed(2) }}</span>
</span>
<span v-else class="product-price">¥ {{ item.price.toFixed(2) }}</span>
</div>
</div>
<div class="card-mid">
<div class="markdown" v-html="renderMarkdown(item.description)"></div>
</div>
<div class="card-bottom">
<div class="meta-row">
<div class="tag">库存{{ item.quantity }}</div>
<div class="tag">浏览量{{ item.viewCount || 0 }}</div>
</div>
<div v-if="item.tags && item.tags.length" class="tag-row">
<span v-for="tag in item.tags" :key="tag" class="tag-chip">
{{ tag }}
</span>
</div>
</div>
<div class="tag">库存{{ item.quantity }}</div>
<div class="markdown" v-html="renderMarkdown(item.description)"></div>
</article>
</router-link>
</div>
</div>
<div class="pagination" v-if="totalPages > 1">
@@ -37,15 +92,20 @@
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import MarkdownIt from 'markdown-it'
import { fetchProducts } from '../shared/api'
const router = useRouter()
const products = ref([])
const loading = ref(true)
const md = new MarkdownIt()
const page = ref(1)
const perPage = ref(20)
const filter = ref('all')
const searchQuery = ref('')
const renderMarkdown = (content) => md.render(content || '')
@@ -54,15 +114,57 @@ const updatePerPage = () => {
page.value = 1
}
const getPayPrice = (item) => {
if (!item) return 0
// 折扣规则:
// - 未填/无效折扣discountPrice <= 0 或 >= price => 不启用折扣
// - 只有当 discountPrice > 0 且小于原价,才用折扣价
// - 只有 price = 0实付价为 0才显示“免费”
if (item.price === 0) return 0
if (item.discountPrice > 0 && item.discountPrice < item.price) return item.discountPrice
return item.price
}
const isFree = (item) => getPayPrice(item) === 0
const matchesSearch = (item) => {
const q = (searchQuery.value || '').trim().toLowerCase()
if (!q) return true
const name = (item?.name || '').toLowerCase()
const tags = (item?.tags || []).join(',').toLowerCase()
return name.includes(q) || tags.includes(q)
}
const filteredProducts = computed(() => {
let list = products.value
if (filter.value === 'free') list = list.filter((p) => isFree(p))
if ((searchQuery.value || '').trim()) list = list.filter((p) => matchesSearch(p))
return list
})
const totalPages = computed(() =>
Math.max(1, Math.ceil(products.value.length / perPage.value))
Math.max(1, Math.ceil(filteredProducts.value.length / perPage.value))
)
const pagedProducts = computed(() => {
const start = (page.value - 1) * perPage.value
return products.value.slice(start, start + perPage.value)
return filteredProducts.value.slice(start, start + perPage.value)
})
const isSoldOut = (item) => item && item.quantity === 0
const handleCardClick = (item) => {
if (!item || isSoldOut(item)) {
return
}
router.push(`/product/${item.id}`)
}
const setFilter = (next) => {
filter.value = next
page.value = 1
}
onMounted(async () => {
updatePerPage()
window.addEventListener('resize', updatePerPage)
@@ -73,6 +175,14 @@ onMounted(async () => {
}
})
watch(filter, () => {
page.value = 1
})
watch(searchQuery, () => {
page.value = 1
})
onUnmounted(() => {
window.removeEventListener('resize', updatePerPage)
})
@@ -86,6 +196,87 @@ onUnmounted(() => {
margin-bottom: 20px;
}
.filters {
display: flex;
gap: 12px;
margin-top: 0;
flex-wrap: wrap;
}
.filter-search-row {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 16px;
flex-wrap: wrap;
margin-top: 12px;
}
.search-row {
margin-top: 0;
}
.search-input {
width: 320px;
max-width: 100%;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.7);
padding: 10px 14px;
font-family: 'Source Sans 3', sans-serif;
font-size: 14px;
outline: none;
}
.search-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(180, 154, 203, 0.18);
}
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.tag-chip {
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
color: var(--accent-2);
background: rgba(145, 168, 208, 0.08);
border: 1px solid rgba(145, 168, 208, 0.18);
}
.filter-btn {
padding: 8px 14px;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.6);
color: var(--text);
font-size: 13px;
font-weight: 700;
transition: background 0.2s ease, color 0.2s ease, transform 0.15s ease;
}
.filter-btn:hover {
transform: translateY(-1px);
}
.filter-btn.active {
background: linear-gradient(135deg, var(--accent), var(--accent-2));
border-color: transparent;
color: #fff;
box-shadow: 0 10px 30px rgba(145, 168, 208, 0.35);
}
.free-price {
color: #3a9a68;
font-weight: 900;
}
.product-link {
display: block;
text-decoration: none;
@@ -93,6 +284,106 @@ onUnmounted(() => {
height: 100%;
}
.product-link.is-disabled {
opacity: 0.6;
cursor: not-allowed;
}
.product-card {
display: flex;
flex-direction: column;
flex: 1;
gap: 12px;
height: 100%;
}
.card-top {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.card-name {
font-size: 16px;
font-weight: 700;
color: var(--text);
line-height: 1.2;
}
.card-price {
display: flex;
align-items: center;
justify-content: flex-end;
}
.card-mid {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.card-mid .markdown {
text-align: center;
}
.card-bottom {
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-start;
}
.meta-row {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.cover-wrap {
position: relative;
border-radius: 12px;
overflow: hidden;
}
.cover-wrap img {
width: 100%;
height: 140px;
object-fit: cover;
display: block;
}
.soldout-badge {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(20, 18, 22, 0.52);
backdrop-filter: blur(3px);
color: #fff;
font-family: 'Playfair Display', serif;
font-size: 22px;
font-weight: 600;
letter-spacing: 4px;
pointer-events: none;
}
.price-original {
text-decoration: line-through;
color: var(--muted);
margin-right: 6px;
font-size: 13px;
}
.price-discount {
color: var(--accent-2);
font-weight: 600;
}
.status {
padding: 24px 0;
color: var(--muted);
@@ -105,4 +396,16 @@ onUnmounted(() => {
gap: 12px;
margin-top: 22px;
}
@media (max-width: 900px) {
.filter-search-row {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.search-input {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,200 @@
<template>
<section class="page-card" v-if="!loggedIn">
<div class="auth-prompt">
<h2>请先登录</h2>
<p class="tag">登录萌芽账号后即可查看你的历史订单记录</p>
<a class="login-btn" :href="loginUrl">使用萌芽账号登录</a>
</div>
</section>
<section class="page-card" v-else>
<div class="orders-header">
<div>
<h2>我的订单</h2>
<p class="tag">
已登录{{ authState.username || authState.account }}
<span v-if="orders.length">· {{ orders.length }} 笔订单</span>
</p>
</div>
<div class="orders-actions">
<button class="ghost" @click="refresh">刷新</button>
<button class="ghost" @click="goHome">返回商店</button>
</div>
</div>
<div v-if="loading" class="status">加载中...</div>
<div v-else-if="orders.length === 0" class="status">暂无订单记录</div>
<div v-else class="orders-list">
<div v-for="order in orders" :key="order.id" class="order-card">
<div class="order-head">
<div>
<h4>{{ order.productName }}</h4>
<span class="tag">订单号{{ order.id }}</span>
</div>
<div class="order-meta">
<span class="badge">数量 {{ order.quantity }}</span>
<span class="tag">{{ formatTime(order.createdAt) }}</span>
</div>
</div>
<div class="order-codes" v-if="order.deliveredCodes?.length">
<p class="tag">购买内容</p>
<textarea
class="codes-box"
readonly
:value="order.deliveredCodes.join('\n')"
:rows="Math.min(order.deliveredCodes.length, 5)"
></textarea>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { fetchMyOrders } from '../shared/api'
import { authState, isLoggedIn, getLoginUrl } from '../shared/auth'
const router = useRouter()
const orders = ref([])
const loading = ref(true)
const loggedIn = computed(() => isLoggedIn())
const loginUrl = computed(() => getLoginUrl())
const goHome = () => router.push('/')
const formatTime = (isoStr) => {
if (!isoStr) return ''
try {
const d = new Date(isoStr)
return d.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
} catch {
return isoStr
}
}
const refresh = async () => {
if (!isLoggedIn()) return
loading.value = true
try {
orders.value = await fetchMyOrders(authState.token)
} catch {
orders.value = []
} finally {
loading.value = false
}
}
onMounted(() => {
if (isLoggedIn()) {
refresh()
} else {
loading.value = false
}
})
</script>
<style scoped>
.auth-prompt {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 48px 0;
text-align: center;
}
.orders-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.orders-actions {
display: flex;
gap: 10px;
}
.orders-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.order-card {
padding: 18px;
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.7);
border: 1px solid var(--line);
display: flex;
flex-direction: column;
gap: 12px;
}
.order-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.order-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
flex-shrink: 0;
}
.order-codes {
display: flex;
flex-direction: column;
gap: 6px;
}
.codes-box {
width: 100%;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.9);
color: var(--text);
font-size: 13px;
line-height: 1.7;
resize: none;
}
.status {
padding: 24px 0;
color: var(--muted);
}
@media (max-width: 900px) {
.orders-header {
flex-direction: column;
align-items: stretch;
}
.order-head {
flex-direction: column;
}
.order-meta {
align-items: flex-start;
flex-direction: row;
gap: 10px;
}
}
</style>

View File

@@ -2,13 +2,19 @@ import { createRouter, createWebHistory } from 'vue-router'
import StorePage from '../modules/store/StorePage.vue'
import AdminPage from '../modules/admin/AdminPage.vue'
import ProductDetail from '../modules/store/ProductDetail.vue'
import CheckoutPage from '../modules/store/CheckoutPage.vue'
import AuthCallback from '../modules/auth/AuthCallback.vue'
import MyOrdersPage from '../modules/user/MyOrdersPage.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', name: 'store', component: StorePage },
{ path: '/product/:id', name: 'product-detail', component: ProductDetail },
{ path: '/admin', name: 'admin', component: AdminPage }
{ path: '/checkout/:id', name: 'checkout', component: CheckoutPage },
{ path: '/admin', name: 'admin', component: AdminPage },
{ path: '/auth/callback', name: 'auth-callback', component: AuthCallback },
{ path: '/my/orders', name: 'my-orders', component: MyOrdersPage }
]
})