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

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