Initial commit: 商品售卖网站
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<div class="install-page">
|
||||
<div class="install-card">
|
||||
<div class="install-logo">
|
||||
<div class="logo-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>系统安装</h2>
|
||||
<p class="install-desc">创建管理员账号以完成系统初始化</p>
|
||||
</div>
|
||||
<el-form :model="form" :rules="rules" ref="formRef" @submit.prevent="handleInstall">
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input v-model="form.username" placeholder="请输入用户名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input v-model="form.email" type="email" placeholder="请输入邮箱" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input v-model="form.password" type="password" show-password placeholder="请输入密码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码" prop="confirmPassword">
|
||||
<el-input v-model="form.confirmPassword" type="password" show-password placeholder="请再次输入密码" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" native-type="submit" class="submit-btn">
|
||||
完成安装
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { systemApi } from '../api'
|
||||
import { useUserStore } from '../store/user'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const formRef = ref()
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
})
|
||||
|
||||
const validateConfirmPassword = (_rule: any, value: string, callback: any) => {
|
||||
if (value !== form.password) {
|
||||
callback(new Error('两次输入的密码不一致'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
const rules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱', trigger: 'blur' },
|
||||
{ type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' },
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码至少6位', trigger: 'blur' },
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: '请再次输入密码', trigger: 'blur' },
|
||||
{ validator: validateConfirmPassword, trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
async function handleInstall() {
|
||||
await formRef.value?.validate()
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await systemApi.install({
|
||||
username: form.username,
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
})
|
||||
userStore.setUser(res.user, res.token)
|
||||
ElMessage.success('安装成功!')
|
||||
window.location.href = '/admin'
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.response?.data?.error || '安装失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.install-page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.install-card {
|
||||
background: #2d2d44;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 16px;
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.install-logo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, #4e6ef2, #7c5cfc);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.install-logo h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.install-desc {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div class="articles-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">资讯管理</h2>
|
||||
<el-button type="primary" @click="showAdd = true">创建资讯</el-button>
|
||||
</div>
|
||||
<div class="table-card">
|
||||
<el-table :data="paginatedArticles">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="title" label="标题" />
|
||||
<el-table-column prop="is_pinned" label="置顶" width="80">
|
||||
<template #default="{ row }"><el-tag :type="row.is_pinned ? 'danger' : 'info'" size="small">{{ row.is_pinned ? '是' : '否' }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="is_published" label="发布" width="80">
|
||||
<template #default="{ row }"><el-tag :type="row.is_published ? 'success' : 'info'" size="small">{{ row.is_published ? '是' : '否' }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="180">
|
||||
<template #default="{ row }">{{ formatDate(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="editArticle(row)">编辑</el-button>
|
||||
<el-button link :type="row.is_pinned ? 'warning' : 'success'" size="small" @click="togglePin(row.id)">{{ row.is_pinned ? '取消置顶' : '置顶' }}</el-button>
|
||||
<el-button link type="danger" size="small" @click="deleteArticle(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<el-dialog v-model="showAdd" :title="editing ? '编辑资讯' : '创建资讯'" width="700px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="标题"><el-input v-model="form.title" /></el-form-item>
|
||||
<el-form-item label="摘要"><el-input v-model="form.summary" type="textarea" :rows="2" /></el-form-item>
|
||||
<el-form-item label="内容"><el-input v-model="form.content" type="textarea" :rows="10" /></el-form-item>
|
||||
<el-form-item label="封面图"><el-input v-model="form.cover_image" placeholder="URL" /></el-form-item>
|
||||
<el-form-item label="排序"><el-input-number v-model="form.sort_order" /></el-form-item>
|
||||
<el-form-item label="发布"><el-switch v-model="form.is_published" /></el-form-item>
|
||||
<el-form-item label="置顶"><el-switch v-model="form.is_pinned" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showAdd = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveArticle">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { adminApi } from '../../api'
|
||||
|
||||
const articles = ref<any[]>([])
|
||||
const showAdd = ref(false)
|
||||
const editing = ref<any>(null)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const total = computed(() => articles.value.length)
|
||||
const paginatedArticles = computed(() => {
|
||||
const start = (page.value - 1) * pageSize
|
||||
return articles.value.slice(start, start + pageSize)
|
||||
})
|
||||
const form = reactive({ title: '', content: '', summary: '', cover_image: '', sort_order: 0, is_published: true, is_pinned: false })
|
||||
|
||||
function handlePageChange() {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
function formatDate(date: string) {
|
||||
if (!date) return '-'
|
||||
const d = new Date(date)
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const hours = String(d.getHours()).padStart(2, '0')
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
async function fetchArticles() {
|
||||
const res: any = await adminApi.getArticles()
|
||||
articles.value = res.data || []
|
||||
}
|
||||
|
||||
function editArticle(row: any) {
|
||||
editing.value = row
|
||||
Object.assign(form, { title: row.title, content: row.content, summary: row.summary || '', cover_image: row.cover_image || '', sort_order: row.sort_order, is_published: row.is_published, is_pinned: row.is_pinned })
|
||||
showAdd.value = true
|
||||
}
|
||||
|
||||
async function saveArticle() {
|
||||
if (editing.value) {
|
||||
await adminApi.updateArticle(editing.value.id, form)
|
||||
} else {
|
||||
await adminApi.createArticle(form)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
showAdd.value = false
|
||||
editing.value = null
|
||||
await fetchArticles()
|
||||
}
|
||||
|
||||
async function togglePin(id: number) {
|
||||
await adminApi.togglePinArticle(id)
|
||||
ElMessage.success('操作成功')
|
||||
await fetchArticles()
|
||||
}
|
||||
|
||||
async function deleteArticle(id: number) {
|
||||
await ElMessageBox.confirm('确定删除?', '确认')
|
||||
await adminApi.deleteArticle(id)
|
||||
ElMessage.success('删除成功')
|
||||
await fetchArticles()
|
||||
}
|
||||
|
||||
onMounted(fetchArticles)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.articles-page { padding: 0; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
|
||||
.page-title { font-size: 24px; font-weight: 600; color: #fff; }
|
||||
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
|
||||
|
||||
.pagination-wrap {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination) {
|
||||
--el-pagination-bg-color: rgba(255, 255, 255, 0.06);
|
||||
--el-pagination-text-color: rgba(255, 255, 255, 0.6);
|
||||
--el-pagination-button-disabled-bg-color: rgba(255, 255, 255, 0.04);
|
||||
--el-pagination-button-bg-color: rgba(255, 255, 255, 0.06);
|
||||
--el-pagination-hover-color: #4e6ef2;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .el-pager li) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border-radius: 6px;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .el-pager li:hover),
|
||||
.pagination-wrap :deep(.el-pagination .el-pager li.is-active) {
|
||||
background: #4e6ef2;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .btn-prev),
|
||||
.pagination-wrap :deep(.el-pagination .btn-next) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .btn-prev:hover),
|
||||
.pagination-wrap :deep(.el-pagination .btn-next:hover) {
|
||||
background: rgba(78, 110, 242, 0.2);
|
||||
color: #4e6ef2;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination__total) {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<div class="categories-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">分类管理</h2>
|
||||
<el-button type="primary" @click="openAdd">创建分类</el-button>
|
||||
</div>
|
||||
<div class="table-card">
|
||||
<el-table :data="paginatedCategories" row-key="id" default-expand-all>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="name" label="分类名称" />
|
||||
<el-table-column prop="description" label="描述" />
|
||||
<el-table-column label="金额限制" width="200">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.min_amount || row.max_amount">¥{{ row.min_amount || 0 }}~{{ row.max_amount || '∞' }}</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="editCat(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="deleteCat(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<el-dialog v-model="showAdd" :title="editing ? '编辑分类' : '创建分类'" width="500px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="名称"><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="描述"><el-input v-model="form.description" type="textarea" /></el-form-item>
|
||||
<el-form-item label="最小金额"><el-input-number v-model="form.min_amount" :precision="2" /></el-form-item>
|
||||
<el-form-item label="最大金额"><el-input-number v-model="form.max_amount" :precision="2" /></el-form-item>
|
||||
<el-form-item label="最小数量"><el-input-number v-model="form.min_quantity" /></el-form-item>
|
||||
<el-form-item label="最大数量"><el-input-number v-model="form.max_quantity" /></el-form-item>
|
||||
<el-form-item label="最小重量"><el-input-number v-model="form.min_weight" :precision="2" /></el-form-item>
|
||||
<el-form-item label="最大重量"><el-input-number v-model="form.max_weight" :precision="2" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showAdd = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveCat">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { adminApi, categoryApi } from '../../api'
|
||||
|
||||
const categories = ref<any[]>([])
|
||||
const showAdd = ref(false)
|
||||
const editing = ref<any>(null)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const total = computed(() => categories.value.length)
|
||||
const paginatedCategories = computed(() => {
|
||||
const start = (page.value - 1) * pageSize
|
||||
return categories.value.slice(start, start + pageSize)
|
||||
})
|
||||
const form = reactive({ name: '', description: '', min_amount: undefined as number | undefined, max_amount: undefined as number | undefined, min_quantity: undefined as number | undefined, max_quantity: undefined as number | undefined, min_weight: undefined as number | undefined, max_weight: undefined as number | undefined })
|
||||
|
||||
function handlePageChange() {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
async function fetchCategories() {
|
||||
const res: any = await categoryApi.list()
|
||||
categories.value = res.data || []
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
editing.value = null
|
||||
Object.assign(form, { name: '', description: '', min_amount: undefined, max_amount: undefined, min_quantity: undefined, max_quantity: undefined, min_weight: undefined, max_weight: undefined })
|
||||
showAdd.value = true
|
||||
}
|
||||
|
||||
function editCat(row: any) {
|
||||
editing.value = row
|
||||
Object.assign(form, { name: row.name, description: row.description || '', min_amount: row.min_amount, max_amount: row.max_amount, min_quantity: row.min_quantity, max_quantity: row.max_quantity, min_weight: row.min_weight, max_weight: row.max_weight })
|
||||
showAdd.value = true
|
||||
}
|
||||
|
||||
async function saveCat() {
|
||||
if (editing.value) {
|
||||
await adminApi.updateCategory(editing.value.id, form)
|
||||
} else {
|
||||
await adminApi.createCategory(form)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
showAdd.value = false
|
||||
editing.value = null
|
||||
await fetchCategories()
|
||||
}
|
||||
|
||||
async function deleteCat(id: number) {
|
||||
await ElMessageBox.confirm('确定删除此分类?', '确认')
|
||||
await adminApi.deleteCategory(id)
|
||||
ElMessage.success('删除成功')
|
||||
await fetchCategories()
|
||||
}
|
||||
|
||||
onMounted(fetchCategories)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.categories-page { padding: 0; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
|
||||
.page-title { font-size: 24px; font-weight: 600; color: #fff; }
|
||||
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
|
||||
|
||||
.pagination-wrap {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination) {
|
||||
--el-pagination-bg-color: rgba(255, 255, 255, 0.06);
|
||||
--el-pagination-text-color: rgba(255, 255, 255, 0.6);
|
||||
--el-pagination-button-disabled-bg-color: rgba(255, 255, 255, 0.04);
|
||||
--el-pagination-button-bg-color: rgba(255, 255, 255, 0.06);
|
||||
--el-pagination-hover-color: #4e6ef2;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .el-pager li) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border-radius: 6px;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .el-pager li:hover),
|
||||
.pagination-wrap :deep(.el-pagination .el-pager li.is-active) {
|
||||
background: #4e6ef2;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .btn-prev),
|
||||
.pagination-wrap :deep(.el-pagination .btn-next) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .btn-prev:hover),
|
||||
.pagination-wrap :deep(.el-pagination .btn-next:hover) {
|
||||
background: rgba(78, 110, 242, 0.2);
|
||||
color: #4e6ef2;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination__total) {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="dashboard-page">
|
||||
<h2 class="page-title">管理后台</h2>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon users"><el-icon><User /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<p class="stat-value">{{ stats.users }}</p>
|
||||
<p class="stat-label">用户数</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon products"><el-icon><Goods /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<p class="stat-value">{{ stats.products }}</p>
|
||||
<p class="stat-label">商品数</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon orders"><el-icon><List /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<p class="stat-value">{{ stats.orders }}</p>
|
||||
<p class="stat-label">订单数</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon revenue"><el-icon><Money /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<p class="stat-value">{{ stats.revenue }}</p>
|
||||
<p class="stat-label">营收</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { User, Goods, List, Money } from '@element-plus/icons-vue'
|
||||
import api from '../../utils/request'
|
||||
|
||||
const stats = ref({ users: '--', products: '--', orders: '--', revenue: '--' })
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res: any = await api.get('/admin/stats')
|
||||
if (res.data) {
|
||||
stats.value.users = res.data.users || 0
|
||||
stats.value.products = res.data.products || 0
|
||||
stats.value.orders = res.data.orders || 0
|
||||
stats.value.revenue = '¥' + (res.data.revenue || 0).toFixed(2)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取统计数据失败:', e)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dashboard-page { padding: 0; }
|
||||
.page-title { font-size: 24px; font-weight: 600; color: #fff; margin-bottom: 24px; }
|
||||
.stat-card {
|
||||
background: #2d2d44;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
}
|
||||
.stat-icon.users { background: rgba(78, 110, 242, 0.15); color: #4e6ef2; }
|
||||
.stat-icon.products { background: rgba(16, 185, 129, 0.15); color: #10b981; }
|
||||
.stat-icon.orders { background: rgba(245, 158, 11, 0.15); color: #f59e0b; }
|
||||
.stat-icon.revenue { background: rgba(239, 68, 68, 0.15); color: #f87171; }
|
||||
.stat-info { flex: 1; }
|
||||
.stat-value { font-size: 28px; font-weight: 700; color: #fff; }
|
||||
.stat-label { font-size: 14px; color: rgba(255, 255, 255, 0.5); margin-top: 4px; }
|
||||
</style>
|
||||
@@ -0,0 +1,316 @@
|
||||
<template>
|
||||
<div class="lotteries-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">抽奖管理</h2>
|
||||
<el-button type="primary" @click="openAdd">创建抽奖</el-button>
|
||||
</div>
|
||||
<div class="table-card">
|
||||
<el-table :data="paginatedLotteries">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column prop="cycle" label="周期" width="100">
|
||||
<template #default="{ row }">{{ cycleText(row.cycle) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="start_time" label="开始时间" width="180">
|
||||
<template #default="{ row }">{{ formatTime(row.start_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="end_time" label="结束时间" width="180">
|
||||
<template #default="{ row }">{{ formatTime(row.end_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="280">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="addPrize(row.id)">奖品</el-button>
|
||||
<el-button link type="success" size="small" @click="draw(row.id)">开奖</el-button>
|
||||
<el-button link type="warning" size="small" @click="editLottery(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="deleteLottery(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="showAdd" :title="editing ? '编辑抽奖' : '创建抽奖'" width="600px">
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item label="抽奖名称" required>
|
||||
<el-input v-model="form.name" placeholder="请输入抽奖名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="抽奖描述">
|
||||
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="请输入抽奖描述" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开始时间" required>
|
||||
<el-date-picker
|
||||
v-model="form.start_time"
|
||||
type="datetime"
|
||||
placeholder="选择开始时间"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-format="YYYY-MM-DDTHH:mm:ssZ"
|
||||
popper-class="dark-date-picker"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="结束时间" required>
|
||||
<el-date-picker
|
||||
v-model="form.end_time"
|
||||
type="datetime"
|
||||
placeholder="选择结束时间"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-format="YYYY-MM-DDTHH:mm:ssZ"
|
||||
popper-class="dark-date-picker"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="抽奖周期">
|
||||
<el-select v-model="form.cycle" placeholder="选择周期" popper-class="dark-select-dropdown" style="width: 100%">
|
||||
<el-option value="daily" label="每日" />
|
||||
<el-option value="weekly" label="每周" />
|
||||
<el-option value="monthly" label="每月" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="每日配额">
|
||||
<el-input-number v-model="form.daily_quota" :min="0" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="总配额">
|
||||
<el-input-number v-model="form.total_quota" :min="0" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="报名有效期">
|
||||
<el-input-number v-model="form.registration_validity" :min="0" style="width: 200px" />
|
||||
<span style="margin-left: 8px; color: rgba(255,255,255,0.5)">天</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showAdd = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveLottery">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="showPrize" title="添加奖品" width="500px">
|
||||
<el-form :model="prizeForm" label-width="100px">
|
||||
<el-form-item label="奖品名称" required>
|
||||
<el-input v-model="prizeForm.name" placeholder="请输入奖品名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="奖品类型">
|
||||
<el-select v-model="prizeForm.type" popper-class="dark-select-dropdown" style="width: 100%">
|
||||
<el-option value="physical" label="实物奖品" />
|
||||
<el-option value="virtual" label="虚拟奖品" />
|
||||
<el-option value="credit" label="购买资格" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="奖品数量">
|
||||
<el-input-number v-model="prizeForm.quantity" :min="1" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="中奖权重">
|
||||
<el-input-number v-model="prizeForm.weight" :min="1" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="抽奖模式">
|
||||
<el-select v-model="prizeForm.draw_mode" popper-class="dark-select-dropdown" style="width: 100%">
|
||||
<el-option value="random" label="随机抽奖" />
|
||||
<el-option value="weight" label="权重抽奖" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="积分奖励" v-if="prizeForm.type === 'credit'">
|
||||
<el-input-number v-model="prizeForm.credit_reward" :min="1" style="width: 200px" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showPrize = false">取消</el-button>
|
||||
<el-button type="primary" @click="savePrize">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { adminApi } from '../../api'
|
||||
|
||||
const lotteries = ref<any[]>([])
|
||||
const showAdd = ref(false)
|
||||
const showPrize = ref(false)
|
||||
const prizeLotteryId = ref(0)
|
||||
const editing = ref<any>(null)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const total = computed(() => lotteries.value.length)
|
||||
const paginatedLotteries = computed(() => {
|
||||
const start = (page.value - 1) * pageSize
|
||||
return lotteries.value.slice(start, start + pageSize)
|
||||
})
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
cycle: 'daily',
|
||||
daily_quota: undefined as number | undefined,
|
||||
total_quota: undefined as number | undefined,
|
||||
registration_validity: undefined as number | undefined
|
||||
})
|
||||
const prizeForm = reactive({
|
||||
name: '',
|
||||
type: 'physical',
|
||||
quantity: 1,
|
||||
weight: 1,
|
||||
draw_mode: 'random',
|
||||
credit_reward: undefined as number | undefined
|
||||
})
|
||||
|
||||
const cycleMap: Record<string, string> = { daily: '每日', weekly: '每周', monthly: '每月' }
|
||||
function cycleText(s: string) { return cycleMap[s] || s }
|
||||
function formatTime(t: string) {
|
||||
if (!t) return '-'
|
||||
return t.replace('T', ' ').replace('Z', '').substring(0, 19)
|
||||
}
|
||||
|
||||
function handlePageChange() {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
async function fetchLotteries() {
|
||||
const res: any = await adminApi.getLotteries()
|
||||
lotteries.value = res.data || []
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
editing.value = null
|
||||
Object.assign(form, { name: '', description: '', start_time: '', end_time: '', cycle: 'daily', daily_quota: undefined, total_quota: undefined, registration_validity: undefined })
|
||||
showAdd.value = true
|
||||
}
|
||||
|
||||
function editLottery(row: any) {
|
||||
editing.value = row
|
||||
Object.assign(form, {
|
||||
name: row.name,
|
||||
description: row.description || '',
|
||||
start_time: row.start_time,
|
||||
end_time: row.end_time,
|
||||
cycle: row.cycle || 'daily',
|
||||
daily_quota: row.daily_quota,
|
||||
total_quota: row.total_quota,
|
||||
registration_validity: row.registration_validity
|
||||
})
|
||||
showAdd.value = true
|
||||
}
|
||||
|
||||
async function saveLottery() {
|
||||
if (!form.name || !form.start_time || !form.end_time) {
|
||||
ElMessage.warning('请填写必填项')
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (editing.value) {
|
||||
await adminApi.updateLottery(editing.value.id, form)
|
||||
} else {
|
||||
await adminApi.createLottery(form)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
showAdd.value = false
|
||||
editing.value = null
|
||||
await fetchLotteries()
|
||||
} catch (e) {
|
||||
ElMessage.error('保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
function addPrize(id: number) {
|
||||
prizeLotteryId.value = id
|
||||
Object.assign(prizeForm, { name: '', type: 'physical', quantity: 1, weight: 1, draw_mode: 'random', credit_reward: undefined })
|
||||
showPrize.value = true
|
||||
}
|
||||
|
||||
async function savePrize() {
|
||||
if (!prizeForm.name) {
|
||||
ElMessage.warning('请输入奖品名称')
|
||||
return
|
||||
}
|
||||
await adminApi.addLotteryPrize(prizeLotteryId.value, prizeForm)
|
||||
ElMessage.success('奖品已添加')
|
||||
showPrize.value = false
|
||||
}
|
||||
|
||||
async function draw(id: number) {
|
||||
await ElMessageBox.confirm('确定执行开奖?此操作不可撤销', '确认开奖')
|
||||
await adminApi.drawLottery(id)
|
||||
ElMessage.success('开奖完成')
|
||||
}
|
||||
|
||||
async function deleteLottery(id: number) {
|
||||
await ElMessageBox.confirm('确定删除此抽奖活动?', '确认删除')
|
||||
await adminApi.deleteLottery(id)
|
||||
ElMessage.success('删除成功')
|
||||
await fetchLotteries()
|
||||
}
|
||||
|
||||
onMounted(fetchLotteries)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.lotteries-page { padding: 0; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
|
||||
.page-title { font-size: 24px; font-weight: 600; color: #fff; }
|
||||
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
|
||||
|
||||
.pagination-wrap {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination) {
|
||||
--el-pagination-bg-color: rgba(255, 255, 255, 0.06);
|
||||
--el-pagination-text-color: rgba(255, 255, 255, 0.6);
|
||||
--el-pagination-button-disabled-bg-color: rgba(255, 255, 255, 0.04);
|
||||
--el-pagination-button-bg-color: rgba(255, 255, 255, 0.06);
|
||||
--el-pagination-hover-color: #4e6ef2;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .el-pager li) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border-radius: 6px;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .el-pager li:hover),
|
||||
.pagination-wrap :deep(.el-pagination .el-pager li.is-active) {
|
||||
background: #4e6ef2;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .btn-prev),
|
||||
.pagination-wrap :deep(.el-pagination .btn-next) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .btn-prev:hover),
|
||||
.pagination-wrap :deep(.el-pagination .btn-next:hover) {
|
||||
background: rgba(78, 110, 242, 0.2);
|
||||
color: #4e6ef2;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination__total) {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<div class="orders-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">订单管理</h2>
|
||||
<el-button @click="exportOrders">导出订单</el-button>
|
||||
</div>
|
||||
<div class="table-card">
|
||||
<el-table :data="paginatedOrders">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="user.username" label="用户" width="120" />
|
||||
<el-table-column prop="total_amount" label="金额" width="100"><template #default="{ row }">¥{{ row.total_amount }}</template></el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="120"><template #default="{ row }"><el-tag>{{ statusText(row.status) }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="refund_status" label="退款" width="100"><template #default="{ row }"><el-tag v-if="row.refund_status" type="warning">{{ row.refund_status }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="创建时间" width="180">
|
||||
<template #default="{ row }">{{ formatDate(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.refund_status === 'pending'" link type="primary" size="small" @click="processRefund(row.id, 'approved')">批准</el-button>
|
||||
<el-button v-if="row.refund_status === 'pending'" link type="danger" size="small" @click="processRefund(row.id, 'rejected')">拒绝</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { adminApi } from '../../api'
|
||||
|
||||
const orders = ref<any[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const total = computed(() => orders.value.length)
|
||||
const paginatedOrders = computed(() => {
|
||||
const start = (page.value - 1) * pageSize
|
||||
return orders.value.slice(start, start + pageSize)
|
||||
})
|
||||
|
||||
const statusMap: Record<string, string> = {
|
||||
pending_payment: '待支付', pending_confirm: '待确认', pending_ship: '待发货',
|
||||
shipped: '已发货', completed: '已完成', refunding: '退款中', refunded: '已退款', cancelled: '已取消',
|
||||
}
|
||||
function statusText(s: string) { return statusMap[s] || s }
|
||||
|
||||
function formatDate(date: string) {
|
||||
if (!date) return '-'
|
||||
const d = new Date(date)
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const hours = String(d.getHours()).padStart(2, '0')
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
function handlePageChange() {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
async function fetchOrders() {
|
||||
const res: any = await adminApi.getOrders()
|
||||
orders.value = res.data || []
|
||||
}
|
||||
|
||||
async function processRefund(id: number, status: string) {
|
||||
await adminApi.processRefund(id, { status })
|
||||
ElMessage.success('已处理')
|
||||
await fetchOrders()
|
||||
}
|
||||
|
||||
async function exportOrders() {
|
||||
await adminApi.exportOrders()
|
||||
ElMessage.success('导出完成')
|
||||
}
|
||||
|
||||
onMounted(fetchOrders)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.orders-page { padding: 0; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
|
||||
.page-title { font-size: 24px; font-weight: 600; color: #fff; }
|
||||
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
|
||||
|
||||
.pagination-wrap {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination) {
|
||||
--el-pagination-bg-color: rgba(255, 255, 255, 0.06);
|
||||
--el-pagination-text-color: rgba(255, 255, 255, 0.6);
|
||||
--el-pagination-button-disabled-bg-color: rgba(255, 255, 255, 0.04);
|
||||
--el-pagination-button-bg-color: rgba(255, 255, 255, 0.06);
|
||||
--el-pagination-hover-color: #4e6ef2;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .el-pager li) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border-radius: 6px;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .el-pager li:hover),
|
||||
.pagination-wrap :deep(.el-pagination .el-pager li.is-active) {
|
||||
background: #4e6ef2;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .btn-prev),
|
||||
.pagination-wrap :deep(.el-pagination .btn-next) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .btn-prev:hover),
|
||||
.pagination-wrap :deep(.el-pagination .btn-next:hover) {
|
||||
background: rgba(78, 110, 242, 0.2);
|
||||
color: #4e6ef2;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination__total) {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,516 @@
|
||||
<template>
|
||||
<div class="products-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">商品管理</h2>
|
||||
<el-button type="primary" @click="openAdd">创建商品</el-button>
|
||||
</div>
|
||||
<div class="table-card">
|
||||
<el-table :data="paginatedProducts" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column label="图片" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-image v-if="getFirstImage(row.images)" :src="getFirstImage(row.images)" fit="cover" style="width: 50px; height: 50px; border-radius: 4px;">
|
||||
<template #error>
|
||||
<div class="table-image-placeholder">-</div>
|
||||
</template>
|
||||
</el-image>
|
||||
<span v-else style="color: rgba(255,255,255,0.3)">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="商品名称" min-width="150" />
|
||||
<el-table-column label="品牌" width="100">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.brand">{{ row.brand.name }}</span>
|
||||
<span v-else style="color: rgba(255,255,255,0.3)">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="price" label="价格" width="100">
|
||||
<template #default="{ row }">¥{{ row.price }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="stock" label="库存" width="80" />
|
||||
<el-table-column label="上架" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.is_active" @change="toggleActive(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="editProd(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="deleteProd(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="showAdd" :title="editing ? '编辑商品' : '创建商品'" width="800px" top="5vh">
|
||||
<el-form :model="form" label-width="90px" class="product-form">
|
||||
<div class="form-section">
|
||||
<div class="section-title">基本信息</div>
|
||||
<el-form-item label="商品名称" required>
|
||||
<el-input v-model="form.name" placeholder="请输入商品名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="商品描述">
|
||||
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="请输入商品描述" />
|
||||
</el-form-item>
|
||||
<el-form-item label="商品图片">
|
||||
<el-upload
|
||||
v-model:file-list="fileList"
|
||||
action=""
|
||||
list-type="picture-card"
|
||||
:auto-upload="false"
|
||||
:on-change="handleFileChange"
|
||||
:on-remove="handleFileRemove"
|
||||
accept="image/*"
|
||||
multiple
|
||||
>
|
||||
<el-icon><Plus /></el-icon>
|
||||
</el-upload>
|
||||
<div class="upload-tip">支持 jpg、png、gif、webp 格式</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="section-title">价格库存</div>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="价格" required>
|
||||
<el-input-number v-model="form.price" :precision="2" :min="0" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="库存">
|
||||
<el-input-number v-model="form.stock" :min="0" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="上架">
|
||||
<el-switch v-model="form.is_active" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="最低购买">
|
||||
<el-input-number v-model="form.min_purchase" :min="1" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="最大购买">
|
||||
<el-input-number v-model="form.max_purchase" :min="0" style="width: 100%" placeholder="0表示不限" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="section-title">分类品牌</div>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="所属分类">
|
||||
<el-select v-model="form.category_ids" multiple placeholder="选择分类" popper-class="dark-select-dropdown" style="width: 100%">
|
||||
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="所属品牌">
|
||||
<el-select v-model="form.brand_id" placeholder="选择品牌" clearable popper-class="dark-select-dropdown" style="width: 100%">
|
||||
<el-option v-for="b in brands" :key="b.id" :label="b.name" :value="b.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="section-title">资格设置</div>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="需要资格">
|
||||
<el-switch v-model="form.require_credit" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="资格消耗">
|
||||
<el-input-number v-model="form.credit_cost" :min="0" style="width: 100%" :disabled="!form.require_credit" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="资格奖励">
|
||||
<el-input-number v-model="form.credit_reward" :min="0" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showAdd = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveProd" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { UploadFile, UploadUserFile } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { adminApi, categoryApi, brandApi, uploadApi } from '../../api'
|
||||
import { getImageUrl } from '../../utils/image'
|
||||
|
||||
const products = ref<any[]>([])
|
||||
const categories = ref<any[]>([])
|
||||
const brands = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const showAdd = ref(false)
|
||||
const editing = ref<any>(null)
|
||||
const fileList = ref<UploadUserFile[]>([])
|
||||
const uploadedUrls = ref<string[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const total = computed(() => products.value.length)
|
||||
const paginatedProducts = computed(() => {
|
||||
const start = (page.value - 1) * pageSize
|
||||
return products.value.slice(start, start + pageSize)
|
||||
})
|
||||
|
||||
function handlePageChange() {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
price: 0,
|
||||
stock: 0,
|
||||
min_purchase: 1,
|
||||
max_purchase: undefined as number | undefined,
|
||||
require_credit: false,
|
||||
credit_cost: 0,
|
||||
credit_reward: 0,
|
||||
is_active: true,
|
||||
category_ids: [] as number[],
|
||||
brand_id: undefined as number | undefined
|
||||
})
|
||||
|
||||
function getFirstImage(images: string) {
|
||||
if (!images) return ''
|
||||
const arr = images.split(',').map(s => s.trim()).filter(Boolean)
|
||||
return getImageUrl(arr[0] || '')
|
||||
}
|
||||
|
||||
function handleFileChange(file: UploadFile) {
|
||||
if (file.status === 'ready') {
|
||||
file.status = 'ready'
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileRemove(file: UploadFile) {
|
||||
const index = fileList.value.findIndex(f => f.uid === file.uid)
|
||||
if (index > -1) {
|
||||
fileList.value.splice(index, 1)
|
||||
}
|
||||
const urlIndex = uploadedUrls.value.indexOf(file.url || '')
|
||||
if (urlIndex > -1) {
|
||||
uploadedUrls.value.splice(urlIndex, 1)
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFiles(): Promise<string[]> {
|
||||
const urls: string[] = [...uploadedUrls.value]
|
||||
const filesToUpload = fileList.value.filter(f => f.raw && f.status === 'ready')
|
||||
|
||||
for (const file of filesToUpload) {
|
||||
try {
|
||||
const res: any = await uploadApi.uploadImage(file.raw as File)
|
||||
if (res.url) {
|
||||
urls.push(res.url)
|
||||
file.url = res.url
|
||||
file.status = 'success'
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Upload failed:', e)
|
||||
file.status = 'fail'
|
||||
}
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
||||
async function fetchProducts() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await adminApi.getProducts()
|
||||
products.value = res.data || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCategories() {
|
||||
const res: any = await categoryApi.list()
|
||||
categories.value = res.data || []
|
||||
}
|
||||
|
||||
async function fetchBrands() {
|
||||
const res: any = await brandApi.list()
|
||||
brands.value = res.data || []
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
editing.value = null
|
||||
Object.assign(form, {
|
||||
name: '', description: '', price: 0, stock: 0,
|
||||
min_purchase: 1, max_purchase: undefined,
|
||||
require_credit: false, credit_cost: 0, credit_reward: 0,
|
||||
is_active: true, category_ids: [], brand_id: undefined
|
||||
})
|
||||
fileList.value = []
|
||||
uploadedUrls.value = []
|
||||
showAdd.value = true
|
||||
}
|
||||
|
||||
function editProd(row: any) {
|
||||
editing.value = row
|
||||
Object.assign(form, {
|
||||
name: row.name,
|
||||
description: row.description || '',
|
||||
price: row.price,
|
||||
stock: row.stock || 0,
|
||||
min_purchase: row.min_purchase || 1,
|
||||
max_purchase: row.max_purchase,
|
||||
require_credit: row.require_credit,
|
||||
credit_cost: row.credit_cost || 0,
|
||||
credit_reward: row.credit_reward || 0,
|
||||
is_active: row.is_active !== false,
|
||||
category_ids: (row.categories || []).map((c: any) => c.id),
|
||||
brand_id: row.brand_id
|
||||
})
|
||||
const images = row.images ? row.images.split(',').map((s: string) => s.trim()).filter(Boolean) : []
|
||||
uploadedUrls.value = images
|
||||
fileList.value = images.map((url: string, index: number) => ({
|
||||
name: `image-${index}`,
|
||||
url: getImageUrl(url),
|
||||
status: 'success' as const,
|
||||
uid: Date.now() + index
|
||||
}))
|
||||
showAdd.value = true
|
||||
}
|
||||
|
||||
async function saveProd() {
|
||||
if (!form.name) {
|
||||
ElMessage.warning('请输入商品名称')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const urls = await uploadFiles()
|
||||
const submitData = { ...form, images: urls.join(',') }
|
||||
if (editing.value) {
|
||||
await adminApi.updateProduct(editing.value.id, submitData)
|
||||
} else {
|
||||
await adminApi.createProduct(submitData)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
showAdd.value = false
|
||||
editing.value = null
|
||||
await fetchProducts()
|
||||
} catch (e) {
|
||||
ElMessage.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleActive(row: any) {
|
||||
await adminApi.updateProduct(row.id, { is_active: row.is_active })
|
||||
ElMessage.success(row.is_active ? '已上架' : '已下架')
|
||||
}
|
||||
|
||||
async function deleteProd(id: number) {
|
||||
await ElMessageBox.confirm('确定删除此商品?', '确认删除')
|
||||
await adminApi.deleteProduct(id)
|
||||
ElMessage.success('删除成功')
|
||||
await fetchProducts()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchProducts()
|
||||
fetchCategories()
|
||||
fetchBrands()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.products-page { padding: 0; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
|
||||
.page-title { font-size: 24px; font-weight: 600; color: #fff; }
|
||||
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
|
||||
.table-image-placeholder { width: 50px; height: 50px; display: flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.04); border-radius: 4px; color: rgba(255,255,255,0.3); }
|
||||
.upload-tip { font-size: 12px; color: rgba(255,255,255,0.4); margin-top: 8px; }
|
||||
|
||||
.pagination-wrap {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination) {
|
||||
--el-pagination-bg-color: rgba(255, 255, 255, 0.06);
|
||||
--el-pagination-text-color: rgba(255, 255, 255, 0.6);
|
||||
--el-pagination-button-disabled-bg-color: rgba(255, 255, 255, 0.04);
|
||||
--el-pagination-button-bg-color: rgba(255, 255, 255, 0.06);
|
||||
--el-pagination-hover-color: #4e6ef2;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .el-pager li) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border-radius: 6px;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .el-pager li:hover),
|
||||
.pagination-wrap :deep(.el-pagination .el-pager li.is-active) {
|
||||
background: #4e6ef2;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.product-form {
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #4e6ef2;
|
||||
margin-bottom: 16px;
|
||||
padding-left: 10px;
|
||||
border-left: 3px solid #4e6ef2;
|
||||
}
|
||||
|
||||
:deep(.el-upload--picture-card) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
:deep(.el-upload--picture-card:hover) {
|
||||
border-color: #4e6ef2;
|
||||
color: #4e6ef2;
|
||||
}
|
||||
|
||||
:deep(.el-upload-list--picture-card .el-upload-list__item) {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__label) {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
:deep(.el-dialog__body) {
|
||||
padding: 20px 24px;
|
||||
max-height: 65vh;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
:deep(.el-dialog) {
|
||||
background: #2d2d44;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
:deep(.el-dialog__header) {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding: 16px 20px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:deep(.el-dialog__title) {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.el-dialog__headerbtn .el-dialog__close) {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-dialog__footer) {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper),
|
||||
:deep(.el-textarea__inner) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: none;
|
||||
|
||||
&:hover, &:focus {
|
||||
border-color: #4e6ef2;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-input__inner),
|
||||
:deep(.el-textarea__inner) {
|
||||
color: #fff;
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-select .el-input__wrapper) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
:deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.el-input-number .el-input__wrapper) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">系统设置</h2>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<el-tabs>
|
||||
<el-tab-pane label="基础设置">
|
||||
<el-form :model="settings" label-width="160px">
|
||||
<el-form-item label="渠道费率 (%)"><el-input-number v-model="settings.payment_channel_fee_rate" :precision="2" /></el-form-item>
|
||||
<el-form-item label="首重运费"><el-input-number v-model="settings.shipping_fee_first_weight" :precision="2" /></el-form-item>
|
||||
<el-form-item label="续重单价"><el-input-number v-model="settings.shipping_fee_per_gram" :precision="2" /></el-form-item>
|
||||
<el-form-item label="服务费率 (%)"><el-input-number v-model="settings.service_fee_rate" :precision="2" /></el-form-item>
|
||||
<el-form-item label="税费 (%)"><el-input-number v-model="settings.tax_rate" :precision="2" /></el-form-item>
|
||||
<el-form-item label="邀请奖励积分"><el-input-number v-model="settings.invite_credit_reward" /></el-form-item>
|
||||
<el-form-item label="验证码功能"><el-switch v-model="verifyEnabled" active-value="true" inactive-value="false" /></el-form-item>
|
||||
<el-form-item><el-button type="primary" @click="saveSettings">保存</el-button></el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="支付设置">
|
||||
<el-form label-width="160px">
|
||||
<el-form-item label="启用支付方式">
|
||||
<el-checkbox-group v-model="enabledPayments">
|
||||
<el-checkbox label="balance">余额支付</el-checkbox>
|
||||
<el-checkbox label="alipay">支付宝</el-checkbox>
|
||||
<el-checkbox label="wechat">微信支付</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">支付宝配置</el-divider>
|
||||
<el-form-item label="支付宝AppID"><el-input v-model="alipayConfig.app_id" placeholder="应用ID" /></el-form-item>
|
||||
<el-form-item label="支付宝私钥"><el-input v-model="alipayConfig.private_key" type="textarea" :rows="3" placeholder="应用私钥" /></el-form-item>
|
||||
<el-divider content-position="left">微信支付配置</el-divider>
|
||||
<el-form-item label="微信AppID"><el-input v-model="wechatConfig.app_id" placeholder="公众号/小程序AppID" /></el-form-item>
|
||||
<el-form-item label="微信MchID"><el-input v-model="wechatConfig.mch_id" placeholder="商户号" /></el-form-item>
|
||||
<el-form-item label="微信API密钥"><el-input v-model="wechatConfig.api_key" type="password" show-password placeholder="APIv2密钥" /></el-form-item>
|
||||
<el-form-item><el-button type="primary" @click="savePayment">保存</el-button></el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="SMTP设置">
|
||||
<el-form :model="smtp" label-width="160px">
|
||||
<el-form-item label="SMTP服务器"><el-input v-model="smtp.smtp_host" /></el-form-item>
|
||||
<el-form-item label="端口"><el-input v-model="smtp.smtp_port" /></el-form-item>
|
||||
<el-form-item label="用户名"><el-input v-model="smtp.smtp_user" /></el-form-item>
|
||||
<el-form-item label="密码"><el-input v-model="smtp.smtp_password" type="password" show-password /></el-form-item>
|
||||
<el-form-item label="发件人"><el-input v-model="smtp.smtp_from" /></el-form-item>
|
||||
<el-form-item><el-button type="primary" @click="saveSMTP">保存</el-button></el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { adminApi } from '../../api'
|
||||
|
||||
const settings = reactive<Record<string, any>>({
|
||||
payment_channel_fee_rate: 0,
|
||||
shipping_fee_first_weight: 0,
|
||||
shipping_fee_per_gram: 0,
|
||||
service_fee_rate: 0,
|
||||
tax_rate: 0,
|
||||
invite_credit_reward: 0
|
||||
})
|
||||
const verifyEnabled = ref('true')
|
||||
const enabledPayments = ref<string[]>(['balance'])
|
||||
const alipayConfig = reactive({ app_id: '', private_key: '' })
|
||||
const wechatConfig = reactive({ app_id: '', mch_id: '', api_key: '' })
|
||||
const smtp = reactive({ smtp_host: '', smtp_port: '587', smtp_user: '', smtp_password: '', smtp_from: '' })
|
||||
|
||||
async function fetchSettings() {
|
||||
const res: any = await adminApi.getSettings()
|
||||
const data = res.data || {}
|
||||
Object.keys(settings).forEach(k => { if (data[k] !== undefined) settings[k] = data[k] })
|
||||
verifyEnabled.value = data.verification_code_enabled || 'true'
|
||||
|
||||
if (data.enabled_payments) {
|
||||
try {
|
||||
enabledPayments.value = JSON.parse(data.enabled_payments)
|
||||
} catch { enabledPayments.value = ['balance'] }
|
||||
}
|
||||
|
||||
if (data.alipay_config) {
|
||||
try { Object.assign(alipayConfig, JSON.parse(data.alipay_config)) } catch {}
|
||||
}
|
||||
if (data.wechat_config) {
|
||||
try { Object.assign(wechatConfig, JSON.parse(data.wechat_config)) } catch {}
|
||||
}
|
||||
|
||||
Object.keys(smtp).forEach(k => { if (data[k] !== undefined) (smtp as any)[k] = data[k] })
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
await adminApi.updateSettings({ settings: { ...settings, verification_code_enabled: verifyEnabled.value } })
|
||||
ElMessage.success('保存成功')
|
||||
}
|
||||
|
||||
async function savePayment() {
|
||||
await adminApi.updatePayment({
|
||||
enabled_payments: JSON.stringify(enabledPayments.value),
|
||||
alipay_config: JSON.stringify(alipayConfig),
|
||||
wechat_config: JSON.stringify(wechatConfig)
|
||||
})
|
||||
ElMessage.success('保存成功')
|
||||
}
|
||||
|
||||
async function saveSMTP() {
|
||||
await adminApi.updateSMTP(smtp)
|
||||
ElMessage.success('保存成功')
|
||||
}
|
||||
|
||||
onMounted(fetchSettings)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.settings-page { padding: 0; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
|
||||
.page-title { font-size: 24px; font-weight: 600; color: #fff; }
|
||||
.settings-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 24px; }
|
||||
|
||||
:deep(.el-checkbox__label) { color: rgba(255, 255, 255, 0.8); }
|
||||
:deep(.el-checkbox__input.is-checked .el-checkbox__inner) { background: #4e6ef2; border-color: #4e6ef2; }
|
||||
:deep(.el-divider__text) { color: rgba(255, 255, 255, 0.6); background: #2d2d44; }
|
||||
</style>
|
||||
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<div class="suppliers-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">供应商管理</h2>
|
||||
<el-button type="primary" @click="showAdd = true">创建供应商</el-button>
|
||||
</div>
|
||||
<div class="table-card">
|
||||
<el-table :data="paginatedSuppliers">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="username" label="用户名" />
|
||||
<el-table-column prop="email" label="邮箱" />
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="authorize(row.id)">授权</el-button>
|
||||
<el-button link type="danger" size="small" @click="deleteSupplier(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<el-dialog v-model="showAdd" title="创建供应商" width="400px">
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="用户名"><el-input v-model="form.username" /></el-form-item>
|
||||
<el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item>
|
||||
<el-form-item label="密码"><el-input v-model="form.password" type="password" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showAdd = false">取消</el-button>
|
||||
<el-button type="primary" @click="addSupplier">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="showAuth" title="授权管理" width="400px">
|
||||
<el-form :model="authForm" label-width="80px">
|
||||
<el-form-item label="类型">
|
||||
<el-select v-model="authForm.type" popper-class="dark-select-dropdown"><el-option value="category" label="分类" /><el-option value="product" label="商品" /></el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关联ID"><el-input-number v-model="authForm.ref_id" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showAuth = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveAuth">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { adminApi } from '../../api'
|
||||
|
||||
const suppliers = ref<any[]>([])
|
||||
const showAdd = ref(false)
|
||||
const showAuth = ref(false)
|
||||
const authSupplierId = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const total = computed(() => suppliers.value.length)
|
||||
const paginatedSuppliers = computed(() => {
|
||||
const start = (page.value - 1) * pageSize
|
||||
return suppliers.value.slice(start, start + pageSize)
|
||||
})
|
||||
const form = reactive({ username: '', email: '', password: '' })
|
||||
const authForm = reactive({ type: 'category', ref_id: 0 })
|
||||
|
||||
function handlePageChange() {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
async function fetchSuppliers() {
|
||||
const res: any = await adminApi.getSuppliers()
|
||||
suppliers.value = res.data || []
|
||||
}
|
||||
|
||||
async function addSupplier() {
|
||||
await adminApi.createSupplier(form)
|
||||
ElMessage.success('创建成功')
|
||||
showAdd.value = false
|
||||
await fetchSuppliers()
|
||||
}
|
||||
|
||||
function authorize(id: number) {
|
||||
authSupplierId.value = id
|
||||
showAuth.value = true
|
||||
}
|
||||
|
||||
async function saveAuth() {
|
||||
await adminApi.authorizeSupplier(authSupplierId.value, authForm)
|
||||
ElMessage.success('授权成功')
|
||||
showAuth.value = false
|
||||
}
|
||||
|
||||
async function deleteSupplier(id: number) {
|
||||
await adminApi.deleteSupplier(id)
|
||||
ElMessage.success('删除成功')
|
||||
await fetchSuppliers()
|
||||
}
|
||||
|
||||
onMounted(fetchSuppliers)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.suppliers-page { padding: 0; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
|
||||
.page-title { font-size: 24px; font-weight: 600; color: #fff; }
|
||||
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
|
||||
|
||||
.pagination-wrap {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination) {
|
||||
--el-pagination-bg-color: rgba(255, 255, 255, 0.06);
|
||||
--el-pagination-text-color: rgba(255, 255, 255, 0.6);
|
||||
--el-pagination-button-disabled-bg-color: rgba(255, 255, 255, 0.04);
|
||||
--el-pagination-button-bg-color: rgba(255, 255, 255, 0.06);
|
||||
--el-pagination-hover-color: #4e6ef2;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .el-pager li) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border-radius: 6px;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .el-pager li:hover),
|
||||
.pagination-wrap :deep(.el-pagination .el-pager li.is-active) {
|
||||
background: #4e6ef2;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .btn-prev),
|
||||
.pagination-wrap :deep(.el-pagination .btn-next) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .btn-prev:hover),
|
||||
.pagination-wrap :deep(.el-pagination .btn-next:hover) {
|
||||
background: rgba(78, 110, 242, 0.2);
|
||||
color: #4e6ef2;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination__total) {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<div class="tickets-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">工单管理</h2>
|
||||
</div>
|
||||
<div class="table-card">
|
||||
<el-table :data="paginatedTickets">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="title" label="标题" />
|
||||
<el-table-column prop="user.username" label="用户" width="120" />
|
||||
<el-table-column prop="status" label="状态" width="120">
|
||||
<template #default="{ row }"><el-tag>{{ statusText(row.status) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="160">
|
||||
<template #default="{ row }">{{ formatDate(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-select v-model="row.status" size="small" popper-class="dark-select-dropdown" @change="updateStatus(row)">
|
||||
<el-option value="pending" label="待处理" />
|
||||
<el-option value="processing" label="处理中" />
|
||||
<el-option value="resolved" label="已解决" />
|
||||
<el-option value="closed" label="已关闭" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { adminApi } from '../../api'
|
||||
|
||||
const tickets = ref<any[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const total = computed(() => tickets.value.length)
|
||||
const paginatedTickets = computed(() => {
|
||||
const start = (page.value - 1) * pageSize
|
||||
return tickets.value.slice(start, start + pageSize)
|
||||
})
|
||||
|
||||
const statusMap: Record<string, string> = { pending: '待处理', processing: '处理中', resolved: '已解决', closed: '已关闭' }
|
||||
function statusText(s: string) { return statusMap[s] || s }
|
||||
|
||||
function formatDate(date: string) {
|
||||
if (!date) return '-'
|
||||
const d = new Date(date)
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const hours = String(d.getHours()).padStart(2, '0')
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
function handlePageChange() {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
async function fetchTickets() {
|
||||
const res: any = await adminApi.getTickets()
|
||||
tickets.value = res.data || []
|
||||
}
|
||||
|
||||
async function updateStatus(row: any) {
|
||||
await adminApi.updateTicket(row.id, { status: row.status })
|
||||
ElMessage.success('状态已更新')
|
||||
}
|
||||
|
||||
onMounted(fetchTickets)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tickets-page { padding: 0; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
|
||||
.page-title { font-size: 24px; font-weight: 600; color: #fff; }
|
||||
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
|
||||
|
||||
.pagination-wrap {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination) {
|
||||
--el-pagination-bg-color: rgba(255, 255, 255, 0.06);
|
||||
--el-pagination-text-color: rgba(255, 255, 255, 0.6);
|
||||
--el-pagination-button-disabled-bg-color: rgba(255, 255, 255, 0.04);
|
||||
--el-pagination-button-bg-color: rgba(255, 255, 255, 0.06);
|
||||
--el-pagination-hover-color: #4e6ef2;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .el-pager li) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border-radius: 6px;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .el-pager li:hover),
|
||||
.pagination-wrap :deep(.el-pagination .el-pager li.is-active) {
|
||||
background: #4e6ef2;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .btn-prev),
|
||||
.pagination-wrap :deep(.el-pagination .btn-next) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .btn-prev:hover),
|
||||
.pagination-wrap :deep(.el-pagination .btn-next:hover) {
|
||||
background: rgba(78, 110, 242, 0.2);
|
||||
color: #4e6ef2;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination__total) {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div class="users-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">用户管理</h2>
|
||||
</div>
|
||||
<div class="table-card">
|
||||
<el-table :data="paginatedUsers">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="username" label="用户名" />
|
||||
<el-table-column prop="email" label="邮箱" />
|
||||
<el-table-column prop="role" label="角色" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.role === 'admin' ? 'danger' : row.role === 'supplier' ? 'warning' : 'info'">
|
||||
{{ row.role === 'admin' ? '管理员' : row.role === 'supplier' ? '供应商' : '用户' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="purchase_credits" label="积分" width="100" />
|
||||
<el-table-column prop="created_at" label="注册时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import api from '../../utils/request'
|
||||
|
||||
const users = ref<any[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const total = computed(() => users.value.length)
|
||||
const paginatedUsers = computed(() => {
|
||||
const start = (page.value - 1) * pageSize
|
||||
return users.value.slice(start, start + pageSize)
|
||||
})
|
||||
|
||||
function handlePageChange() {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res: any = await api.get('/admin/users')
|
||||
users.value = res.data || []
|
||||
} catch {}
|
||||
})
|
||||
|
||||
function formatDate(date: string) {
|
||||
if (!date) return '-'
|
||||
const d = new Date(date)
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const hours = String(d.getHours()).padStart(2, '0')
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.users-page { padding: 0; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
|
||||
.page-title { font-size: 24px; font-weight: 600; color: #fff; }
|
||||
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
|
||||
|
||||
.pagination-wrap {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination) {
|
||||
--el-pagination-bg-color: rgba(255, 255, 255, 0.06);
|
||||
--el-pagination-text-color: rgba(255, 255, 255, 0.6);
|
||||
--el-pagination-button-disabled-bg-color: rgba(255, 255, 255, 0.04);
|
||||
--el-pagination-button-bg-color: rgba(255, 255, 255, 0.06);
|
||||
--el-pagination-hover-color: #4e6ef2;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .el-pager li) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border-radius: 6px;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .el-pager li:hover),
|
||||
.pagination-wrap :deep(.el-pagination .el-pager li.is-active) {
|
||||
background: #4e6ef2;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .btn-prev),
|
||||
.pagination-wrap :deep(.el-pagination .btn-next) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .btn-prev:hover),
|
||||
.pagination-wrap :deep(.el-pagination .btn-next:hover) {
|
||||
background: rgba(78, 110, 242, 0.2);
|
||||
color: #4e6ef2;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination__total) {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<div class="auth-card">
|
||||
<div class="auth-logo">
|
||||
<div class="logo-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>{{ $t('auth.forgotPassword') }}</h2>
|
||||
</div>
|
||||
<el-form :model="form" :rules="rules" ref="formRef" @submit.prevent="handleSubmit">
|
||||
<el-form-item :label="$t('auth.email')" prop="email">
|
||||
<el-input v-model="form.email" type="email" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" native-type="submit" class="submit-btn">
|
||||
{{ $t('auth.resetPassword') }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="auth-links">
|
||||
<router-link to="/login">{{ $t('common.login') }}</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { authApi } from '../../api'
|
||||
|
||||
const formRef = ref()
|
||||
const loading = ref(false)
|
||||
const form = reactive({ email: '' })
|
||||
const rules = {
|
||||
email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await formRef.value?.validate()
|
||||
loading.value = true
|
||||
try {
|
||||
await authApi.forgotPassword(form)
|
||||
ElMessage.success('如果邮箱存在,验证码已发送')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.auth-page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: #2d2d44;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 16px;
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.auth-logo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #4e6ef2, #7c5cfc);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.auth-logo h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.auth-links a {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.auth-links a:hover {
|
||||
color: #4e6ef2;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<div class="auth-card">
|
||||
<div class="auth-logo">
|
||||
<div class="logo-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>{{ $t('common.login') }}</h2>
|
||||
</div>
|
||||
<el-form :model="form" :rules="rules" ref="formRef" @submit.prevent="handleLogin">
|
||||
<el-form-item :label="$t('auth.email')" prop="email">
|
||||
<el-input v-model="form.email" type="email" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('auth.password')" prop="password">
|
||||
<el-input v-model="form.password" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" native-type="submit" class="submit-btn">
|
||||
{{ $t('common.login') }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="auth-links">
|
||||
<router-link to="/forgot-password">{{ $t('auth.forgotPassword') }}</router-link>
|
||||
<router-link to="/register">{{ $t('common.register') }}</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '../../store/user'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
const formRef = ref()
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive({ email: '', password: '' })
|
||||
const rules = {
|
||||
email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
await formRef.value?.validate()
|
||||
loading.value = true
|
||||
try {
|
||||
await userStore.login(form)
|
||||
ElMessage.success(t('auth.loginSuccess'))
|
||||
const user = userStore.user
|
||||
if (user?.role === 'admin') router.push('/admin')
|
||||
else if (user?.role === 'supplier') router.push('/supplier')
|
||||
else router.push('/')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.auth-page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: #2d2d44;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 16px;
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.auth-logo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #4e6ef2, #7c5cfc);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.auth-logo h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-links {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.auth-links a {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.auth-links a:hover {
|
||||
color: #4e6ef2;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<div class="auth-card">
|
||||
<div class="auth-logo">
|
||||
<div class="logo-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>{{ $t('common.register') }}</h2>
|
||||
</div>
|
||||
<el-form :model="form" :rules="rules" ref="formRef" @submit.prevent="handleRegister">
|
||||
<el-form-item :label="$t('auth.username')" prop="username">
|
||||
<el-input v-model="form.username" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('auth.email')" prop="email">
|
||||
<el-input v-model="form.email" type="email" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('auth.password')" prop="password">
|
||||
<el-input v-model="form.password" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('auth.inviteCode')">
|
||||
<el-input v-model="form.invite_code" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" native-type="submit" class="submit-btn">
|
||||
{{ $t('common.register') }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="auth-links">
|
||||
<router-link to="/login">{{ $t('common.login') }}</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '../../store/user'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
const formRef = ref()
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive({ username: '', email: '', password: '', invite_code: '' })
|
||||
const rules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }],
|
||||
password: [{ required: true, min: 6, message: '密码至少6位', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
async function handleRegister() {
|
||||
await formRef.value?.validate()
|
||||
loading.value = true
|
||||
try {
|
||||
await userStore.register(form)
|
||||
ElMessage.success(t('auth.registerSuccess'))
|
||||
router.push('/')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.auth-page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: #2d2d44;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 16px;
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.auth-logo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #4e6ef2, #7c5cfc);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.auth-logo h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.auth-links a {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.auth-links a:hover {
|
||||
color: #4e6ef2;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,517 @@
|
||||
<template>
|
||||
<div class="home-page">
|
||||
<div class="banner-section">
|
||||
<el-carousel height="280px" :interval="5000" arrow="hover" indicator-position="outside">
|
||||
<el-carousel-item v-for="(banner, index) in banners" :key="index">
|
||||
<div class="banner-slide" :style="{ background: banner.bg }">
|
||||
<div class="banner-content">
|
||||
<h2>{{ banner.title }}</h2>
|
||||
<p>{{ banner.desc }}</p>
|
||||
<button class="banner-btn" @click="$router.push(banner.link)">{{ banner.btn }}</button>
|
||||
</div>
|
||||
<div class="banner-icon">
|
||||
<el-icon :size="80"><component :is="banner.icon" /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</el-carousel-item>
|
||||
</el-carousel>
|
||||
</div>
|
||||
|
||||
<div class="quick-actions">
|
||||
<div class="action-item" @click="$router.push('/products')">
|
||||
<div class="action-icon" style="background: rgba(78, 110, 242, 0.15); color: #4e6ef2;">
|
||||
<el-icon size="22"><Goods /></el-icon>
|
||||
</div>
|
||||
<span>商品</span>
|
||||
</div>
|
||||
<div class="action-item" @click="$router.push('/lotteries')">
|
||||
<div class="action-icon" style="background: rgba(245, 158, 11, 0.15); color: #f59e0b;">
|
||||
<el-icon size="22"><Trophy /></el-icon>
|
||||
</div>
|
||||
<span>抽奖</span>
|
||||
</div>
|
||||
<div class="action-item" @click="$router.push('/articles')">
|
||||
<div class="action-icon" style="background: rgba(16, 185, 129, 0.15); color: #10b981;">
|
||||
<el-icon size="22"><Document /></el-icon>
|
||||
</div>
|
||||
<span>资讯</span>
|
||||
</div>
|
||||
<div class="action-item" @click="$router.push('/cart')">
|
||||
<div class="action-icon" style="background: rgba(124, 92, 252, 0.15); color: #7c5cfc;">
|
||||
<el-icon size="22"><ShoppingCart /></el-icon>
|
||||
</div>
|
||||
<span>购物车</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section" v-if="lotteries.length">
|
||||
<div class="section-head">
|
||||
<h2>热门活动</h2>
|
||||
</div>
|
||||
<div class="lottery-cards">
|
||||
<div v-for="l in lotteries" :key="l.id" class="lottery-card" @click="$router.push(`/lotteries/${l.id}`)">
|
||||
<div class="lottery-icon">
|
||||
<el-icon size="20"><Trophy /></el-icon>
|
||||
</div>
|
||||
<div class="lottery-info">
|
||||
<h3>{{ l.name }}</h3>
|
||||
<p>{{ l.description }}</p>
|
||||
</div>
|
||||
<div class="lottery-right">
|
||||
<span class="lottery-status" :class="l.status === 'active' ? 'active' : ''">
|
||||
{{ l.status === 'active' ? '进行中' : '已结束' }}
|
||||
</span>
|
||||
<button class="lottery-btn">参与</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section" v-if="articles.length">
|
||||
<div class="section-head">
|
||||
<h2>最新资讯</h2>
|
||||
<span class="section-more" @click="$router.push('/articles')">查看全部 →</span>
|
||||
</div>
|
||||
<div class="article-list">
|
||||
<div v-for="a in articles" :key="a.id" class="article-item" @click="$router.push(`/articles/${a.id}`)">
|
||||
<div class="article-icon">
|
||||
<el-icon size="18"><Document /></el-icon>
|
||||
</div>
|
||||
<div class="article-info">
|
||||
<h3>{{ a.title }}</h3>
|
||||
<p>{{ a.summary || a.content?.slice(0, 60) }}...</p>
|
||||
</div>
|
||||
<span class="article-date">{{ formatDate(a.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section" v-if="categories.length">
|
||||
<div class="section-head">
|
||||
<h2>热门分类</h2>
|
||||
<span class="section-more" @click="$router.push('/products')">查看全部 →</span>
|
||||
</div>
|
||||
<div class="category-scroll">
|
||||
<div v-for="cat in categories" :key="cat.id" class="category-card" @click="goCategory(cat.id)">
|
||||
<div class="category-icon">
|
||||
<el-icon size="24"><Folder /></el-icon>
|
||||
</div>
|
||||
<div class="category-name">{{ cat.name }}</div>
|
||||
<div class="category-count">{{ cat.product_count || 0 }} 件</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, shallowRef } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Goods, Trophy, Document, ShoppingCart, Folder, Present, Star, Timer } from '@element-plus/icons-vue'
|
||||
import { categoryApi, lotteryApi, articleApi } from '../../api'
|
||||
|
||||
const router = useRouter()
|
||||
const categories = ref<any[]>([])
|
||||
const lotteries = ref<any[]>([])
|
||||
const articles = ref<any[]>([])
|
||||
|
||||
const banners = [
|
||||
{ title: '新品上市', desc: '精选优质商品,限时特惠', btn: '立即选购', link: '/products', icon: shallowRef(Present), bg: 'linear-gradient(135deg, #4e6ef2 0%, #7c5cfc 100%)' },
|
||||
{ title: '幸运抽奖', desc: '参与抽奖赢取好礼', btn: '参与活动', link: '/lotteries', icon: shallowRef(Star), bg: 'linear-gradient(135deg, #f59e0b 0%, #f97316 100%)' },
|
||||
{ title: '限时秒杀', desc: '每日精选,超值优惠', btn: '查看详情', link: '/products', icon: shallowRef(Timer), bg: 'linear-gradient(135deg, #10b981 0%, #059669 100%)' },
|
||||
]
|
||||
|
||||
function goCategory(id: number) {
|
||||
router.push({ path: '/products', query: { category_id: String(id) } })
|
||||
}
|
||||
|
||||
function formatDate(date: string) {
|
||||
if (!date) return '-'
|
||||
const d = new Date(date)
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [catRes, lotRes, artRes]: any[] = await Promise.all([
|
||||
categoryApi.list(),
|
||||
lotteryApi.list(),
|
||||
articleApi.list({ page: 1, page_size: 5 }),
|
||||
])
|
||||
categories.value = (catRes.data || []).slice(0, 6)
|
||||
lotteries.value = (lotRes.data || []).slice(0, 3)
|
||||
articles.value = (artRes.data || []).slice(0, 5)
|
||||
} catch (e) {
|
||||
console.log('加载失败:', e)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.home-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.banner-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.banner-slide {
|
||||
height: 280px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 60px;
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.banner-content h2 {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.banner-content p {
|
||||
font-size: 16px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.banner-btn {
|
||||
padding: 12px 32px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.banner-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.banner-icon {
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
background: #2d2d44;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.action-item:hover {
|
||||
background: #35355a;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-item span {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.section-head h2 {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.section-more {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.section-more:hover {
|
||||
color: #4e6ef2;
|
||||
}
|
||||
|
||||
.category-scroll {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.category-card {
|
||||
flex-shrink: 0;
|
||||
width: 140px;
|
||||
padding: 16px;
|
||||
background: #2d2d44;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.category-card:hover {
|
||||
background: #35355a;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.category-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: 0 auto 10px;
|
||||
border-radius: 12px;
|
||||
background: rgba(78, 110, 242, 0.12);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #4e6ef2;
|
||||
}
|
||||
|
||||
.category-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.category-count {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.lottery-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.lottery-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
background: #2d2d44;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.lottery-card:hover {
|
||||
background: #35355a;
|
||||
}
|
||||
|
||||
.lottery-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #4e6ef2, #7c5cfc);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lottery-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.lottery-info h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.lottery-info p {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lottery-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lottery-status {
|
||||
font-size: 12px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.lottery-status.active {
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.lottery-btn {
|
||||
padding: 6px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
background: #4e6ef2;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.lottery-btn:hover {
|
||||
background: #5a7af5;
|
||||
}
|
||||
|
||||
.article-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.article-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: #2d2d44;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.article-item:hover {
|
||||
background: #35355a;
|
||||
}
|
||||
|
||||
.article-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #10b981;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.article-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.article-info h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin-bottom: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.article-info p {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.article-date {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.el-carousel__indicators--outside) {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
:deep(.el-carousel__indicator--horizontal .el-carousel__button) {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
:deep(.el-carousel__indicator--horizontal.is-active .el-carousel__button) {
|
||||
background: #4e6ef2;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.banner-slide {
|
||||
height: 200px;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.banner-content h2 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.banner-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
flex: 1 1 45%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div class="dashboard-page">
|
||||
<h2 class="page-title">{{ $t('supplier.dashboard') }}</h2>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon pending"><el-icon><Clock /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<p class="stat-value">{{ stats.pending }}</p>
|
||||
<p class="stat-label">待处理订单</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon total"><el-icon><List /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<p class="stat-value">{{ stats.total }}</p>
|
||||
<p class="stat-label">总订单数</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon products"><el-icon><Goods /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<p class="stat-value">{{ stats.products }}</p>
|
||||
<p class="stat-label">商品数量</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Clock, List, Goods } from '@element-plus/icons-vue'
|
||||
import { supplierApi } from '../../api'
|
||||
|
||||
const stats = ref({ pending: '--', total: '--', products: '--' })
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res: any = await supplierApi.getOrders()
|
||||
const orders = res.data || []
|
||||
stats.value.pending = orders.filter((o: any) => o.status === 'pending_confirm').length
|
||||
stats.value.total = orders.length
|
||||
} catch {}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dashboard-page { padding: 0; }
|
||||
.page-title { font-size: 24px; font-weight: 600; color: #fff; margin-bottom: 24px; }
|
||||
.stat-card {
|
||||
background: #2d2d44;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
}
|
||||
.stat-icon.pending { background: rgba(245, 158, 11, 0.15); color: #f59e0b; }
|
||||
.stat-icon.total { background: rgba(78, 110, 242, 0.15); color: #4e6ef2; }
|
||||
.stat-icon.products { background: rgba(16, 185, 129, 0.15); color: #10b981; }
|
||||
.stat-info { flex: 1; }
|
||||
.stat-value { font-size: 28px; font-weight: 700; color: #fff; }
|
||||
.stat-label { font-size: 14px; color: rgba(255, 255, 255, 0.5); margin-top: 4px; }
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="inventory-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">{{ $t('supplier.inventoryManagement') }}</h2>
|
||||
</div>
|
||||
<div class="table-card">
|
||||
<el-table :data="inventory">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="product.name" :label="$t('product.name')" />
|
||||
<el-table-column prop="quantity" :label="$t('product.stock')" width="120" />
|
||||
<el-table-column :label="$t('common.edit')" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.newQty" :min="0" size="small" style="width: 100px" />
|
||||
<el-button link type="primary" size="small" @click="updateQty(row)" style="margin-left: 8px">{{ $t('common.save') }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { supplierApi } from '../../api'
|
||||
|
||||
const inventory = ref<any[]>([])
|
||||
|
||||
async function fetchInventory() {
|
||||
const res: any = await supplierApi.getInventory()
|
||||
inventory.value = (res.data || []).map((i: any) => ({ ...i, newQty: i.quantity }))
|
||||
}
|
||||
|
||||
async function updateQty(row: any) {
|
||||
await supplierApi.updateInventory(row.id, { quantity: row.newQty })
|
||||
ElMessage.success('Updated')
|
||||
await fetchInventory()
|
||||
}
|
||||
|
||||
onMounted(fetchInventory)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.inventory-page { padding: 0; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
|
||||
.page-title { font-size: 24px; font-weight: 600; color: #fff; }
|
||||
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
|
||||
</style>
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="orders-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">{{ $t('supplier.orderManagement') }}</h2>
|
||||
</div>
|
||||
<div class="table-card">
|
||||
<el-table :data="orders">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="total_amount" label="Amount" width="100"><template #default="{ row }">¥{{ row.total_amount }}</template></el-table-column>
|
||||
<el-table-column prop="status" label="Status" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Created" width="180">
|
||||
<template #default="{ row }">{{ formatDate(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Actions" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status === 'pending_confirm'" link type="primary" size="small" @click="confirmOrder(row.id)">{{ $t('supplier.confirmOrder') }}</el-button>
|
||||
<el-button v-if="row.status === 'pending_ship'" link type="success" size="small" @click="openShip(row)">{{ $t('supplier.shipOrder') }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<el-dialog v-model="showShip" :title="$t('supplier.shipOrder')" width="500px">
|
||||
<el-form :model="shipForm" label-width="140px">
|
||||
<el-form-item :label="$t('order.trackingNumber')"><el-input v-model="shipForm.tracking_number" /></el-form-item>
|
||||
<el-form-item :label="$t('supplier.shippingPhoto')"><el-input v-model="shipForm.shipping_photo" placeholder="URL" /></el-form-item>
|
||||
<el-form-item :label="$t('supplier.expressPhoto')"><el-input v-model="shipForm.express_photo" placeholder="URL" /></el-form-item>
|
||||
<el-form-item :label="$t('supplier.customsPhoto')"><el-input v-model="shipForm.customs_photo" placeholder="URL" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showShip = false">{{ $t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" @click="shipOrder">{{ $t('supplier.shipOrder') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { supplierApi } from '../../api'
|
||||
|
||||
const orders = ref<any[]>([])
|
||||
const showShip = ref(false)
|
||||
const shipOrderId = ref(0)
|
||||
const shipForm = reactive({ tracking_number: '', shipping_photo: '', express_photo: '', customs_photo: '' })
|
||||
|
||||
const statusMap: Record<string, string> = {
|
||||
pending_payment: '待支付', pending_confirm: '待确认', pending_ship: '待发货',
|
||||
shipped: '已发货', completed: '已完成', refunding: '退款中', refunded: '已退款', cancelled: '已取消',
|
||||
}
|
||||
const statusTypeMap: Record<string, string> = {
|
||||
pending_payment: 'warning', pending_confirm: 'info', pending_ship: 'info',
|
||||
shipped: 'primary', completed: 'success', refunding: 'danger', refunded: 'danger', cancelled: 'info',
|
||||
}
|
||||
function statusText(s: string) { return statusMap[s] || s }
|
||||
function statusType(s: string) { return statusTypeMap[s] || 'info' }
|
||||
|
||||
function formatDate(date: string) {
|
||||
if (!date) return '-'
|
||||
const d = new Date(date)
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const hours = String(d.getHours()).padStart(2, '0')
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
async function fetchOrders() {
|
||||
const res: any = await supplierApi.getOrders()
|
||||
orders.value = res.data || []
|
||||
}
|
||||
|
||||
async function confirmOrder(id: number) {
|
||||
await supplierApi.confirmOrder(id)
|
||||
ElMessage.success('Order confirmed')
|
||||
await fetchOrders()
|
||||
}
|
||||
|
||||
function openShip(row: any) {
|
||||
shipOrderId.value = row.id
|
||||
showShip.value = true
|
||||
}
|
||||
|
||||
async function shipOrder() {
|
||||
if (!shipForm.tracking_number.trim()) {
|
||||
ElMessage.warning('请输入快递单号')
|
||||
return
|
||||
}
|
||||
await supplierApi.shipOrder(shipOrderId.value, shipForm)
|
||||
ElMessage.success('Order shipped')
|
||||
showShip.value = false
|
||||
Object.assign(shipForm, { tracking_number: '', shipping_photo: '', express_photo: '', customs_photo: '' })
|
||||
await fetchOrders()
|
||||
}
|
||||
|
||||
onMounted(fetchOrders)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.orders-page { padding: 0; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
|
||||
.page-title { font-size: 24px; font-weight: 600; color: #fff; }
|
||||
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
|
||||
</style>
|
||||
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div class="addresses-page">
|
||||
<BackNav />
|
||||
<div class="page-header">
|
||||
<el-button type="primary" @click="showAdd = true">{{ $t('address.addAddress') }}</el-button>
|
||||
</div>
|
||||
<div v-for="a in addresses" :key="a.id" class="address-card">
|
||||
<div class="address-info">
|
||||
<strong>{{ a.name }}</strong> {{ a.phone }}
|
||||
<p>{{ a.province }}{{ a.city }}{{ a.district }}{{ a.address }}</p>
|
||||
</div>
|
||||
<div class="address-actions">
|
||||
<el-tag v-if="a.is_default" type="success" size="small">默认</el-tag>
|
||||
<el-button v-else link size="small" @click="setDefault(a.id)">{{ $t('address.setDefault') }}</el-button>
|
||||
<el-button link type="danger" size="small" @click="deleteAddr(a.id)">{{ $t('common.delete') }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-dialog v-model="showAdd" :title="$t('address.addAddress')" width="500px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item :label="$t('address.name')"><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item :label="$t('address.phone')"><el-input v-model="form.phone" /></el-form-item>
|
||||
<el-form-item :label="$t('address.province')"><el-input v-model="form.province" /></el-form-item>
|
||||
<el-form-item :label="$t('address.city')"><el-input v-model="form.city" /></el-form-item>
|
||||
<el-form-item :label="$t('address.district')"><el-input v-model="form.district" /></el-form-item>
|
||||
<el-form-item :label="$t('address.detail')"><el-input v-model="form.address" type="textarea" /></el-form-item>
|
||||
<el-form-item><el-switch v-model="form.is_default" active-text="默认地址" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showAdd = false">{{ $t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" @click="addAddress">{{ $t('common.save') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { addressApi } from '../../api'
|
||||
import BackNav from '../../components/BackNav.vue'
|
||||
|
||||
const addresses = ref<any[]>([])
|
||||
const showAdd = ref(false)
|
||||
const form = reactive({ name: '', phone: '', province: '', city: '', district: '', address: '', is_default: false })
|
||||
|
||||
async function fetchAddresses() {
|
||||
const res: any = await addressApi.list()
|
||||
addresses.value = res.data || []
|
||||
}
|
||||
|
||||
async function addAddress() {
|
||||
await addressApi.create(form)
|
||||
ElMessage.success('地址已添加')
|
||||
showAdd.value = false
|
||||
Object.assign(form, { name: '', phone: '', province: '', city: '', district: '', address: '', is_default: false })
|
||||
await fetchAddresses()
|
||||
}
|
||||
|
||||
async function setDefault(id: number) {
|
||||
await addressApi.setDefault(id)
|
||||
await fetchAddresses()
|
||||
}
|
||||
|
||||
async function deleteAddr(id: number) {
|
||||
await addressApi.delete(id)
|
||||
ElMessage.success('地址已删除')
|
||||
await fetchAddresses()
|
||||
}
|
||||
|
||||
onMounted(fetchAddresses)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.addresses-page { padding: 0; }
|
||||
.page-header { display: flex; justify-content: flex-end; align-items: center; margin-bottom: 24px; }
|
||||
.address-card {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; margin-bottom: 12px;
|
||||
}
|
||||
.address-info strong { color: #fff; margin-right: 8px; }
|
||||
.address-info p { color: rgba(255, 255, 255, 0.5); margin-top: 4px; font-size: 14px; }
|
||||
.address-actions { display: flex; align-items: center; gap: 8px; }
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="article-detail-page">
|
||||
<BackNav />
|
||||
<div v-if="article" class="detail-card">
|
||||
<div class="article-meta">
|
||||
<el-tag v-if="article.is_pinned" type="danger" size="small">置顶</el-tag>
|
||||
<span class="article-time">{{ formatDate(article.created_at) }}</span>
|
||||
</div>
|
||||
<h1 class="article-title">{{ article.title }}</h1>
|
||||
<div class="article-content" v-html="article.content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { articleApi } from '../../api'
|
||||
import BackNav from '../../components/BackNav.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const article = ref<any>(null)
|
||||
|
||||
function formatDate(date: string) {
|
||||
if (!date) return '-'
|
||||
const d = new Date(date)
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const hours = String(d.getHours()).padStart(2, '0')
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const res: any = await articleApi.getById(Number(route.params.id))
|
||||
article.value = res.data
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.article-detail-page { padding: 0; }
|
||||
.article-meta { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
|
||||
.article-time { color: rgba(255, 255, 255, 0.4); font-size: 14px; }
|
||||
.article-title { font-size: 28px; font-weight: 700; color: #fff; margin-bottom: 24px; line-height: 1.4; }
|
||||
.detail-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 24px; }
|
||||
.article-content { font-size: 16px; line-height: 1.8; color: rgba(255, 255, 255, 0.8); }
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="articles-page">
|
||||
<BackNav />
|
||||
<div class="article-list">
|
||||
<div v-for="a in articles" :key="a.id" class="article-card" @click="$router.push(`/articles/${a.id}`)">
|
||||
<div class="article-cover" v-if="a.cover_image">
|
||||
<img :src="getImageUrl(a.cover_image)" alt="" />
|
||||
</div>
|
||||
<div class="article-body">
|
||||
<div class="article-header">
|
||||
<el-tag v-if="a.is_pinned" type="danger" size="small">置顶</el-tag>
|
||||
<h3>{{ a.title }}</h3>
|
||||
</div>
|
||||
<p class="article-summary">{{ a.summary || a.content?.slice(0, 100) }}</p>
|
||||
<span class="article-time">{{ formatDate(a.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-if="!articles.length && !loading" />
|
||||
<div class="pagination-wrap" v-if="total > pageSize">
|
||||
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next" @current-change="fetchArticles" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { articleApi } from '../../api'
|
||||
import { getImageUrl } from '../../utils/image'
|
||||
import BackNav from '../../components/BackNav.vue'
|
||||
|
||||
const articles = ref<any[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = 10
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchArticles() {
|
||||
loading.value = true
|
||||
const res: any = await articleApi.list({ page: page.value, page_size: pageSize })
|
||||
articles.value = res.data || []
|
||||
total.value = res.pagination?.total || 0
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function formatDate(date: string) {
|
||||
if (!date) return '-'
|
||||
const d = new Date(date)
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const hours = String(d.getHours()).padStart(2, '0')
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
onMounted(fetchArticles)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.articles-page { padding: 0; }
|
||||
.article-list { display: flex; flex-direction: column; gap: 16px; }
|
||||
.article-card {
|
||||
background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; overflow: hidden; cursor: pointer; transition: all 0.3s; display: flex;
|
||||
&:hover { transform: translateY(-2px); border-color: rgba(78, 110, 242, 0.3); }
|
||||
}
|
||||
.article-cover { width: 200px; min-height: 140px; background: rgba(255, 255, 255, 0.04); img { width: 100%; height: 100%; object-fit: cover; } }
|
||||
.article-body { flex: 1; padding: 20px; }
|
||||
.article-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; h3 { font-size: 18px; font-weight: 600; color: #fff; } }
|
||||
.article-summary { color: rgba(255, 255, 255, 0.5); font-size: 14px; line-height: 1.6; margin-bottom: 8px; }
|
||||
.article-time { color: rgba(255, 255, 255, 0.3); font-size: 13px; }
|
||||
.pagination-wrap {
|
||||
margin-top: 32px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination) {
|
||||
--el-pagination-bg-color: rgba(255, 255, 255, 0.06);
|
||||
--el-pagination-text-color: rgba(255, 255, 255, 0.6);
|
||||
--el-pagination-button-disabled-bg-color: rgba(255, 255, 255, 0.04);
|
||||
--el-pagination-button-bg-color: rgba(255, 255, 255, 0.06);
|
||||
--el-pagination-hover-color: #4e6ef2;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .el-pager li) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border-radius: 6px;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .el-pager li:hover),
|
||||
.pagination-wrap :deep(.el-pagination .el-pager li.is-active) {
|
||||
background: #4e6ef2;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .btn-prev),
|
||||
.pagination-wrap :deep(.el-pagination .btn-next) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .btn-prev:hover),
|
||||
.pagination-wrap :deep(.el-pagination .btn-next:hover) {
|
||||
background: rgba(78, 110, 242, 0.2);
|
||||
color: #4e6ef2;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,959 @@
|
||||
<template>
|
||||
<div class="cart-page">
|
||||
<BackNav />
|
||||
|
||||
<el-empty v-if="!items.length" description="购物车是空的" :image-size="120">
|
||||
<template #image>
|
||||
<el-icon :size="80" color="rgba(255,255,255,0.2)"><ShoppingCart /></el-icon>
|
||||
</template>
|
||||
<el-button type="primary" @click="$router.push('/products')">去购物</el-button>
|
||||
</el-empty>
|
||||
|
||||
<div v-else class="cart-container">
|
||||
<div class="cart-left">
|
||||
<div class="cart-main">
|
||||
<div class="cart-header">
|
||||
<el-checkbox v-model="selectAll" @change="handleSelectAll">全选</el-checkbox>
|
||||
<span class="header-product">商品信息</span>
|
||||
<span class="header-price">单价</span>
|
||||
<span class="header-quantity">数量</span>
|
||||
<span class="header-subtotal">小计</span>
|
||||
<span class="header-action">操作</span>
|
||||
</div>
|
||||
|
||||
<div class="cart-list">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="cart-item"
|
||||
:class="{ 'item-disabled': !item.product?.is_active }"
|
||||
>
|
||||
<el-checkbox v-model="selectedIds" :value="item.id" />
|
||||
|
||||
<div class="item-product" @click="$router.push(`/products/${item.product?.id}`)">
|
||||
<el-image
|
||||
:src="getFirstImage(item.product?.images)"
|
||||
fit="cover"
|
||||
class="item-image"
|
||||
>
|
||||
<template #error>
|
||||
<div class="image-placeholder">
|
||||
<el-icon><Picture /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
<div class="item-info">
|
||||
<h4 class="item-name">{{ item.product?.name }}</h4>
|
||||
<div class="item-tags">
|
||||
<el-tag v-if="item.product?.require_credit" size="small" type="warning">
|
||||
需要资格: {{ item.product?.credit_cost }}
|
||||
</el-tag>
|
||||
<el-tag v-if="item.product?.credit_reward > 0" size="small" type="success">
|
||||
奖励: +{{ item.product?.credit_reward }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<p v-if="!item.product?.is_active" class="item-off">已下架</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item-price">
|
||||
<span class="price-current">¥{{ item.product?.price }}</span>
|
||||
</div>
|
||||
|
||||
<div class="item-quantity">
|
||||
<el-input-number
|
||||
v-model="item.quantity"
|
||||
:min="1"
|
||||
:max="getMaxQuantity(item.product)"
|
||||
size="small"
|
||||
@change="(val: number) => updateQuantity(item, val)"
|
||||
/>
|
||||
<span v-if="item.product?.stock !== undefined && item.product?.stock <= 10" class="stock-tip">
|
||||
仅剩 {{ item.product?.stock }} 件
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="item-subtotal">
|
||||
<span class="subtotal-price">¥{{ ((item.product?.price || 0) * item.quantity).toFixed(2) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="item-action">
|
||||
<el-button link type="danger" size="small" @click="removeItem(item.id)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checkout-section">
|
||||
<h3 class="section-title">
|
||||
<el-icon><Location /></el-icon>
|
||||
收货地址
|
||||
</h3>
|
||||
<div class="address-list" v-if="addresses.length">
|
||||
<div
|
||||
v-for="addr in addresses"
|
||||
:key="addr.id"
|
||||
class="address-item"
|
||||
:class="{ active: selectedAddressId === addr.id }"
|
||||
@click="selectedAddressId = addr.id"
|
||||
>
|
||||
<div class="address-radio">
|
||||
<el-icon v-if="selectedAddressId === addr.id" class="checked"><CircleCheckFilled /></el-icon>
|
||||
<span v-else class="unchecked"></span>
|
||||
</div>
|
||||
<div class="address-content">
|
||||
<div class="address-header">
|
||||
<span class="address-name">{{ addr.name }}</span>
|
||||
<span class="address-phone">{{ addr.phone }}</span>
|
||||
<el-tag v-if="addr.is_default" size="small" type="success">默认</el-tag>
|
||||
</div>
|
||||
<div class="address-detail">
|
||||
{{ addr.province }}{{ addr.city }}{{ addr.district }}{{ addr.address }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="no-address" v-else>
|
||||
<p>您还没有添加收货地址</p>
|
||||
<el-button type="primary" @click="showAddAddress = true">添加地址</el-button>
|
||||
</div>
|
||||
<el-button class="add-address-btn" link type="primary" @click="showAddAddress = true" v-if="addresses.length">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加新地址
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="checkout-section" v-if="availablePayments.length > 0">
|
||||
<h3 class="section-title">
|
||||
<el-icon><CreditCard /></el-icon>
|
||||
支付方式
|
||||
</h3>
|
||||
<div class="payment-methods" :class="{ 'single': availablePayments.length === 1 }">
|
||||
<div
|
||||
v-for="method in availablePayments"
|
||||
:key="method.value"
|
||||
class="payment-item"
|
||||
:class="{ active: selectedPayment === method.value }"
|
||||
@click="selectedPayment = method.value"
|
||||
>
|
||||
<el-icon class="payment-icon"><component :is="method.icon" /></el-icon>
|
||||
<span class="payment-name">{{ method.label }}</span>
|
||||
<el-icon v-if="selectedPayment === method.value" class="check-icon"><CircleCheckFilled /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cart-summary-panel">
|
||||
<h3 class="summary-title">订单摘要</h3>
|
||||
|
||||
<div class="summary-row">
|
||||
<span>商品金额</span>
|
||||
<span>¥{{ selectedTotal.toFixed(2) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="summary-row">
|
||||
<span>运费</span>
|
||||
<span v-if="shippingFee > 0">¥{{ shippingFee.toFixed(2) }}</span>
|
||||
<span v-else class="free-shipping">免运费</span>
|
||||
</div>
|
||||
|
||||
<div class="summary-row" v-if="serviceFee > 0">
|
||||
<span>服务费 ({{ settings.service_fee_rate || 0 }}%)</span>
|
||||
<span>¥{{ serviceFee.toFixed(2) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="summary-row" v-if="taxFee > 0">
|
||||
<span>税费 ({{ settings.tax_rate || 0 }}%)</span>
|
||||
<span>¥{{ taxFee.toFixed(2) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="summary-divider"></div>
|
||||
|
||||
<div class="summary-row summary-total">
|
||||
<span>应付总额</span>
|
||||
<span class="total-amount">¥{{ grandTotal.toFixed(2) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="summary-count">
|
||||
已选择 <strong>{{ selectedIds.length }}</strong> 件商品
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="checkout-btn"
|
||||
:disabled="!canCheckout"
|
||||
:loading="submitting"
|
||||
@click="handleCheckout"
|
||||
>
|
||||
提交订单
|
||||
</el-button>
|
||||
|
||||
<div class="summary-actions">
|
||||
<el-checkbox v-model="selectAll" @change="handleSelectAll">全选</el-checkbox>
|
||||
<el-button link type="danger" size="small" @click="removeSelected" :disabled="!selectedIds.length">
|
||||
删除选中
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="showAddAddress" title="添加收货地址" width="500px">
|
||||
<el-form :model="addressForm" label-width="100px">
|
||||
<el-form-item label="收货人" required><el-input v-model="addressForm.name" placeholder="请输入收货人姓名" /></el-form-item>
|
||||
<el-form-item label="手机号" required><el-input v-model="addressForm.phone" placeholder="请输入手机号" /></el-form-item>
|
||||
<el-form-item label="省份"><el-input v-model="addressForm.province" placeholder="省/直辖市" /></el-form-item>
|
||||
<el-form-item label="城市"><el-input v-model="addressForm.city" placeholder="市/区" /></el-form-item>
|
||||
<el-form-item label="区县"><el-input v-model="addressForm.district" placeholder="区/县" /></el-form-item>
|
||||
<el-form-item label="详细地址" required><el-input v-model="addressForm.address" type="textarea" :rows="2" placeholder="街道、门牌号等" /></el-form-item>
|
||||
<el-form-item><el-switch v-model="addressForm.is_default" active-text="设为默认地址" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showAddAddress = false">取消</el-button>
|
||||
<el-button type="primary" @click="addAddress">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch, onMounted, markRaw } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
ShoppingCart, Picture, Location, CreditCard, Plus, CircleCheckFilled,
|
||||
Wallet, Coin, ChatDotRound
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useCartStore } from '../../store/cart'
|
||||
import { systemApi, addressApi, orderApi } from '../../api'
|
||||
import { getFirstImage } from '../../utils/image'
|
||||
import BackNav from '../../components/BackNav.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const cartStore = useCartStore()
|
||||
|
||||
const items = computed(() => cartStore.items)
|
||||
const selectedIds = ref<number[]>([])
|
||||
const settings = ref<Record<string, string>>({})
|
||||
const addresses = ref<any[]>([])
|
||||
const selectedAddressId = ref<number>()
|
||||
const selectedPayment = ref('')
|
||||
const showAddAddress = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
const addressForm = reactive({
|
||||
name: '',
|
||||
phone: '',
|
||||
province: '',
|
||||
city: '',
|
||||
district: '',
|
||||
address: '',
|
||||
is_default: false
|
||||
})
|
||||
|
||||
const allPaymentMethods = [
|
||||
{ value: 'balance', label: '余额支付', icon: markRaw(Wallet) },
|
||||
{ value: 'alipay', label: '支付宝', icon: markRaw(Coin) },
|
||||
{ value: 'wechat', label: '微信支付', icon: markRaw(ChatDotRound) },
|
||||
]
|
||||
|
||||
const availablePayments = computed(() => {
|
||||
const enabledStr = settings.value.enabled_payments
|
||||
if (!enabledStr) return [allPaymentMethods[0]]
|
||||
try {
|
||||
const enabled = JSON.parse(enabledStr)
|
||||
return allPaymentMethods.filter(m => enabled.includes(m.value))
|
||||
} catch {
|
||||
return [allPaymentMethods[0]]
|
||||
}
|
||||
})
|
||||
|
||||
const selectAll = computed({
|
||||
get: () => {
|
||||
const activeItems = items.value.filter(i => i.product?.is_active)
|
||||
return activeItems.length > 0 && selectedIds.value.length === activeItems.length
|
||||
},
|
||||
set: () => {}
|
||||
})
|
||||
|
||||
const selectedTotal = computed(() => {
|
||||
return items.value
|
||||
.filter(item => selectedIds.value.includes(item.id))
|
||||
.reduce((sum, item) => sum + (item.product?.price || 0) * item.quantity, 0)
|
||||
})
|
||||
|
||||
const selectedQuantity = computed(() => {
|
||||
return items.value
|
||||
.filter(item => selectedIds.value.includes(item.id))
|
||||
.reduce((sum, item) => sum + item.quantity, 0)
|
||||
})
|
||||
|
||||
const shippingFee = computed(() => {
|
||||
const firstWeight = parseFloat(settings.value.shipping_fee_first_weight || '0')
|
||||
const perGram = parseFloat(settings.value.shipping_fee_per_gram || '0')
|
||||
if (selectedTotal.value >= 99 || firstWeight === 0) return 0
|
||||
return firstWeight + perGram * Math.max(0, selectedQuantity.value - 500)
|
||||
})
|
||||
|
||||
const serviceFee = computed(() => {
|
||||
const rate = parseFloat(settings.value.service_fee_rate || '0')
|
||||
return selectedTotal.value * rate / 100
|
||||
})
|
||||
|
||||
const taxFee = computed(() => {
|
||||
const rate = parseFloat(settings.value.tax_rate || '0')
|
||||
return selectedTotal.value * rate / 100
|
||||
})
|
||||
|
||||
const grandTotal = computed(() => {
|
||||
return selectedTotal.value + shippingFee.value + serviceFee.value + taxFee.value
|
||||
})
|
||||
|
||||
const canCheckout = computed(() => {
|
||||
if (selectedIds.value.length === 0) return false
|
||||
if (!selectedAddressId.value) return false
|
||||
const selectedItems = items.value.filter(item => selectedIds.value.includes(item.id))
|
||||
return selectedItems.every(item => item.product?.is_active)
|
||||
})
|
||||
|
||||
async function fetchSettings() {
|
||||
try {
|
||||
const res: any = await systemApi.getPublicSettings()
|
||||
settings.value = res.data || {}
|
||||
if (availablePayments.value.length > 0 && !selectedPayment.value) {
|
||||
selectedPayment.value = availablePayments.value[0].value
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function fetchAddresses() {
|
||||
try {
|
||||
const res: any = await addressApi.list()
|
||||
addresses.value = res.data || []
|
||||
const defaultAddr = addresses.value.find((a: any) => a.is_default)
|
||||
if (defaultAddr && !selectedAddressId.value) {
|
||||
selectedAddressId.value = defaultAddr.id
|
||||
} else if (addresses.value.length > 0 && !selectedAddressId.value) {
|
||||
selectedAddressId.value = addresses.value[0].id
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function addAddress() {
|
||||
if (!addressForm.name || !addressForm.phone || !addressForm.address) {
|
||||
ElMessage.warning('请填写必要信息')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await addressApi.create(addressForm)
|
||||
ElMessage.success('地址已添加')
|
||||
showAddAddress.value = false
|
||||
Object.assign(addressForm, { name: '', phone: '', province: '', city: '', district: '', address: '', is_default: false })
|
||||
await fetchAddresses()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function handleSelectAll(val: boolean) {
|
||||
if (val) {
|
||||
selectedIds.value = items.value.filter(i => i.product?.is_active).map(i => i.id)
|
||||
} else {
|
||||
selectedIds.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function getMaxQuantity(product: any) {
|
||||
if (!product) return 99
|
||||
let max = 999
|
||||
if (product.stock !== undefined && product.stock !== null) {
|
||||
max = product.stock
|
||||
}
|
||||
if (product.max_purchase && product.max_purchase > 0) {
|
||||
max = Math.min(max, product.max_purchase)
|
||||
}
|
||||
return Math.max(1, max)
|
||||
}
|
||||
|
||||
async function updateQuantity(item: any, quantity: number) {
|
||||
if (quantity < 1) return
|
||||
await cartStore.updateItem(item.id, quantity)
|
||||
}
|
||||
|
||||
async function removeItem(id: number) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该商品吗?', '提示', { type: 'warning' })
|
||||
await cartStore.removeItem(id)
|
||||
selectedIds.value = selectedIds.value.filter(i => i !== id)
|
||||
ElMessage.success('已删除')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function removeSelected() {
|
||||
if (!selectedIds.value.length) return
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除选中的 ${selectedIds.value.length} 件商品吗?`, '提示', { type: 'warning' })
|
||||
await cartStore.removeItems(selectedIds.value)
|
||||
selectedIds.value = []
|
||||
ElMessage.success('已删除')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function handleCheckout() {
|
||||
if (!canCheckout.value) {
|
||||
if (!selectedAddressId.value) {
|
||||
ElMessage.warning('请选择收货地址')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const unavailableItems = items.value
|
||||
.filter(item => selectedIds.value.includes(item.id) && !item.product?.is_active)
|
||||
|
||||
if (unavailableItems.length > 0) {
|
||||
ElMessage.warning('请移除已下架的商品')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await orderApi.create({
|
||||
shipping_address_id: selectedAddressId.value,
|
||||
payment_method: selectedPayment.value
|
||||
})
|
||||
ElMessage.success('订单创建成功')
|
||||
await cartStore.fetchCart()
|
||||
router.push('/orders')
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.response?.data?.error || '订单创建失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(items, () => {
|
||||
selectedIds.value = selectedIds.value.filter(id =>
|
||||
items.value.some(item => item.id === id)
|
||||
)
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
cartStore.fetchCart()
|
||||
fetchSettings()
|
||||
fetchAddresses()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.cart-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.cart-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 360px;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.cart-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.cart-main {
|
||||
background: #2d2d44;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cart-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px 20px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
|
||||
:deep(.el-checkbox) {
|
||||
width: 50px;
|
||||
flex-shrink: 0;
|
||||
margin-right: 0;
|
||||
|
||||
.el-checkbox__label {
|
||||
font-size: 13px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-product { flex: 1; min-width: 200px; padding-left: 12px; }
|
||||
.header-price { width: 80px; text-align: center; flex-shrink: 0; }
|
||||
.header-quantity { width: 120px; text-align: center; flex-shrink: 0; }
|
||||
.header-subtotal { width: 90px; text-align: center; flex-shrink: 0; }
|
||||
.header-action { width: 60px; text-align: center; flex-shrink: 0; }
|
||||
}
|
||||
|
||||
.cart-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.cart-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&.item-disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
> .el-checkbox { width: 50px; flex-shrink: 0; }
|
||||
> .item-product { flex: 1; min-width: 200px; padding-left: 12px; }
|
||||
> .item-price { width: 80px; text-align: center; flex-shrink: 0; }
|
||||
> .item-quantity { width: 120px; display: flex; flex-direction: column; align-items: center; gap: 4px; flex-shrink: 0; }
|
||||
> .item-subtotal { width: 90px; text-align: center; flex-shrink: 0; }
|
||||
> .item-action { width: 60px; display: flex; justify-content: center; flex-shrink: 0; }
|
||||
}
|
||||
|
||||
.item-product {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.item-image {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.image-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.item-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.item-tags {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.item-off {
|
||||
color: #f56c6c;
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.item-price {
|
||||
text-align: center;
|
||||
width: 80px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.price-current {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.item-quantity {
|
||||
width: 120px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.stock-tip {
|
||||
font-size: 11px;
|
||||
color: #f59e0b;
|
||||
}
|
||||
}
|
||||
|
||||
.item-subtotal {
|
||||
text-align: center;
|
||||
width: 90px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.subtotal-price {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #4e6ef2;
|
||||
}
|
||||
}
|
||||
|
||||
.item-action {
|
||||
width: 60px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.checkout-section {
|
||||
background: #2d2d44;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin: 0 0 16px 0;
|
||||
|
||||
.el-icon {
|
||||
color: #4e6ef2;
|
||||
}
|
||||
}
|
||||
|
||||
.address-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.address-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(78, 110, 242, 0.3);
|
||||
background: rgba(78, 110, 242, 0.05);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: #4e6ef2;
|
||||
background: rgba(78, 110, 242, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.address-radio {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding-top: 2px;
|
||||
|
||||
.checked {
|
||||
color: #4e6ef2;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.unchecked {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.address-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.address-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.address-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.address-phone {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.address-detail {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.no-address {
|
||||
text-align: center;
|
||||
padding: 30px 0;
|
||||
|
||||
p {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.add-address-btn {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.payment-methods {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
|
||||
&.single {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.payment-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 12px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(78, 110, 242, 0.3);
|
||||
background: rgba(78, 110, 242, 0.05);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: #4e6ef2;
|
||||
background: rgba(78, 110, 242, 0.1);
|
||||
}
|
||||
|
||||
.payment-icon {
|
||||
font-size: 28px;
|
||||
color: #4e6ef2;
|
||||
}
|
||||
|
||||
.payment-name {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
color: #4e6ef2;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.cart-summary-panel {
|
||||
background: #2d2d44;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin: 0 0 20px 0;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
|
||||
span:last-child {
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.free-shipping {
|
||||
color: #10b981 !important;
|
||||
}
|
||||
|
||||
.summary-divider {
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.summary-total {
|
||||
margin-bottom: 16px;
|
||||
|
||||
span:first-child {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.total-amount {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #f56c6c;
|
||||
}
|
||||
}
|
||||
|
||||
.summary-count {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-bottom: 16px;
|
||||
|
||||
strong {
|
||||
color: #4e6ef2;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.checkout-btn {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, #4e6ef2, #7c5cfc);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.summary-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__label) {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
|
||||
background: #4e6ef2;
|
||||
border-color: #4e6ef2;
|
||||
}
|
||||
|
||||
:deep(.el-input-number) {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
:deep(.el-input-number .el-input__wrapper) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:deep(.el-input-number .el-input__inner) {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.cart-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cart-summary-panel {
|
||||
position: static;
|
||||
order: -1;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.cart-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cart-item {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
|
||||
> .el-checkbox { width: 24px; order: 1; }
|
||||
> .item-product { flex: 1; min-width: 0; order: 2; }
|
||||
> .item-price { width: auto; order: 4; margin-left: 36px; }
|
||||
> .item-quantity { width: auto; order: 5; }
|
||||
> .item-subtotal { width: auto; order: 3; margin-left: auto; }
|
||||
> .item-action { width: auto; order: 6; }
|
||||
}
|
||||
|
||||
.item-product {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.item-image {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
.payment-methods {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cart-left {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.checkout-section {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div class="create-ticket-page">
|
||||
<BackNav />
|
||||
<div class="form-card">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item :label="$t('ticket.subject')"><el-input v-model="form.title" /></el-form-item>
|
||||
<el-form-item :label="$t('ticket.category')">
|
||||
<el-select v-model="form.category" popper-class="dark-select-dropdown">
|
||||
<el-option value="order" label="订单" />
|
||||
<el-option value="account" label="账户" />
|
||||
<el-option value="product" label="商品" />
|
||||
<el-option value="other" label="其他" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('ticket.content')"><el-input v-model="form.content" type="textarea" :rows="6" /></el-form-item>
|
||||
<el-form-item><el-button type="primary" @click="submit">{{ $t('common.submit') }}</el-button></el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ticketApi } from '../../api'
|
||||
import BackNav from '../../components/BackNav.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const form = reactive({ title: '', content: '', category: 'other' })
|
||||
|
||||
async function submit() {
|
||||
await ticketApi.create(form)
|
||||
ElMessage.success('工单已提交')
|
||||
router.push('/tickets')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.create-ticket-page { padding: 0; }
|
||||
.form-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 24px; }
|
||||
</style>
|
||||
@@ -0,0 +1,218 @@
|
||||
<template>
|
||||
<div class="home-page">
|
||||
<section class="hero">
|
||||
<div class="hero-content">
|
||||
<h1>Discover Premium Products</h1>
|
||||
<p>Find the best products from trusted suppliers worldwide</p>
|
||||
<el-button type="primary" size="large" @click="$router.push('/products')">
|
||||
{{ $t('common.products') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">{{ $t('product.category') }}</h2>
|
||||
</div>
|
||||
<div class="category-grid">
|
||||
<div v-for="cat in categories" :key="cat.id" class="category-card" @click="goCategory(cat.id)">
|
||||
<div class="category-icon">
|
||||
<el-icon size="32"><Folder /></el-icon>
|
||||
</div>
|
||||
<span class="category-name">{{ cat.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">{{ $t('common.products') }}</h2>
|
||||
<el-button link @click="$router.push('/products')">{{ $t('common.search') }} →</el-button>
|
||||
</div>
|
||||
<div class="product-grid">
|
||||
<div v-for="p in products" :key="p.id" class="product-card" @click="$router.push(`/products/${p.id}`)">
|
||||
<div class="product-image">
|
||||
<el-image v-if="getFirstImage(p.images)" :src="getFirstImage(p.images)" fit="cover" lazy>
|
||||
<template #error>
|
||||
<div class="image-placeholder">
|
||||
<el-icon size="48"><Goods /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
<div v-else class="image-placeholder">
|
||||
<el-icon size="48"><Goods /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="product-info">
|
||||
<h3 class="product-name">{{ p.name }}</h3>
|
||||
<p class="product-price">¥{{ p.price }}</p>
|
||||
<div class="product-tags">
|
||||
<el-tag v-if="p.require_credit" size="small" type="warning">{{ $t('product.requireCredit') }}</el-tag>
|
||||
<el-tag v-if="p.credit_reward > 0" size="small" type="success">+{{ p.credit_reward }} {{ $t('product.creditReward') }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" v-if="lotteries.length">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">{{ $t('lottery.title') }}</h2>
|
||||
<el-button link @click="$router.push('/lotteries')">View All →</el-button>
|
||||
</div>
|
||||
<div class="lottery-grid">
|
||||
<div v-for="l in lotteries" :key="l.id" class="lottery-card" @click="$router.push(`/lotteries/${l.id}`)">
|
||||
<h3>{{ l.name }}</h3>
|
||||
<p>{{ l.description }}</p>
|
||||
<el-button type="primary" size="small">{{ $t('lottery.register') }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Folder, Goods } from '@element-plus/icons-vue'
|
||||
import { categoryApi, productApi, lotteryApi } from '../../api'
|
||||
import { getFirstImage } from '../../utils/image'
|
||||
|
||||
const router = useRouter()
|
||||
const categories = ref<any[]>([])
|
||||
const products = ref<any[]>([])
|
||||
const lotteries = ref<any[]>([])
|
||||
|
||||
function goCategory(id: number) {
|
||||
router.push({ path: '/products', query: { category_id: String(id) } })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [catRes, prodRes, lotRes]: any[] = await Promise.all([
|
||||
categoryApi.list(),
|
||||
productApi.list({ page: 1, page_size: 8 }),
|
||||
lotteryApi.list(),
|
||||
])
|
||||
categories.value = catRes.data || []
|
||||
products.value = prodRes.data || []
|
||||
lotteries.value = (lotRes.data || []).slice(0, 3)
|
||||
} catch {}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.hero {
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
color: #fff;
|
||||
padding: 80px 24px;
|
||||
text-align: center;
|
||||
|
||||
h1 { font-size: 42px; font-weight: 700; margin-bottom: 16px; }
|
||||
p { font-size: 18px; opacity: 0.8; margin-bottom: 32px; }
|
||||
}
|
||||
|
||||
.section {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 24px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.category-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.category-card {
|
||||
background: #2d2d44;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover { transform: translateY(-4px); border-color: rgba(78, 110, 242, 0.3); }
|
||||
}
|
||||
|
||||
.category-icon { margin-bottom: 8px; color: #4e6ef2; }
|
||||
.category-name { font-size: 14px; font-weight: 500; color: rgba(255, 255, 255, 0.85); }
|
||||
|
||||
.product-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
background: #2d2d44;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover { transform: translateY(-4px); border-color: rgba(78, 110, 242, 0.3); }
|
||||
}
|
||||
|
||||
.product-image {
|
||||
height: 200px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
|
||||
.el-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.image-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.product-info { padding: 16px; }
|
||||
.product-name { font-size: 16px; font-weight: 500; margin-bottom: 8px; color: rgba(255, 255, 255, 0.9); }
|
||||
.product-price { font-size: 20px; font-weight: 700; color: #4e6ef2; margin-bottom: 8px; }
|
||||
.product-tags { display: flex; gap: 8px; }
|
||||
|
||||
.lottery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.lottery-card {
|
||||
background: linear-gradient(135deg, #4e6ef2, #7c5cfc);
|
||||
color: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
h3 { margin-bottom: 8px; }
|
||||
p { opacity: 0.8; margin-bottom: 16px; font-size: 14px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="lotteries-page">
|
||||
<BackNav />
|
||||
<div class="lottery-grid">
|
||||
<div v-for="l in lotteries" :key="l.id" class="lottery-card" @click="$router.push(`/lotteries/${l.id}`)">
|
||||
<h3>{{ l.name }}</h3>
|
||||
<p>{{ l.description }}</p>
|
||||
<p class="time">{{ formatDate(l.start_time) }} ~ {{ formatDate(l.end_time) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-if="!lotteries.length && !loading" />
|
||||
<div class="pagination-wrap" v-if="total > pageSize">
|
||||
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next" @current-change="fetchLotteries" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { lotteryApi } from '../../api'
|
||||
import BackNav from '../../components/BackNav.vue'
|
||||
|
||||
const lotteries = ref<any[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = 6
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
function formatDate(date: string) {
|
||||
if (!date) return '-'
|
||||
const d = new Date(date)
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
async function fetchLotteries() {
|
||||
loading.value = true
|
||||
const res: any = await lotteryApi.list({ page: page.value, page_size: pageSize })
|
||||
lotteries.value = res.data || []
|
||||
total.value = res.pagination?.total || res.data?.length || 0
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
onMounted(fetchLotteries)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.lotteries-page { padding: 0; }
|
||||
.lottery-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; }
|
||||
.lottery-card {
|
||||
background: linear-gradient(135deg, #4e6ef2, #7c5cfc);
|
||||
color: #fff; border-radius: 12px; padding: 24px; cursor: pointer; transition: all 0.3s;
|
||||
h3 { margin-bottom: 8px; } p { opacity: 0.8; margin-bottom: 8px; } .time { font-size: 13px; }
|
||||
&:hover { transform: translateY(-4px); }
|
||||
}
|
||||
.pagination-wrap {
|
||||
margin-top: 32px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination) {
|
||||
--el-pagination-bg-color: rgba(255, 255, 255, 0.06);
|
||||
--el-pagination-text-color: rgba(255, 255, 255, 0.6);
|
||||
--el-pagination-button-disabled-bg-color: rgba(255, 255, 255, 0.04);
|
||||
--el-pagination-button-bg-color: rgba(255, 255, 255, 0.06);
|
||||
--el-pagination-hover-color: #4e6ef2;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .el-pager li) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border-radius: 6px;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .el-pager li:hover),
|
||||
.pagination-wrap :deep(.el-pagination .el-pager li.is-active) {
|
||||
background: #4e6ef2;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .btn-prev),
|
||||
.pagination-wrap :deep(.el-pagination .btn-next) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .btn-prev:hover),
|
||||
.pagination-wrap :deep(.el-pagination .btn-next:hover) {
|
||||
background: rgba(78, 110, 242, 0.2);
|
||||
color: #4e6ef2;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="lottery-detail-page">
|
||||
<BackNav />
|
||||
<div v-if="lottery" class="detail-card">
|
||||
<p class="description">{{ lottery.description }}</p>
|
||||
<div class="time-info">
|
||||
<span>活动时间:{{ formatDate(lottery.start_time) }} ~ {{ formatDate(lottery.end_time) }}</span>
|
||||
</div>
|
||||
<h4 class="prizes-title">{{ $t('lottery.prizes') }}</h4>
|
||||
<el-table :data="lottery.prizes" size="small">
|
||||
<el-table-column prop="name" label="奖品" />
|
||||
<el-table-column label="类型" width="100">
|
||||
<template #default="{ row }">{{ prizeTypeText(row.type) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="quantity" label="数量" width="80" />
|
||||
</el-table>
|
||||
<el-button type="primary" size="large" style="margin-top:24px" @click="register">{{ $t('lottery.register') }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { lotteryApi } from '../../api'
|
||||
import BackNav from '../../components/BackNav.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const lottery = ref<any>(null)
|
||||
|
||||
const prizeTypeMap: Record<string, string> = {
|
||||
product: '商品',
|
||||
credits: '积分',
|
||||
cash: '现金',
|
||||
}
|
||||
function prizeTypeText(t: string) { return prizeTypeMap[t] || t }
|
||||
|
||||
function formatDate(date: string) {
|
||||
if (!date) return '-'
|
||||
const d = new Date(date)
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const res: any = await lotteryApi.getById(Number(route.params.id))
|
||||
lottery.value = res.data
|
||||
})
|
||||
|
||||
async function register() {
|
||||
await lotteryApi.register(Number(route.params.id))
|
||||
ElMessage.success('报名成功')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.lottery-detail-page { padding: 0; }
|
||||
.detail-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 24px; }
|
||||
.description { color: rgba(255, 255, 255, 0.5); margin-bottom: 16px; }
|
||||
.time-info { color: rgba(255, 255, 255, 0.6); font-size: 14px; margin-bottom: 24px; }
|
||||
.prizes-title { color: #fff; margin: 24px 0 12px; font-size: 16px; }
|
||||
</style>
|
||||
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<div class="order-detail-page">
|
||||
<BackNav />
|
||||
<div v-if="order" class="detail-card">
|
||||
|
||||
<div class="status-section">
|
||||
<el-tag :type="statusType(order.status)" size="large">{{ statusText(order.status) }}</el-tag>
|
||||
<span v-if="order.payment_method" class="payment-method">支付方式: {{ paymentMethodText(order.payment_method) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="section-title">订单信息</div>
|
||||
<el-descriptions :column="2" border class="order-desc">
|
||||
<el-descriptions-item label="商品小计">¥{{ order.subtotal || order.total_amount }}</el-descriptions-item>
|
||||
<el-descriptions-item label="运费">¥{{ order.shipping_fee || 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务费">¥{{ order.service_fee || 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="税费">¥{{ order.tax || 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="订单总额">
|
||||
<span class="total-amount">¥{{ order.total_amount }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('order.createTime')">{{ formatDate(order.created_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('order.trackingNumber')" v-if="order.tracking_number">{{ order.tracking_number }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="section-title" v-if="order.shipping_address">收货地址</div>
|
||||
<div class="address-info" v-if="order.shipping_address">
|
||||
<p><strong>{{ order.shipping_address.name }}</strong> {{ order.shipping_address.phone }}</p>
|
||||
<p>{{ order.shipping_address.province }}{{ order.shipping_address.city }}{{ order.shipping_address.district }}{{ order.shipping_address.address }}</p>
|
||||
</div>
|
||||
|
||||
<div class="section-title">商品明细</div>
|
||||
<el-table :data="order.order_items" size="small">
|
||||
<el-table-column prop="product.name" label="商品" />
|
||||
<el-table-column prop="quantity" label="数量" width="80" />
|
||||
<el-table-column prop="price" label="单价" width="100"><template #default="{ row }">¥{{ row.price }}</template></el-table-column>
|
||||
<el-table-column label="小计" width="100"><template #default="{ row }">¥{{ (row.price * row.quantity).toFixed(2) }}</template></el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="refund-section" v-if="canRefund">
|
||||
<el-button type="danger" @click="showRefund = true">{{ $t('order.applyRefund') }}</el-button>
|
||||
</div>
|
||||
|
||||
<div class="refund-info" v-if="order.refund_status">
|
||||
<el-tag type="warning">退款状态: {{ refundStatusText(order.refund_status) }}</el-tag>
|
||||
<p v-if="order.refund_reason">退款原因: {{ order.refund_reason }}</p>
|
||||
<p v-if="order.refund_amount">退款金额: ¥{{ order.refund_amount }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<el-dialog v-model="showRefund" :title="$t('order.applyRefund')" width="400px">
|
||||
<el-input v-model="refundReason" type="textarea" :placeholder="$t('order.refundReason')" />
|
||||
<template #footer>
|
||||
<el-button @click="showRefund = false">{{ $t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" @click="applyRefund">{{ $t('common.submit') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { orderApi } from '../../api'
|
||||
import BackNav from '../../components/BackNav.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const order = ref<any>(null)
|
||||
const showRefund = ref(false)
|
||||
const refundReason = ref('')
|
||||
|
||||
const statusMap: Record<string, string> = {
|
||||
pending_payment: '待支付', pending_confirm: '待确认', pending_ship: '待发货',
|
||||
shipped: '已发货', completed: '已完成', refunding: '退款中', refunded: '已退款', cancelled: '已取消',
|
||||
}
|
||||
const statusTypeMap: Record<string, string> = {
|
||||
pending_payment: 'warning', pending_confirm: 'info', pending_ship: 'info',
|
||||
shipped: 'primary', completed: 'success', refunding: 'danger', refunded: 'danger', cancelled: 'info',
|
||||
}
|
||||
const refundStatusMap: Record<string, string> = {
|
||||
pending: '待处理', approved: '已批准', rejected: '已拒绝', completed: '已完成',
|
||||
}
|
||||
const paymentMethodMap: Record<string, string> = {
|
||||
balance: '余额支付', alipay: '支付宝', wechat: '微信支付',
|
||||
}
|
||||
|
||||
function statusText(s: string) { return statusMap[s] || s }
|
||||
function statusType(s: string) { return statusTypeMap[s] || 'info' }
|
||||
function refundStatusText(s: string) { return refundStatusMap[s] || s }
|
||||
function paymentMethodText(s: string) { return paymentMethodMap[s] || s }
|
||||
|
||||
function formatDate(date: string) {
|
||||
if (!date) return '-'
|
||||
const d = new Date(date)
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const hours = String(d.getHours()).padStart(2, '0')
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
const canRefund = computed(() => {
|
||||
if (!order.value) return false
|
||||
return !['shipped', 'completed', 'refunded', 'refunding'].includes(order.value.status)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const res: any = await orderApi.getById(Number(route.params.id))
|
||||
order.value = res.data
|
||||
})
|
||||
|
||||
async function applyRefund() {
|
||||
if (!refundReason.value.trim()) {
|
||||
ElMessage.warning('请填写退款原因')
|
||||
return
|
||||
}
|
||||
await orderApi.refund(order.value.id, { reason: refundReason.value })
|
||||
ElMessage.success('退款申请已提交')
|
||||
showRefund.value = false
|
||||
const res: any = await orderApi.getById(order.value.id)
|
||||
order.value = res.data
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.order-detail-page { padding: 0; }
|
||||
.detail-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 24px; }
|
||||
|
||||
.status-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.payment-method {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 24px 0 12px;
|
||||
padding-left: 12px;
|
||||
border-left: 3px solid #4e6ef2;
|
||||
}
|
||||
|
||||
.order-desc { margin-bottom: 0; }
|
||||
|
||||
.total-amount {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #4e6ef2;
|
||||
}
|
||||
|
||||
.address-info {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
|
||||
p {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin: 4px 0;
|
||||
|
||||
strong {
|
||||
color: #fff;
|
||||
margin-right: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.refund-section { margin-top: 24px; }
|
||||
|
||||
.refund-info {
|
||||
margin-top: 24px;
|
||||
padding: 16px;
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
border-radius: 8px;
|
||||
|
||||
p {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div class="orders-page">
|
||||
<BackNav />
|
||||
<div class="table-card">
|
||||
<el-table :data="orders">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="total_amount" :label="$t('order.totalAmount')" width="120">
|
||||
<template #default="{ row }">¥{{ row.total_amount }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" :label="$t('order.status')" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="180">
|
||||
<template #default="{ row }">{{ formatDate(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('common.edit')" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="$router.push(`/orders/${row.id}`)">查看</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { orderApi } from '../../api'
|
||||
import BackNav from '../../components/BackNav.vue'
|
||||
|
||||
const orders = ref<any[]>([])
|
||||
|
||||
const statusMap: Record<string, string> = {
|
||||
pending_payment: '待支付', pending_confirm: '待确认', pending_ship: '待发货',
|
||||
shipped: '已发货', completed: '已完成', refunding: '退款中', refunded: '已退款', cancelled: '已取消',
|
||||
}
|
||||
const statusTypeMap: Record<string, string> = {
|
||||
pending_payment: 'warning', pending_confirm: 'info', pending_ship: 'info',
|
||||
shipped: 'primary', completed: 'success', refunding: 'danger', refunded: 'danger', cancelled: 'info',
|
||||
}
|
||||
function statusText(s: string) { return statusMap[s] || s }
|
||||
function statusType(s: string) { return statusTypeMap[s] || 'info' }
|
||||
|
||||
function formatDate(date: string) {
|
||||
if (!date) return '-'
|
||||
const d = new Date(date)
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const hours = String(d.getHours()).padStart(2, '0')
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const res: any = await orderApi.list()
|
||||
orders.value = res.data || []
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.orders-page { padding: 0; }
|
||||
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
|
||||
</style>
|
||||
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<div class="product-detail-page" v-if="product">
|
||||
<BackNav />
|
||||
<div class="detail-card">
|
||||
<el-row :gutter="32">
|
||||
<el-col :span="12">
|
||||
<div class="product-images">
|
||||
<el-carousel v-if="productImages.length > 1" height="400px" :autoplay="false" indicator-position="outside">
|
||||
<el-carousel-item v-for="(img, index) in productImages" :key="index">
|
||||
<el-image :src="img" fit="contain" class="carousel-image" :preview-src-list="productImages" :initial-index="index">
|
||||
<template #error>
|
||||
<div class="image-placeholder">
|
||||
<el-icon size="80"><Goods /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
</el-carousel-item>
|
||||
</el-carousel>
|
||||
<el-image v-else-if="productImages.length === 1" :src="productImages[0]" fit="contain" class="single-image" :preview-src-list="productImages">
|
||||
<template #error>
|
||||
<div class="image-placeholder">
|
||||
<el-icon size="80"><Goods /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
<div v-else class="image-placeholder">
|
||||
<el-icon size="80"><Goods /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<h1>{{ product.name }}</h1>
|
||||
<p class="price">¥{{ product.price }}</p>
|
||||
<p class="description">{{ product.description }}</p>
|
||||
<div class="meta">
|
||||
<el-tag v-if="product.require_credit" type="warning">{{ $t('product.requireCredit') }}: {{ product.credit_cost }}</el-tag>
|
||||
<el-tag v-if="product.credit_reward > 0" type="success">+{{ product.credit_reward }} {{ $t('product.creditReward') }}</el-tag>
|
||||
</div>
|
||||
<div class="custom-fields" v-if="product.custom_fields?.length">
|
||||
<h4>{{ $t('product.customFields') }}</h4>
|
||||
<div v-for="f in product.custom_fields" :key="f.id" class="field-item">
|
||||
<span class="field-name">{{ f.field_name }}:</span>
|
||||
<span>{{ f.field_value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="purchase-section">
|
||||
<el-input-number v-model="quantity" :min="product.min_purchase || 1" :max="product.max_purchase || 999" />
|
||||
<el-button type="primary" size="large" @click="addToCart">{{ $t('product.addToCart') }}</el-button>
|
||||
<el-button type="success" size="large" @click="buyNow">{{ $t('product.buyNow') }}</el-button>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Goods } from '@element-plus/icons-vue'
|
||||
import { productApi } from '../../api'
|
||||
import { useCartStore } from '../../store/cart'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { getImageUrl } from '../../utils/image'
|
||||
import BackNav from '../../components/BackNav.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const cartStore = useCartStore()
|
||||
const product = ref<any>(null)
|
||||
const quantity = ref(1)
|
||||
|
||||
const productImages = computed(() => {
|
||||
if (!product.value?.images) return []
|
||||
return product.value.images.split(',').map((s: string) => s.trim()).filter(Boolean).map(url => getImageUrl(url))
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const res: any = await productApi.getById(Number(route.params.id))
|
||||
product.value = res.data
|
||||
})
|
||||
|
||||
async function addToCart() {
|
||||
await cartStore.addItem(product.value.id, quantity.value)
|
||||
ElMessage.success(t('product.addToCart') + ' ✓')
|
||||
}
|
||||
|
||||
function buyNow() {
|
||||
addToCart().then(() => router.push('/cart'))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.product-detail-page { padding: 0; }
|
||||
.detail-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 32px; }
|
||||
.product-images {
|
||||
height: 400px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
|
||||
.carousel-image, .single-image {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.image-placeholder {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
h1 { font-size: 28px; color: #fff; margin-bottom: 16px; }
|
||||
.price { font-size: 32px; font-weight: 700; color: #4e6ef2; margin-bottom: 16px; }
|
||||
.description { color: rgba(255, 255, 255, 0.5); margin-bottom: 16px; line-height: 1.6; }
|
||||
.meta { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||
.custom-fields { margin-bottom: 24px; h4 { color: #fff; margin-bottom: 8px; } .field-item { color: rgba(255, 255, 255, 0.7); margin-bottom: 4px; .field-name { color: rgba(255, 255, 255, 0.5); margin-right: 8px; } } }
|
||||
.purchase-section { display: flex; gap: 12px; align-items: center; margin-top: 24px; }
|
||||
</style>
|
||||
@@ -0,0 +1,286 @@
|
||||
<template>
|
||||
<div class="products-page">
|
||||
<div class="page-header">
|
||||
<BackNav />
|
||||
<div class="filter-bar">
|
||||
<el-select v-model="categoryId" :placeholder="$t('product.filterByCategory')" popper-class="dark-select-dropdown" @change="fetchProducts">
|
||||
<el-option label="全部分类" :value="undefined" />
|
||||
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
<el-select v-model="brandId" placeholder="筛选品牌" popper-class="dark-select-dropdown" @change="fetchProducts">
|
||||
<el-option label="全部品牌" :value="undefined" />
|
||||
<el-option v-for="b in brands" :key="b.id" :label="b.name" :value="b.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="product-grid">
|
||||
<div v-for="p in products" :key="p.id" class="product-card" @click="$router.push(`/products/${p.id}`)">
|
||||
<div class="product-image">
|
||||
<el-image v-if="getFirstImage(p.images)" :src="getFirstImage(p.images)" fit="contain" lazy>
|
||||
<template #error>
|
||||
<div class="image-placeholder">
|
||||
<el-icon :size="40"><Picture /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
<div v-else class="image-placeholder">
|
||||
<el-icon :size="40"><Picture /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="product-info">
|
||||
<h3 class="product-name">{{ p.name }}</h3>
|
||||
<div class="product-meta">
|
||||
<span class="price">¥{{ p.price }}</span>
|
||||
<el-tag v-if="p.brand" size="small" type="info">{{ p.brand?.name }}</el-tag>
|
||||
</div>
|
||||
<div class="product-tags">
|
||||
<el-tag v-if="p.require_credit" size="small" type="warning">需要资格</el-tag>
|
||||
<el-tag v-if="p.credit_reward > 0" size="small" type="success">奖励 +{{ p.credit_reward }}</el-tag>
|
||||
<el-tag v-if="p.stock !== undefined && p.stock <= 10" size="small" type="danger">仅剩{{ p.stock }}件</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!products.length" :description="$t('common.noData')" />
|
||||
|
||||
<div class="pagination-wrap" v-if="total > pageSize">
|
||||
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next" @current-change="fetchProducts" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Picture } from '@element-plus/icons-vue'
|
||||
import { productApi, categoryApi, brandApi } from '../../api'
|
||||
import { getFirstImage } from '../../utils/image'
|
||||
import BackNav from '../../components/BackNav.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const products = ref<any[]>([])
|
||||
const categories = ref<any[]>([])
|
||||
const brands = ref<any[]>([])
|
||||
const categoryId = ref<number | undefined>(undefined)
|
||||
const brandId = ref<number | undefined>(undefined)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const total = ref(0)
|
||||
|
||||
async function fetchProducts() {
|
||||
const res: any = await productApi.list({
|
||||
page: page.value,
|
||||
page_size: pageSize,
|
||||
category_id: categoryId.value,
|
||||
brand_id: brandId.value,
|
||||
})
|
||||
products.value = res.data || []
|
||||
total.value = res.pagination?.total || 0
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (route.query.category_id) categoryId.value = Number(route.query.category_id)
|
||||
const [catRes, brandRes]: any[] = await Promise.all([
|
||||
categoryApi.list(),
|
||||
brandApi.list()
|
||||
])
|
||||
categories.value = catRes.data || []
|
||||
brands.value = brandRes.data || []
|
||||
await fetchProducts()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.products-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
|
||||
:deep(.breadcrumb-nav) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filter-bar :deep(.el-select) {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.product-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
background: #2d2d44;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.product-card:hover {
|
||||
transform: translateY(-4px);
|
||||
background: #35355a;
|
||||
border-color: rgba(78, 110, 242, 0.3);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.product-image {
|
||||
aspect-ratio: 1;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
.el-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
img {
|
||||
object-fit: contain;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
}
|
||||
|
||||
.image-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.product-info {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.product-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.price {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #4e6ef2;
|
||||
}
|
||||
|
||||
.product-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
margin-top: 32px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination) {
|
||||
--el-pagination-bg-color: rgba(255, 255, 255, 0.06);
|
||||
--el-pagination-text-color: rgba(255, 255, 255, 0.6);
|
||||
--el-pagination-button-disabled-bg-color: rgba(255, 255, 255, 0.04);
|
||||
--el-pagination-button-bg-color: rgba(255, 255, 255, 0.06);
|
||||
--el-pagination-hover-color: #4e6ef2;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .el-pager li) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border-radius: 6px;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .el-pager li:hover),
|
||||
.pagination-wrap :deep(.el-pagination .el-pager li.is-active) {
|
||||
background: #4e6ef2;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .btn-prev),
|
||||
.pagination-wrap :deep(.el-pagination .btn-next) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination .btn-prev:hover),
|
||||
.pagination-wrap :deep(.el-pagination .btn-next:hover) {
|
||||
background: rgba(78, 110, 242, 0.2);
|
||||
color: #4e6ef2;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-bar :deep(.el-select) {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.product-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 13px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.price {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="profile-page">
|
||||
<BackNav />
|
||||
<div class="profile-card" v-if="user">
|
||||
<div class="profile-header">
|
||||
<el-avatar :size="64" class="profile-avatar">{{ user.username?.charAt(0).toUpperCase() }}</el-avatar>
|
||||
<div class="profile-info">
|
||||
<h3>{{ user.username }}</h3>
|
||||
<p>{{ user.email }}</p>
|
||||
<el-tag :type="user.role === 'admin' ? 'danger' : user.role === 'supplier' ? 'warning' : 'info'" size="small">{{ roleText(user.role) }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<el-descriptions :column="2" border class="profile-desc">
|
||||
<el-descriptions-item label="购买资格">{{ user.purchase_credits }}</el-descriptions-item>
|
||||
<el-descriptions-item label="邀请码">{{ user.invite_code }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div class="profile-actions">
|
||||
<el-button @click="$router.push('/addresses')">地址管理</el-button>
|
||||
<el-button @click="$router.push('/orders')">我的订单</el-button>
|
||||
<el-button v-if="user.role === 'admin'" type="primary" @click="$router.push('/admin')">管理后台</el-button>
|
||||
<el-button v-if="user.role === 'supplier'" type="primary" @click="$router.push('/supplier')">供应商后台</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useUserStore } from '../../store/user'
|
||||
import BackNav from '../../components/BackNav.vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const user = computed(() => userStore.user)
|
||||
|
||||
const roleMap: Record<string, string> = {
|
||||
admin: '管理员',
|
||||
supplier: '供应商',
|
||||
user: '用户',
|
||||
}
|
||||
function roleText(r: string) { return roleMap[r] || r }
|
||||
|
||||
onMounted(() => userStore.fetchProfile())
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.profile-page {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
background: #2d2d44;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
background: linear-gradient(135deg, #4e6ef2, #7c5cfc);
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.profile-info h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.profile-info p {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.profile-desc {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.profile-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="tickets-page">
|
||||
<BackNav />
|
||||
<div class="page-header">
|
||||
<el-button type="primary" @click="$router.push('/tickets/create')">{{ $t('ticket.createTicket') }}</el-button>
|
||||
</div>
|
||||
<div class="table-card">
|
||||
<el-table :data="tickets">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="title" :label="$t('ticket.subject')" />
|
||||
<el-table-column prop="status" :label="$t('ticket.status')" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="180">
|
||||
<template #default="{ row }">{{ formatDate(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ticketApi } from '../../api'
|
||||
import BackNav from '../../components/BackNav.vue'
|
||||
|
||||
const tickets = ref<any[]>([])
|
||||
|
||||
const statusMap: Record<string, string> = {
|
||||
pending: '待处理',
|
||||
processing: '处理中',
|
||||
resolved: '已解决',
|
||||
closed: '已关闭',
|
||||
}
|
||||
const statusTypeMap: Record<string, string> = {
|
||||
pending: 'warning',
|
||||
processing: 'primary',
|
||||
resolved: 'success',
|
||||
closed: 'info',
|
||||
}
|
||||
function statusText(s: string) { return statusMap[s] || s }
|
||||
function statusType(s: string) { return statusTypeMap[s] || 'info' }
|
||||
|
||||
function formatDate(date: string) {
|
||||
if (!date) return '-'
|
||||
const d = new Date(date)
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const hours = String(d.getHours()).padStart(2, '0')
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const res: any = await ticketApi.list()
|
||||
tickets.value = res.data || []
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tickets-page { padding: 0; }
|
||||
.page-header { display: flex; justify-content: flex-end; align-items: center; margin-bottom: 24px; }
|
||||
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user