Initial commit: 商品售卖网站

This commit is contained in:
2026-04-13 07:20:09 +08:00
commit c6154273f2
865 changed files with 26573 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sale</title>
</head>
<body class="scheme4">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2383
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"typescript": "~6.0.2",
"vite": "^6.4.2"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@vitejs/plugin-vue": "^6.0.5",
"axios": "^1.15.0",
"element-plus": "^2.13.6",
"pinia": "^3.0.4",
"sass": "^1.99.0",
"vue-i18n": "^9.14.5",
"vue-router": "^4.6.4"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+3
View File
@@ -0,0 +1,3 @@
<template>
<router-view />
</template>
+162
View File
@@ -0,0 +1,162 @@
import api from '../utils/request'
export const uploadApi = {
uploadImage: (file: File) => {
const formData = new FormData()
formData.append('file', file)
return api.post('/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
},
uploadMultiple: (files: File[]) => {
const formData = new FormData()
files.forEach(file => formData.append('files', file))
return api.post('/upload/multiple', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
},
deleteImage: (filename: string) => api.delete(`/upload/${filename}`)
}
export const systemApi = {
checkInstalled: () => api.get('/installed'),
install: (data: any) => api.post('/install', data),
getPublicSettings: () => api.get('/settings/public'),
}
export const authApi = {
register: (data: any) => api.post('/auth/register', data),
login: (data: any) => api.post('/auth/login', data),
forgotPassword: (data: any) => api.post('/auth/forgot-password', data),
resetPassword: (data: any) => api.post('/auth/reset-password', data),
verifyEmail: (params: any) => api.get('/auth/verify-email', { params }),
sendVerifyCode: () => api.post('/users/send-verify-code'),
}
export const userApi = {
getProfile: () => api.get('/users/profile'),
updateProfile: (data: any) => api.put('/users/profile', data),
changePassword: (data: any) => api.put('/users/password', data),
}
export const addressApi = {
list: () => api.get('/users/addresses'),
create: (data: any) => api.post('/users/addresses', data),
update: (id: number, data: any) => api.put(`/users/addresses/${id}`, data),
delete: (id: number) => api.delete(`/users/addresses/${id}`),
setDefault: (id: number) => api.put(`/users/addresses/${id}/default`),
}
export const categoryApi = {
list: () => api.get('/categories'),
}
export const brandApi = {
list: () => api.get('/brands'),
}
export const productApi = {
list: (params?: any) => api.get('/products', { params }),
getById: (id: number) => api.get(`/products/${id}`),
}
export const cartApi = {
list: () => api.get('/cart'),
add: (data: any) => api.post('/cart', data),
update: (id: number, data: any) => api.put(`/cart/${id}`, data),
delete: (id: number) => api.delete(`/cart/${id}`),
}
export const orderApi = {
list: () => api.get('/orders'),
getById: (id: number) => api.get(`/orders/${id}`),
create: (data: any) => api.post('/orders', data),
refund: (id: number, data: any) => api.post(`/orders/${id}/refund`, data),
}
export const articleApi = {
list: (params?: any) => api.get('/articles', { params }),
getById: (id: number) => api.get(`/articles/${id}`),
}
export const lotteryApi = {
list: () => api.get('/lotteries'),
getById: (id: number) => api.get(`/lotteries/${id}`),
register: (id: number) => api.post(`/lotteries/${id}/register`),
}
export const ticketApi = {
list: (params?: any) => api.get('/tickets', { params }),
create: (data: any) => api.post('/tickets', data),
getById: (id: number) => api.get(`/tickets/${id}`),
}
export const menuApi = {
list: () => api.get('/menus'),
}
export const supplierApi = {
getOrders: () => api.get('/supplier/orders'),
getOrderById: (id: number) => api.get(`/supplier/orders/${id}`),
confirmOrder: (id: number) => api.put(`/supplier/orders/${id}/confirm`),
shipOrder: (id: number, data: any) => api.put(`/supplier/orders/${id}/ship`, data),
getInventory: () => api.get('/supplier/inventory'),
updateInventory: (id: number, data: any) => api.put(`/supplier/inventory/${id}`, data),
}
export const adminApi = {
getCategories: () => api.get('/categories'),
createCategory: (data: any) => api.post('/admin/categories', data),
updateCategory: (id: number, data: any) => api.put(`/admin/categories/${id}`, data),
deleteCategory: (id: number) => api.delete(`/admin/categories/${id}`),
getBrands: () => api.get('/admin/brands'),
createBrand: (data: any) => api.post('/admin/brands', data),
updateBrand: (id: number, data: any) => api.put(`/admin/brands/${id}`, data),
deleteBrand: (id: number) => api.delete(`/admin/brands/${id}`),
getProducts: () => api.get('/admin/products'),
createProduct: (data: any) => api.post('/admin/products', data),
updateProduct: (id: number, data: any) => api.put(`/admin/products/${id}`, data),
deleteProduct: (id: number) => api.delete(`/admin/products/${id}`),
addCustomField: (id: number, data: any) => api.post(`/admin/products/${id}/custom-fields`, data),
getOrders: (params?: any) => api.get('/admin/orders', { params }),
exportOrders: () => api.get('/admin/orders/export'),
processRefund: (id: number, data: any) => api.put(`/admin/orders/${id}/refund`, data),
getSuppliers: () => api.get('/admin/suppliers'),
createSupplier: (data: any) => api.post('/admin/suppliers', data),
updateSupplier: (id: number, data: any) => api.put(`/admin/suppliers/${id}`, data),
deleteSupplier: (id: number) => api.delete(`/admin/suppliers/${id}`),
authorizeSupplier: (id: number, data: any) => api.post(`/admin/suppliers/${id}/authorize`, data),
getLotteries: () => api.get('/lotteries'),
createLottery: (data: any) => api.post('/admin/lotteries', data),
updateLottery: (id: number, data: any) => api.put(`/admin/lotteries/${id}`, data),
deleteLottery: (id: number) => api.delete(`/admin/lotteries/${id}`),
addLotteryPrize: (id: number, data: any) => api.post(`/admin/lotteries/${id}/prizes`, data),
drawLottery: (id: number) => api.post(`/admin/lotteries/${id}/draw`),
getTickets: (params?: any) => api.get('/admin/tickets', { params }),
updateTicket: (id: number, data: any) => api.put(`/admin/tickets/${id}`, data),
assignTicket: (id: number, data: any) => api.put(`/admin/tickets/${id}/assign`, data),
getSettings: () => api.get('/admin/settings'),
updateSettings: (data: any) => api.put('/admin/settings', data),
updateSMTP: (data: any) => api.put('/admin/settings/smtp', data),
updatePayment: (data: any) => api.put('/admin/settings/payment', data),
getMenus: () => api.get('/admin/menus'),
createMenu: (data: any) => api.post('/admin/menus', data),
updateMenu: (id: number, data: any) => api.put(`/admin/menus/${id}`, data),
deleteMenu: (id: number) => api.delete(`/admin/menus/${id}`),
getInventory: () => api.get('/admin/inventory'),
getArticles: (params?: any) => api.get('/admin/articles', { params }),
createArticle: (data: any) => api.post('/admin/articles', data),
updateArticle: (id: number, data: any) => api.put(`/admin/articles/${id}`, data),
deleteArticle: (id: number) => api.delete(`/admin/articles/${id}`),
togglePinArticle: (id: number) => api.put(`/admin/articles/${id}/pin`),
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="32" height="32" viewBox="0 0 256 256"><path fill="#007ACC" d="M0 128v128h256V0H0z"/><path fill="#FFF" d="m56.612 128.85l-.081 10.483h33.32v94.68h23.568v-94.68h33.321v-10.28c0-5.69-.122-10.444-.284-10.566c-.122-.162-20.4-.244-44.983-.203l-44.74.122l-.121 10.443Zm149.955-10.742c6.501 1.625 11.459 4.51 16.01 9.224c2.357 2.52 5.851 7.111 6.136 8.208c.08.325-11.053 7.802-17.798 11.988c-.244.162-1.22-.894-2.317-2.52c-3.291-4.795-6.745-6.867-12.028-7.233c-7.76-.528-12.759 3.535-12.718 10.321c0 1.992.284 3.17 1.097 4.795c1.707 3.536 4.876 5.649 14.832 9.956c18.326 7.883 26.168 13.084 31.045 20.48c5.445 8.249 6.664 21.415 2.966 31.208c-4.063 10.646-14.14 17.879-28.323 20.276c-4.388.772-14.79.65-19.504-.203c-10.28-1.828-20.033-6.908-26.047-13.572c-2.357-2.6-6.949-9.387-6.664-9.874c.122-.163 1.178-.813 2.356-1.504c1.138-.65 5.446-3.129 9.509-5.485l7.355-4.267l1.544 2.276c2.154 3.29 6.867 7.801 9.712 9.305c8.167 4.307 19.383 3.698 24.909-1.26c2.357-2.153 3.332-4.388 3.332-7.68c0-2.966-.366-4.266-1.91-6.501c-1.99-2.845-6.054-5.242-17.595-10.24c-13.206-5.69-18.895-9.224-24.096-14.832c-3.007-3.25-5.852-8.452-7.03-12.8c-.975-3.617-1.22-12.678-.447-16.335c2.723-12.76 12.353-21.659 26.25-24.3c4.51-.853 14.994-.528 19.424.569Z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+158
View File
@@ -0,0 +1,158 @@
<template>
<nav class="breadcrumb-nav">
<ol class="breadcrumb-list">
<li v-for="(item, index) in items" :key="index" class="breadcrumb-item">
<span v-if="index === items.length - 1" class="breadcrumb-current">{{ item.label }}</span>
<a v-else class="breadcrumb-link" @click="navigate(item)">
<el-icon v-if="item.icon" class="breadcrumb-icon"><component :is="item.icon" /></el-icon>
<span>{{ item.label }}</span>
</a>
<el-icon v-if="index < items.length - 1" class="breadcrumb-separator"><ArrowRight /></el-icon>
</li>
</ol>
</nav>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ArrowRight, HomeFilled } from '@element-plus/icons-vue'
interface BreadcrumbItem {
label: string
path?: string
icon?: any
}
const props = withDefaults(defineProps<{
items?: BreadcrumbItem[]
}>(), {
items: () => []
})
const router = useRouter()
const route = useRoute()
const routeLabels: Record<string, string> = {
'/': '首页',
'/products': '商品',
'/articles': '资讯',
'/lotteries': '抽奖',
'/orders': '订单',
'/tickets': '工单',
'/addresses': '地址管理',
'/profile': '个人中心',
'/cart': '购物车',
}
const actionLabels: Record<string, string> = {
'create': '创建',
'edit': '编辑',
}
const items = computed(() => {
if (props.items.length > 0) return props.items
const pathSegments = route.path.split('/').filter(Boolean)
const result: BreadcrumbItem[] = [
{ label: '首页', path: '/', icon: HomeFilled }
]
let currentPath = ''
pathSegments.forEach((segment, index) => {
currentPath += '/' + segment
const isLast = index === pathSegments.length - 1
const isId = /^\d+$/.test(segment)
const isAction = actionLabels[segment]
if (isId) {
const parentPath = '/' + pathSegments[index - 1]
const parentLabel = routeLabels[parentPath] || pathSegments[index - 1]
if (result[result.length - 1]?.path !== parentPath) {
result.push({ label: parentLabel, path: parentPath })
}
result.push({ label: '详情' })
} else if (isAction) {
const parentPath = '/' + pathSegments.slice(0, index).join('/')
const parentLabel = routeLabels[parentPath] || pathSegments[index - 1]
if (result[result.length - 1]?.path !== parentPath) {
result.push({ label: parentLabel, path: parentPath })
}
result.push({ label: actionLabels[segment] })
} else if (!isLast) {
result.push({
label: routeLabels[currentPath] || segment,
path: currentPath
})
} else {
result.push({ label: routeLabels[currentPath] || segment })
}
})
return result
})
function navigate(item: BreadcrumbItem) {
if (item.path) {
router.push(item.path)
}
}
</script>
<style scoped lang="scss">
.breadcrumb-nav {
margin-bottom: 20px;
}
.breadcrumb-list {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.breadcrumb-item {
display: flex;
align-items: center;
gap: 4px;
}
.breadcrumb-link {
display: inline-flex;
align-items: center;
gap: 4px;
color: rgba(255, 255, 255, 0.5);
font-size: 13px;
text-decoration: none;
cursor: pointer;
transition: all 0.15s ease;
padding: 4px 8px;
border-radius: 6px;
&:hover {
color: rgba(255, 255, 255, 0.9);
background: rgba(255, 255, 255, 0.06);
}
}
.breadcrumb-icon {
font-size: 14px;
}
.breadcrumb-current {
color: rgba(255, 255, 255, 0.9);
font-size: 13px;
font-weight: 500;
padding: 4px 8px;
background: rgba(255, 255, 255, 0.06);
border-radius: 6px;
}
.breadcrumb-separator {
font-size: 12px;
color: rgba(255, 255, 255, 0.2);
}
</style>
+13
View File
@@ -0,0 +1,13 @@
import { createI18n } from 'vue-i18n'
import zh from './locales/zh.json'
import en from './locales/en.json'
import ja from './locales/ja.json'
const i18n = createI18n({
legacy: false,
locale: localStorage.getItem('locale') || 'zh',
fallbackLocale: 'zh',
messages: { zh, en, ja },
})
export default i18n
+145
View File
@@ -0,0 +1,145 @@
{
"common": {
"home": "Home",
"products": "Products",
"cart": "Cart",
"orders": "Orders",
"profile": "Profile",
"login": "Login",
"register": "Register",
"logout": "Logout",
"search": "Search",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"create": "Create",
"submit": "Submit",
"confirm": "Confirm",
"back": "Back",
"loading": "Loading...",
"success": "Success",
"error": "Error",
"noData": "No data",
"total": "Total {count} items",
"language": "Language"
},
"auth": {
"email": "Email",
"username": "Username",
"password": "Password",
"confirmPassword": "Confirm Password",
"inviteCode": "Invite Code",
"forgotPassword": "Forgot Password",
"resetPassword": "Reset Password",
"loginSuccess": "Login successful",
"registerSuccess": "Registration successful",
"emailVerified": "Email verified",
"sendVerifyCode": "Send Code",
"verifyEmail": "Verify Email"
},
"product": {
"name": "Product Name",
"price": "Price",
"description": "Description",
"category": "Category",
"stock": "Stock",
"addToCart": "Add to Cart",
"buyNow": "Buy Now",
"requireCredit": "Requires Purchase Credit",
"creditCost": "Credit Cost",
"creditReward": "Credit Reward",
"minPurchase": "Min Purchase",
"maxPurchase": "Max Purchase",
"customFields": "Custom Fields",
"filterByCategory": "Filter by Category",
"searchProducts": "Search Products"
},
"cart": {
"empty": "Cart is empty",
"total": "Total",
"checkout": "Checkout",
"quantity": "Quantity",
"subtotal": "Subtotal",
"removeItem": "Remove Item"
},
"order": {
"orderNumber": "Order No.",
"status": "Status",
"totalAmount": "Total Amount",
"createTime": "Created At",
"pendingPayment": "Pending Payment",
"pendingConfirm": "Pending Confirm",
"pendingShip": "Pending Shipment",
"shipped": "Shipped",
"completed": "Completed",
"refunding": "Refunding",
"refunded": "Refunded",
"cancelled": "Cancelled",
"applyRefund": "Apply Refund",
"refundReason": "Refund Reason",
"trackingNumber": "Tracking Number",
"shippingAddress": "Shipping Address",
"exportOrders": "Export Orders"
},
"address": {
"name": "Recipient",
"phone": "Phone",
"province": "Province",
"city": "City",
"district": "District",
"detail": "Address",
"setDefault": "Set Default",
"addAddress": "Add Address",
"editAddress": "Edit Address"
},
"lottery": {
"title": "Lottery",
"register": "Register",
"registered": "Registered",
"drawTime": "Draw Time",
"prizes": "Prizes",
"winners": "Winners"
},
"ticket": {
"title": "Tickets",
"createTicket": "Create Ticket",
"subject": "Subject",
"content": "Content",
"category": "Category",
"status": "Status",
"pending": "Pending",
"processing": "Processing",
"resolved": "Resolved",
"closed": "Closed",
"reply": "Reply"
},
"admin": {
"dashboard": "Dashboard",
"userManagement": "User Management",
"productManagement": "Product Management",
"categoryManagement": "Category Management",
"orderManagement": "Order Management",
"supplierManagement": "Supplier Management",
"lotteryManagement": "Lottery Management",
"ticketManagement": "Ticket Management",
"systemSettings": "System Settings",
"menuManagement": "Menu Management",
"smtpSettings": "SMTP Settings",
"paymentSettings": "Payment Settings",
"shippingSettings": "Shipping Settings",
"inviteSettings": "Invite Settings",
"channelFeeRate": "Channel Fee Rate"
},
"supplier": {
"dashboard": "Supplier Dashboard",
"orderManagement": "Order Management",
"inventoryManagement": "Inventory Management",
"confirmOrder": "Confirm Order",
"shipOrder": "Ship Order",
"shippingPhoto": "Shipping Photo",
"expressPhoto": "Express Photo",
"customsPhoto": "Customs Photo",
"updateStock": "Update Stock"
}
}
+145
View File
@@ -0,0 +1,145 @@
{
"common": {
"home": "ホーム",
"products": "商品",
"cart": "カート",
"orders": "注文",
"profile": "マイページ",
"login": "ログイン",
"register": "新規登録",
"logout": "ログアウト",
"search": "検索",
"save": "保存",
"cancel": "キャンセル",
"delete": "削除",
"edit": "編集",
"create": "作成",
"submit": "送信",
"confirm": "確認",
"back": "戻る",
"loading": "読み込み中...",
"success": "成功しました",
"error": "エラーが発生しました",
"noData": "データがありません",
"total": "{count}件",
"language": "言語"
},
"auth": {
"email": "メールアドレス",
"username": "ユーザー名",
"password": "パスワード",
"confirmPassword": "パスワード確認",
"inviteCode": "招待コード",
"forgotPassword": "パスワードをお忘れですか",
"resetPassword": "パスワードリセット",
"loginSuccess": "ログイン成功",
"registerSuccess": "登録成功",
"emailVerified": "メール認証済み",
"sendVerifyCode": "認証コード送信",
"verifyEmail": "メール認証"
},
"product": {
"name": "商品名",
"price": "価格",
"description": "説明",
"category": "カテゴリ",
"stock": "在庫",
"addToCart": "カートに追加",
"buyNow": "今すぐ購入",
"requireCredit": "購入資格が必要",
"creditCost": "必要資格数",
"creditReward": "付与資格数",
"minPurchase": "最小購入数",
"maxPurchase": "最大購入数",
"customFields": "カスタムフィールド",
"filterByCategory": "カテゴリで絞り込み",
"searchProducts": "商品を検索"
},
"cart": {
"empty": "カートは空です",
"total": "合計",
"checkout": "注文する",
"quantity": "数量",
"subtotal": "小計",
"removeItem": "削除"
},
"order": {
"orderNumber": "注文番号",
"status": "ステータス",
"totalAmount": "合計金額",
"createTime": "注文日時",
"pendingPayment": "支払待ち",
"pendingConfirm": "確認待ち",
"pendingShip": "発送待ち",
"shipped": "発送済み",
"completed": "完了",
"refunding": "返金中",
"refunded": "返金済み",
"cancelled": "キャンセル",
"applyRefund": "返金申請",
"refundReason": "返金理由",
"trackingNumber": "追跡番号",
"shippingAddress": "配送先",
"exportOrders": "注文エクスポート"
},
"address": {
"name": "受取人",
"phone": "電話番号",
"province": "都道府県",
"city": "市区町村",
"district": "区",
"detail": "住所",
"setDefault": "デフォルトに設定",
"addAddress": "住所を追加",
"editAddress": "住所を編集"
},
"lottery": {
"title": "抽選",
"register": "参加登録",
"registered": "登録済み",
"drawTime": "抽選日時",
"prizes": "賞品",
"winners": "当選者"
},
"ticket": {
"title": "チケット",
"createTicket": "チケット作成",
"subject": "件名",
"content": "内容",
"category": "カテゴリ",
"status": "ステータス",
"pending": "未対応",
"processing": "対応中",
"resolved": "解決済み",
"closed": "クローズ",
"reply": "返信"
},
"admin": {
"dashboard": "ダッシュボード",
"userManagement": "ユーザー管理",
"productManagement": "商品管理",
"categoryManagement": "カテゴリ管理",
"orderManagement": "注文管理",
"supplierManagement": "サプライヤー管理",
"lotteryManagement": "抽選管理",
"ticketManagement": "チケット管理",
"systemSettings": "システム設定",
"menuManagement": "メニュー管理",
"smtpSettings": "SMTP設定",
"paymentSettings": "決済設定",
"shippingSettings": "配送設定",
"inviteSettings": "招待設定",
"channelFeeRate": "チャネル手数料率"
},
"supplier": {
"dashboard": "サプライヤーダッシュボード",
"orderManagement": "注文管理",
"inventoryManagement": "在庫管理",
"confirmOrder": "注文確認",
"shipOrder": "発送確認",
"shippingPhoto": "発送写真",
"expressPhoto": "配送伝票写真",
"customsPhoto": "税関申告書",
"updateStock": "在庫更新"
}
}
+145
View File
@@ -0,0 +1,145 @@
{
"common": {
"home": "首页",
"products": "商品",
"cart": "购物车",
"orders": "订单",
"profile": "个人中心",
"login": "登录",
"register": "注册",
"logout": "退出登录",
"search": "搜索",
"save": "保存",
"cancel": "取消",
"delete": "删除",
"edit": "编辑",
"create": "创建",
"submit": "提交",
"confirm": "确认",
"back": "返回",
"loading": "加载中...",
"success": "操作成功",
"error": "操作失败",
"noData": "暂无数据",
"total": "共 {count} 条",
"language": "语言"
},
"auth": {
"email": "邮箱",
"username": "用户名",
"password": "密码",
"confirmPassword": "确认密码",
"inviteCode": "邀请码",
"forgotPassword": "忘记密码",
"resetPassword": "重置密码",
"loginSuccess": "登录成功",
"registerSuccess": "注册成功",
"emailVerified": "邮箱已验证",
"sendVerifyCode": "发送验证码",
"verifyEmail": "验证邮箱"
},
"product": {
"name": "商品名称",
"price": "价格",
"description": "描述",
"category": "分类",
"stock": "库存",
"addToCart": "加入购物车",
"buyNow": "立即购买",
"requireCredit": "需要购买资格",
"creditCost": "需要资格数",
"creditReward": "赠送资格数",
"minPurchase": "最低购买数量",
"maxPurchase": "最大购买数量",
"customFields": "自定义字段",
"filterByCategory": "按分类筛选",
"searchProducts": "搜索商品"
},
"cart": {
"empty": "购物车为空",
"total": "合计",
"checkout": "结算",
"quantity": "数量",
"subtotal": "小计",
"removeItem": "移除商品"
},
"order": {
"orderNumber": "订单号",
"status": "状态",
"totalAmount": "总金额",
"createTime": "创建时间",
"pendingPayment": "待支付",
"pendingConfirm": "待确认",
"pendingShip": "待发货",
"shipped": "已发货",
"completed": "已完成",
"refunding": "退款中",
"refunded": "已退款",
"cancelled": "已取消",
"applyRefund": "申请退款",
"refundReason": "退款原因",
"trackingNumber": "快递单号",
"shippingAddress": "收货地址",
"exportOrders": "导出订单"
},
"address": {
"name": "收件人",
"phone": "联系电话",
"province": "省份",
"city": "城市",
"district": "区县",
"detail": "详细地址",
"setDefault": "设为默认",
"addAddress": "添加地址",
"editAddress": "编辑地址"
},
"lottery": {
"title": "抽奖活动",
"register": "报名",
"registered": "已报名",
"drawTime": "抽奖时间",
"prizes": "奖品",
"winners": "中奖名单"
},
"ticket": {
"title": "工单",
"createTicket": "提交工单",
"subject": "标题",
"content": "内容",
"category": "分类",
"status": "状态",
"pending": "待处理",
"processing": "处理中",
"resolved": "已解决",
"closed": "已关闭",
"reply": "回复"
},
"admin": {
"dashboard": "仪表盘",
"userManagement": "用户管理",
"productManagement": "商品管理",
"categoryManagement": "分类管理",
"orderManagement": "订单管理",
"supplierManagement": "供货商管理",
"lotteryManagement": "抽奖管理",
"ticketManagement": "工单管理",
"systemSettings": "系统设置",
"menuManagement": "菜单管理",
"smtpSettings": "SMTP设置",
"paymentSettings": "支付通道设置",
"shippingSettings": "运费设置",
"inviteSettings": "邀请奖励设置",
"channelFeeRate": "通道费率"
},
"supplier": {
"dashboard": "供货商仪表盘",
"orderManagement": "订单管理",
"inventoryManagement": "库存管理",
"confirmOrder": "确认订单",
"shipOrder": "确认发货",
"shippingPhoto": "发货照片",
"expressPhoto": "快递单照片",
"customsPhoto": "报关清单",
"updateStock": "更新库存"
}
}
+383
View File
@@ -0,0 +1,383 @@
<template>
<div class="layout-wrapper">
<aside class="sidebar" :class="{ collapsed: sidebarCollapsed, 'mobile-open': mobileMenuOpen }">
<div class="sidebar-header">
<div class="logo" @click="$router.push('/admin')">
<div class="logo-icon">
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
</div>
<span class="logo-text">Admin</span>
</div>
</div>
<nav class="sidebar-nav">
<router-link to="/admin" class="nav-link" exact-active-class="router-link-active">
<el-icon><DataAnalysis /></el-icon>
<span class="nav-text">{{ $t('admin.dashboard') }}</span>
</router-link>
<router-link to="/admin/users" class="nav-link">
<el-icon><User /></el-icon>
<span class="nav-text">{{ $t('admin.userManagement') }}</span>
</router-link>
<router-link to="/admin/categories" class="nav-link">
<el-icon><Folder /></el-icon>
<span class="nav-text">{{ $t('admin.categoryManagement') }}</span>
</router-link>
<router-link to="/admin/products" class="nav-link">
<el-icon><Goods /></el-icon>
<span class="nav-text">{{ $t('admin.productManagement') }}</span>
</router-link>
<router-link to="/admin/orders" class="nav-link">
<el-icon><List /></el-icon>
<span class="nav-text">{{ $t('admin.orderManagement') }}</span>
</router-link>
<router-link to="/admin/suppliers" class="nav-link">
<el-icon><Van /></el-icon>
<span class="nav-text">{{ $t('admin.supplierManagement') }}</span>
</router-link>
<router-link to="/admin/lotteries" class="nav-link">
<el-icon><Trophy /></el-icon>
<span class="nav-text">{{ $t('admin.lotteryManagement') }}</span>
</router-link>
<router-link to="/admin/tickets" class="nav-link">
<el-icon><ChatDotSquare /></el-icon>
<span class="nav-text">{{ $t('admin.ticketManagement') }}</span>
</router-link>
<router-link to="/admin/articles" class="nav-link">
<el-icon><Document /></el-icon>
<span class="nav-text">资讯管理</span>
</router-link>
<router-link to="/admin/settings" class="nav-link">
<el-icon><Setting /></el-icon>
<span class="nav-text">{{ $t('admin.systemSettings') }}</span>
</router-link>
</nav>
<div class="sidebar-footer">
<el-dropdown trigger="click" @command="changeLocale" placement="right">
<div class="nav-link">
<el-icon><Compass /></el-icon>
<span class="nav-text">语言</span>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="zh">中文</el-dropdown-item>
<el-dropdown-item command="en">English</el-dropdown-item>
<el-dropdown-item command="ja">日本語</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-dropdown trigger="click" @command="handleCommand" placement="right">
<div class="nav-link user-link">
<el-avatar :size="22" class="sidebar-avatar">{{ user?.username?.charAt(0).toUpperCase() }}</el-avatar>
<span class="nav-text">{{ user?.username }}</span>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="home">返回前台</el-dropdown-item>
<el-dropdown-item divided command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<div class="nav-link collapse-btn" @click="sidebarCollapsed = !sidebarCollapsed">
<el-icon><Fold v-if="!sidebarCollapsed" /><Expand v-else /></el-icon>
<span class="nav-text">收起</span>
</div>
</div>
</aside>
<div class="main-container" :class="{ 'sidebar-collapsed': sidebarCollapsed }">
<header class="topbar">
<button class="menu-btn" @click="mobileMenuOpen = !mobileMenuOpen">
<el-icon><Expand /></el-icon>
</button>
<h1 class="page-title">{{ currentPageTitle }}</h1>
</header>
<main class="page-content">
<router-view />
</main>
</div>
<div v-if="mobileMenuOpen" class="overlay" @click="mobileMenuOpen = false"></div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { DataAnalysis, User, Folder, Goods, List, Van, Trophy, ChatDotSquare, Setting, Document, Compass, Fold, Expand } from '@element-plus/icons-vue'
import { useUserStore } from '../store/user'
const router = useRouter()
const route = useRoute()
const { locale, t } = useI18n()
const userStore = useUserStore()
const sidebarCollapsed = ref(false)
const mobileMenuOpen = ref(false)
const user = computed(() => userStore.user)
const pageTitles: Record<string, string> = {
'/admin': '控制台',
'/admin/users': '用户管理',
'/admin/categories': '分类管理',
'/admin/products': '商品管理',
'/admin/orders': '订单管理',
'/admin/suppliers': '供应商管理',
'/admin/lotteries': '抽奖管理',
'/admin/tickets': '工单管理',
'/admin/articles': '资讯管理',
'/admin/settings': '系统设置'
}
const currentPageTitle = computed(() => {
return pageTitles[route.path] || t('admin.dashboard')
})
function changeLocale(lang: string) {
locale.value = lang
localStorage.setItem('locale', lang)
}
function handleCommand(command: string) {
switch (command) {
case 'home': router.push('/'); break
case 'logout': userStore.logout(); router.push('/login'); break
}
}
</script>
<style scoped lang="scss">
.layout-wrapper {
display: flex;
min-height: 100vh;
background: #1a1a2e;
}
.sidebar {
width: 220px;
background: #16162a;
border-right: 1px solid rgba(255, 255, 255, 0.06);
position: fixed;
left: 0;
top: 0;
bottom: 0;
z-index: 100;
display: flex;
flex-direction: column;
transition: width 0.2s, transform 0.2s;
overflow: hidden;
}
.sidebar.collapsed {
width: 60px;
}
.sidebar-header {
padding: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.logo {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
}
.logo-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: linear-gradient(135deg, #4e6ef2, #7c5cfc);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
flex-shrink: 0;
}
.logo-text {
font-size: 16px;
font-weight: 700;
color: #fff;
white-space: nowrap;
}
.sidebar.collapsed .logo-text {
display: none;
}
.sidebar-nav {
flex: 1;
padding: 8px;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
.nav-link {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 8px;
color: rgba(255, 255, 255, 0.55);
font-size: 14px;
cursor: pointer;
transition: all 0.15s;
text-decoration: none;
margin-bottom: 2px;
}
.nav-link:hover {
color: rgba(255, 255, 255, 0.9);
background: rgba(255, 255, 255, 0.06);
}
.nav-link.router-link-active {
color: #fff;
background: rgba(78, 110, 242, 0.15);
}
.nav-link .el-icon {
font-size: 18px;
flex-shrink: 0;
}
.nav-text {
white-space: nowrap;
overflow: hidden;
}
.sidebar.collapsed .nav-text {
display: none;
}
.sidebar-footer {
padding: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.sidebar-avatar {
background: linear-gradient(135deg, #4e6ef2, #7c5cfc);
color: #fff;
font-size: 10px;
font-weight: 600;
}
.user-link {
gap: 8px;
}
.collapse-btn {
margin-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding-top: 12px;
}
.main-container {
margin-left: 220px;
flex: 1;
display: flex;
flex-direction: column;
min-height: 100vh;
transition: margin-left 0.2s;
}
.main-container.sidebar-collapsed {
margin-left: 60px;
}
.topbar {
height: 56px;
background: rgba(26, 26, 46, 0.9);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
display: flex;
align-items: center;
padding: 0 24px;
position: sticky;
top: 0;
z-index: 50;
}
.menu-btn {
display: none;
width: 36px;
height: 36px;
border-radius: 8px;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.6);
background: none;
border: none;
cursor: pointer;
}
.menu-btn:hover {
background: rgba(255, 255, 255, 0.06);
}
.page-title {
font-size: 18px;
font-weight: 600;
color: #fff;
margin: 0;
}
.page-content {
flex: 1;
padding: 24px;
overflow-y: auto;
}
.overlay {
display: none;
}
@media (max-width: 768px) {
.sidebar {
transform: translateX(-100%);
width: 220px !important;
}
.sidebar.mobile-open {
transform: translateX(0);
}
.sidebar.collapsed {
width: 220px !important;
}
.main-container,
.main-container.sidebar-collapsed {
margin-left: 0;
}
.menu-btn {
display: flex;
}
.collapse-btn {
display: none;
}
.overlay {
display: block;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 99;
}
}
</style>
+329
View File
@@ -0,0 +1,329 @@
<template>
<div class="layout-wrapper">
<aside class="sidebar" :class="{ collapsed: sidebarCollapsed, 'mobile-open': mobileMenuOpen }">
<div class="sidebar-header">
<div class="logo" @click="$router.push('/supplier')">
<div class="logo-icon">
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
</div>
<span class="logo-text">Supplier</span>
</div>
</div>
<nav class="sidebar-nav">
<router-link to="/supplier" class="nav-link" exact-active-class="router-link-active">
<el-icon><DataAnalysis /></el-icon>
<span class="nav-text">{{ $t('supplier.dashboard') }}</span>
</router-link>
<router-link to="/supplier/orders" class="nav-link">
<el-icon><List /></el-icon>
<span class="nav-text">{{ $t('supplier.orderManagement') }}</span>
</router-link>
<router-link to="/supplier/inventory" class="nav-link">
<el-icon><Box /></el-icon>
<span class="nav-text">{{ $t('supplier.inventoryManagement') }}</span>
</router-link>
</nav>
<div class="sidebar-footer">
<el-dropdown trigger="click" @command="handleCommand" placement="right">
<div class="nav-link user-link">
<el-avatar :size="22" class="sidebar-avatar">{{ user?.username?.charAt(0).toUpperCase() }}</el-avatar>
<span class="nav-text">{{ user?.username }}</span>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="home">返回前台</el-dropdown-item>
<el-dropdown-item divided command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<div class="nav-link collapse-btn" @click="sidebarCollapsed = !sidebarCollapsed">
<el-icon><Fold v-if="!sidebarCollapsed" /><Expand v-else /></el-icon>
<span class="nav-text">收起</span>
</div>
</div>
</aside>
<div class="main-container" :class="{ 'sidebar-collapsed': sidebarCollapsed }">
<header class="topbar">
<button class="menu-btn" @click="mobileMenuOpen = !mobileMenuOpen">
<el-icon><Expand /></el-icon>
</button>
<h1 class="page-title">{{ currentPageTitle }}</h1>
</header>
<main class="page-content">
<router-view />
</main>
</div>
<div v-if="mobileMenuOpen" class="overlay" @click="mobileMenuOpen = false"></div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { DataAnalysis, List, Box, Fold, Expand } from '@element-plus/icons-vue'
import { useUserStore } from '../store/user'
const router = useRouter()
const route = useRoute()
const { t } = useI18n()
const userStore = useUserStore()
const sidebarCollapsed = ref(false)
const mobileMenuOpen = ref(false)
const user = computed(() => userStore.user)
const pageTitles: Record<string, string> = {
'/supplier': '控制台',
'/supplier/orders': '订单管理',
'/supplier/inventory': '库存管理'
}
const currentPageTitle = computed(() => {
return pageTitles[route.path] || t('supplier.dashboard')
})
function handleCommand(command: string) {
switch (command) {
case 'home': router.push('/'); break
case 'logout': userStore.logout(); router.push('/login'); break
}
}
</script>
<style scoped lang="scss">
.layout-wrapper {
display: flex;
min-height: 100vh;
background: #1a1a2e;
}
.sidebar {
width: 220px;
background: #16162a;
border-right: 1px solid rgba(255, 255, 255, 0.06);
position: fixed;
left: 0;
top: 0;
bottom: 0;
z-index: 100;
display: flex;
flex-direction: column;
transition: width 0.2s, transform 0.2s;
overflow: hidden;
}
.sidebar.collapsed {
width: 60px;
}
.sidebar-header {
padding: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.logo {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
}
.logo-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: linear-gradient(135deg, #4e6ef2, #7c5cfc);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
flex-shrink: 0;
}
.logo-text {
font-size: 16px;
font-weight: 700;
color: #fff;
white-space: nowrap;
}
.sidebar.collapsed .logo-text {
display: none;
}
.sidebar-nav {
flex: 1;
padding: 8px;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
.nav-link {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 8px;
color: rgba(255, 255, 255, 0.55);
font-size: 14px;
cursor: pointer;
transition: all 0.15s;
text-decoration: none;
margin-bottom: 2px;
}
.nav-link:hover {
color: rgba(255, 255, 255, 0.9);
background: rgba(255, 255, 255, 0.06);
}
.nav-link.router-link-active {
color: #fff;
background: rgba(78, 110, 242, 0.15);
}
.nav-link .el-icon {
font-size: 18px;
flex-shrink: 0;
}
.nav-text {
white-space: nowrap;
overflow: hidden;
}
.sidebar.collapsed .nav-text {
display: none;
}
.sidebar-footer {
padding: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.sidebar-avatar {
background: linear-gradient(135deg, #4e6ef2, #7c5cfc);
color: #fff;
font-size: 10px;
font-weight: 600;
}
.user-link {
gap: 8px;
}
.collapse-btn {
margin-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding-top: 12px;
}
.main-container {
margin-left: 220px;
flex: 1;
display: flex;
flex-direction: column;
min-height: 100vh;
transition: margin-left 0.2s;
}
.main-container.sidebar-collapsed {
margin-left: 60px;
}
.topbar {
height: 56px;
background: rgba(26, 26, 46, 0.9);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
display: flex;
align-items: center;
padding: 0 24px;
position: sticky;
top: 0;
z-index: 50;
}
.menu-btn {
display: none;
width: 36px;
height: 36px;
border-radius: 8px;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.6);
background: none;
border: none;
cursor: pointer;
}
.menu-btn:hover {
background: rgba(255, 255, 255, 0.06);
}
.page-title {
font-size: 18px;
font-weight: 600;
color: #fff;
margin: 0;
}
.page-content {
flex: 1;
padding: 24px;
overflow-y: auto;
}
.overlay {
display: none;
}
@media (max-width: 768px) {
.sidebar {
transform: translateX(-100%);
width: 220px !important;
}
.sidebar.mobile-open {
transform: translateX(0);
}
.sidebar.collapsed {
width: 220px !important;
}
.main-container,
.main-container.sidebar-collapsed {
margin-left: 0;
}
.menu-btn {
display: flex;
}
.collapse-btn {
display: none;
}
.overlay {
display: block;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 99;
}
}
</style>
+479
View File
@@ -0,0 +1,479 @@
<template>
<div class="layout-wrapper" :class="{ 'hide-sidebar': hideSidebar }">
<aside v-if="!hideSidebar" class="sidebar" :class="{ collapsed: sidebarCollapsed, 'mobile-open': mobileMenuOpen }">
<div class="sidebar-header">
<div class="logo" @click="$router.push('/')">
<div class="logo-icon">
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
</div>
<span class="logo-text">Sale</span>
</div>
</div>
<nav class="sidebar-nav">
<router-link to="/" class="nav-link" exact-active-class="router-link-active">
<el-icon><HomeFilled /></el-icon>
<span class="nav-text">首页</span>
</router-link>
<router-link to="/products" class="nav-link">
<el-icon><Goods /></el-icon>
<span class="nav-text">商品</span>
</router-link>
<router-link to="/articles" class="nav-link">
<el-icon><Document /></el-icon>
<span class="nav-text">资讯</span>
</router-link>
<router-link to="/lotteries" class="nav-link">
<el-icon><Trophy /></el-icon>
<span class="nav-text">抽奖</span>
</router-link>
<router-link to="/cart" class="nav-link">
<el-icon><ShoppingCart /></el-icon>
<span class="nav-text">购物车</span>
<span v-if="cartCount > 0" class="nav-badge">{{ cartCount }}</span>
</router-link>
</nav>
<div class="sidebar-footer">
<el-dropdown trigger="click" @command="changeLocale" placement="right" class="sidebar-dropdown">
<div class="nav-link">
<el-icon><Compass /></el-icon>
<span class="nav-text">语言</span>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="zh">中文</el-dropdown-item>
<el-dropdown-item command="en">English</el-dropdown-item>
<el-dropdown-item command="ja">日本語</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<template v-if="isLoggedIn">
<el-dropdown trigger="click" @command="handleCommand" placement="right" class="sidebar-dropdown">
<div class="nav-link user-link">
<el-avatar :size="22" class="sidebar-avatar">{{ user?.username?.charAt(0).toUpperCase() }}</el-avatar>
<span class="nav-text">{{ user?.username }}</span>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">个人中心</el-dropdown-item>
<el-dropdown-item command="orders">我的订单</el-dropdown-item>
<el-dropdown-item v-if="user?.role === 'admin'" command="admin">管理后台</el-dropdown-item>
<el-dropdown-item v-if="user?.role === 'supplier'" command="supplier">供应商后台</el-dropdown-item>
<el-dropdown-item divided command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<template v-else>
<router-link to="/login" class="nav-link">
<el-icon><User /></el-icon>
<span class="nav-text">登录</span>
</router-link>
</template>
<div class="nav-link collapse-btn" @click="sidebarCollapsed = !sidebarCollapsed">
<el-icon><Fold v-if="!sidebarCollapsed" /><Expand v-else /></el-icon>
<span class="nav-text">收起</span>
</div>
</div>
</aside>
<div class="main-container" :class="{ 'sidebar-collapsed': sidebarCollapsed && !hideSidebar }">
<header v-if="!hideSidebar" class="topbar">
<button class="menu-btn" @click="mobileMenuOpen = !mobileMenuOpen">
<el-icon><Expand /></el-icon>
</button>
<div class="search-wrap">
<el-input v-model="searchQuery" placeholder="搜索商品、分类、资讯..." @keyup.enter="handleSearch">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
</div>
</header>
<header v-else class="auth-topbar">
<div class="auth-logo" @click="$router.push('/')">
<div class="logo-icon">
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
</div>
<span class="logo-text">Sale</span>
</div>
</header>
<main class="page-content" :class="{ 'auth-page': hideSidebar }"><router-view /></main>
</div>
<div v-if="mobileMenuOpen && !hideSidebar" class="overlay" @click="mobileMenuOpen = false"></div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { HomeFilled, Goods, Document, Trophy, ShoppingCart, Compass, User, Fold, Expand, Search } from '@element-plus/icons-vue'
import { useUserStore } from '../../store/user'
import { useCartStore } from '../../store/cart'
const router = useRouter()
const route = useRoute()
const { locale } = useI18n()
const userStore = useUserStore()
const cartStore = useCartStore()
const searchQuery = ref('')
const sidebarCollapsed = ref(false)
const mobileMenuOpen = ref(false)
const isLoggedIn = computed(() => userStore.isLoggedIn)
const user = computed(() => userStore.user)
const cartCount = computed(() => cartStore.totalCount)
const hideSidebar = computed(() => route.meta.hideSidebar as boolean)
watch(route, () => { mobileMenuOpen.value = false })
function changeLocale(lang: string) {
locale.value = lang
localStorage.setItem('locale', lang)
}
function handleCommand(command: string) {
switch (command) {
case 'profile': router.push('/profile'); break
case 'orders': router.push('/orders'); break
case 'admin': router.push('/admin'); break
case 'supplier': router.push('/supplier'); break
case 'logout': userStore.logout(); router.push('/login'); break
}
}
function handleSearch() {
if (searchQuery.value.trim()) {
router.push({ path: '/products', query: { search: searchQuery.value } })
}
}
if (isLoggedIn.value) cartStore.fetchCart()
</script>
<style scoped lang="scss">
.layout-wrapper {
display: flex;
min-height: 100vh;
background: #1a1a2e;
}
.sidebar {
width: 220px;
background: #16162a;
border-right: 1px solid rgba(255, 255, 255, 0.06);
position: fixed;
left: 0;
top: 0;
bottom: 0;
z-index: 100;
display: flex;
flex-direction: column;
transition: width 0.2s, transform 0.2s;
overflow: hidden;
}
.sidebar.collapsed {
width: 60px;
}
.sidebar-header {
padding: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.logo {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
}
.logo-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: linear-gradient(135deg, #4e6ef2, #7c5cfc);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
flex-shrink: 0;
}
.logo-text {
font-size: 16px;
font-weight: 700;
color: #fff;
white-space: nowrap;
}
.sidebar.collapsed .logo-text {
display: none;
}
.sidebar-nav {
flex: 1;
padding: 8px;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
.nav-link {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 8px;
color: rgba(255, 255, 255, 0.55);
font-size: 14px;
cursor: pointer;
transition: all 0.15s;
text-decoration: none;
margin-bottom: 2px;
}
.nav-link:hover {
color: rgba(255, 255, 255, 0.9);
background: rgba(255, 255, 255, 0.06);
}
.nav-link.router-link-active {
color: #fff;
background: rgba(78, 110, 242, 0.15);
}
.nav-link .el-icon {
font-size: 18px;
flex-shrink: 0;
}
.nav-text {
white-space: nowrap;
overflow: hidden;
}
.sidebar.collapsed .nav-text {
display: none;
}
.nav-badge {
margin-left: auto;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: #4e6ef2;
color: #fff;
font-size: 11px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
.sidebar.collapsed .nav-badge {
display: none;
}
.sidebar-footer {
padding: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
display: flex;
flex-direction: column;
gap: 2px;
.nav-link {
width: 100%;
}
}
.sidebar-dropdown {
width: 100%;
}
.sidebar-avatar {
background: linear-gradient(135deg, #4e6ef2, #7c5cfc);
color: #fff;
font-size: 10px;
font-weight: 600;
}
.user-link {
gap: 8px;
}
.collapse-btn {
margin-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding-top: 12px;
}
.main-container {
margin-left: 220px;
flex: 1;
display: flex;
flex-direction: column;
min-height: 100vh;
transition: margin-left 0.2s;
}
.main-container.sidebar-collapsed {
margin-left: 60px;
}
.layout-wrapper.hide-sidebar .main-container {
margin-left: 0;
}
.topbar {
height: 56px;
background: rgba(26, 26, 46, 0.9);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
display: flex;
align-items: center;
padding: 0 24px;
position: sticky;
top: 0;
z-index: 50;
gap: 16px;
}
.auth-topbar {
height: 56px;
background: rgba(26, 26, 46, 0.9);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
display: flex;
align-items: center;
padding: 0 24px;
}
.auth-logo {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
}
.menu-btn {
display: none;
width: 36px;
height: 36px;
border-radius: 8px;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.6);
background: none;
border: none;
cursor: pointer;
}
.menu-btn:hover {
background: rgba(255, 255, 255, 0.06);
}
.search-wrap {
flex: 1;
max-width: 400px;
}
.search-wrap :deep(.el-input__wrapper) {
background: rgba(255, 255, 255, 0.06) !important;
border: 1px solid rgba(255, 255, 255, 0.08) !important;
border-radius: 8px !important;
box-shadow: none !important;
height: 36px !important;
}
.search-wrap :deep(.el-input__wrapper:hover) {
border-color: rgba(255, 255, 255, 0.15) !important;
}
.search-wrap :deep(.el-input__wrapper.is-focus) {
border-color: #4e6ef2 !important;
box-shadow: 0 0 0 2px rgba(78, 110, 242, 0.15) !important;
}
.search-wrap :deep(.el-input__inner) {
color: rgba(255, 255, 255, 0.85) !important;
font-size: 13px;
}
.search-wrap :deep(.el-input__inner::placeholder) {
color: rgba(255, 255, 255, 0.3) !important;
}
.search-wrap :deep(.el-input__prefix .el-icon) {
color: rgba(255, 255, 255, 0.3) !important;
}
.page-content {
flex: 1;
padding: 24px;
overflow-y: auto;
}
.page-content.auth-page {
padding: 0;
display: flex;
align-items: center;
justify-content: center;
flex: 1;
}
.overlay {
display: none;
}
@media (max-width: 768px) {
.sidebar {
transform: translateX(-100%);
width: 220px !important;
}
.sidebar.mobile-open {
transform: translateX(0);
}
.sidebar.collapsed {
width: 220px !important;
}
.main-container,
.main-container.sidebar-collapsed {
margin-left: 0;
}
.menu-btn {
display: flex;
}
.collapse-btn {
display: none;
}
.overlay {
display: block;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 99;
}
}
</style>
+16
View File
@@ -0,0 +1,16 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import App from './App.vue'
import router from './router'
import i18n from './i18n'
import './assets/styles/global.scss'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(i18n)
app.use(ElementPlus)
app.mount('#app')
+111
View File
@@ -0,0 +1,111 @@
import { createRouter, createWebHistory } from 'vue-router'
import { systemApi } from '../api'
const routes = [
{
path: '/install',
name: 'Install',
component: () => import('../views/Install.vue'),
meta: { install: true },
},
{
path: '/',
component: () => import('../layouts/scheme4/UserLayout.vue'),
children: [
{ path: '', name: 'Home', component: () => import('../views/scheme4/Home.vue') },
{ path: 'products', name: 'Products', component: () => import('../views/user/Products.vue') },
{ path: 'products/:id', name: 'ProductDetail', component: () => import('../views/user/ProductDetail.vue') },
{ path: 'cart', name: 'Cart', component: () => import('../views/user/Cart.vue') },
{ path: 'orders', name: 'Orders', component: () => import('../views/user/Orders.vue') },
{ path: 'orders/:id', name: 'OrderDetail', component: () => import('../views/user/OrderDetail.vue') },
{ path: 'profile', name: 'Profile', component: () => import('../views/user/Profile.vue') },
{ path: 'addresses', name: 'Addresses', component: () => import('../views/user/Addresses.vue') },
{ path: 'lotteries', name: 'Lotteries', component: () => import('../views/user/Lotteries.vue') },
{ path: 'lotteries/:id', name: 'LotteryDetail', component: () => import('../views/user/LotteryDetail.vue') },
{ path: 'tickets', name: 'Tickets', component: () => import('../views/user/Tickets.vue') },
{ path: 'tickets/create', name: 'CreateTicket', component: () => import('../views/user/CreateTicket.vue') },
{ path: 'articles', name: 'Articles', component: () => import('../views/user/Articles.vue') },
{ path: 'articles/:id', name: 'ArticleDetail', component: () => import('../views/user/ArticleDetail.vue') },
{ path: 'login', name: 'Login', component: () => import('../views/auth/Login.vue'), meta: { guest: true, hideSidebar: true } },
{ path: 'register', name: 'Register', component: () => import('../views/auth/Register.vue'), meta: { guest: true, hideSidebar: true } },
{ path: 'forgot-password', name: 'ForgotPassword', component: () => import('../views/auth/ForgotPassword.vue'), meta: { guest: true, hideSidebar: true } },
],
},
{
path: '/admin',
component: () => import('../layouts/AdminLayout.vue'),
meta: { requiresAuth: true, role: 'admin' },
children: [
{ path: '', name: 'AdminDashboard', component: () => import('../views/admin/Dashboard.vue') },
{ path: 'users', name: 'AdminUsers', component: () => import('../views/admin/Users.vue') },
{ path: 'categories', name: 'AdminCategories', component: () => import('../views/admin/Categories.vue') },
{ path: 'products', name: 'AdminProducts', component: () => import('../views/admin/Products.vue') },
{ path: 'orders', name: 'AdminOrders', component: () => import('../views/admin/Orders.vue') },
{ path: 'suppliers', name: 'AdminSuppliers', component: () => import('../views/admin/Suppliers.vue') },
{ path: 'lotteries', name: 'AdminLotteries', component: () => import('../views/admin/Lotteries.vue') },
{ path: 'tickets', name: 'AdminTickets', component: () => import('../views/admin/Tickets.vue') },
{ path: 'settings', name: 'AdminSettings', component: () => import('../views/admin/Settings.vue') },
{ path: 'articles', name: 'AdminArticles', component: () => import('../views/admin/Articles.vue') },
],
},
{
path: '/supplier',
component: () => import('../layouts/SupplierLayout.vue'),
meta: { requiresAuth: true, role: 'supplier' },
children: [
{ path: '', name: 'SupplierDashboard', component: () => import('../views/supplier/Dashboard.vue') },
{ path: 'orders', name: 'SupplierOrders', component: () => import('../views/supplier/Orders.vue') },
{ path: 'inventory', name: 'SupplierInventory', component: () => import('../views/supplier/Inventory.vue') },
],
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
let installedChecked = false
let isInstalled = false
async function checkInstalled() {
if (installedChecked) return isInstalled
try {
const res: any = await systemApi.checkInstalled()
isInstalled = res.installed
installedChecked = true
return isInstalled
} catch {
return true
}
}
router.beforeEach(async (to, _from, next) => {
const installed = await checkInstalled()
if (!installed && to.path !== '/install') {
next('/install')
return
}
if (installed && to.path === '/install') {
next('/')
return
}
const token = localStorage.getItem('token')
const userStr = localStorage.getItem('user')
const user = userStr && userStr !== 'undefined' ? JSON.parse(userStr) : null
if (to.meta.requiresAuth && !token) {
next('/login')
} else if (to.meta.role && user?.role !== to.meta.role && user?.role !== 'admin') {
next('/')
} else if (to.meta.guest && token) {
next('/')
} else {
next()
}
})
export default router
+62
View File
@@ -0,0 +1,62 @@
import { ref, watch } from 'vue'
import { defineStore } from 'pinia'
import { cartApi } from '../api'
export const useCartStore = defineStore('cart', () => {
const items = ref<any[]>([])
const loading = ref(false)
const totalAmount = ref(0)
const totalCount = ref(0)
async function fetchCart() {
loading.value = true
try {
const res: any = await cartApi.list()
items.value = res.data || []
calcTotals()
} finally {
loading.value = false
}
}
async function addItem(productId: number, quantity: number) {
await cartApi.add({ product_id: productId, quantity })
await fetchCart()
}
async function updateItem(id: number, quantity: number) {
await cartApi.update(id, { quantity })
await fetchCart()
}
async function removeItem(id: number) {
await cartApi.delete(id)
await fetchCart()
}
async function removeItems(ids: number[]) {
for (const id of ids) {
await cartApi.delete(id)
}
await fetchCart()
}
function calcTotals() {
totalAmount.value = items.value.reduce((sum, item) => sum + (item.product?.price || 0) * item.quantity, 0)
totalCount.value = items.value.reduce((sum, item) => sum + item.quantity, 0)
}
watch(items, calcTotals, { deep: true })
return {
items,
loading,
totalAmount,
totalCount,
fetchCart,
addItem,
updateItem,
removeItem,
removeItems
}
})
+53
View File
@@ -0,0 +1,53 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { authApi, userApi } from '../api'
export const useUserStore = defineStore('user', () => {
const token = ref(localStorage.getItem('token') || '')
const userStr = localStorage.getItem('user')
const user = ref<any>(userStr && userStr !== 'undefined' ? JSON.parse(userStr) : null)
const isLoggedIn = computed(() => !!token.value)
const isAdmin = computed(() => user.value?.role === 'admin')
const isSupplier = computed(() => user.value?.role === 'supplier')
async function login(data: { email: string; password: string }) {
const res: any = await authApi.login(data)
token.value = res.token
user.value = res.user
localStorage.setItem('token', res.token)
localStorage.setItem('user', JSON.stringify(res.user))
return res
}
async function register(data: any) {
const res: any = await authApi.register(data)
token.value = res.token
user.value = res.user
localStorage.setItem('token', res.token)
localStorage.setItem('user', JSON.stringify(res.user))
return res
}
async function fetchProfile() {
const res: any = await userApi.getProfile()
user.value = res.data || res
localStorage.setItem('user', JSON.stringify(res.data || res))
}
function logout() {
token.value = ''
user.value = null
localStorage.removeItem('token')
localStorage.removeItem('user')
}
function setUser(userData: any, userToken: string) {
token.value = userToken
user.value = userData
localStorage.setItem('token', userToken)
localStorage.setItem('user', JSON.stringify(userData))
}
return { token, user, isLoggedIn, isAdmin, isSupplier, login, register, fetchProfile, logout, setUser }
})
+20
View File
@@ -0,0 +1,20 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api'
const BASE_URL = API_BASE_URL.replace('/api', '')
export function getImageUrl(path: string): string {
if (!path) return ''
if (path.startsWith('http://') || path.startsWith('https://')) {
return path
}
if (path.startsWith('/')) {
return BASE_URL + path
}
return BASE_URL + '/' + path
}
export function getFirstImage(images: string): string {
if (!images) return ''
const arr = images.split(',').map((s: string) => s.trim()).filter(Boolean)
const first = arr[0] || ''
return getImageUrl(first)
}
+41
View File
@@ -0,0 +1,41 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
})
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error),
)
api.interceptors.response.use(
(response) => response.data,
(error) => {
const message = error.response?.data?.error || error.message || 'Request failed'
if (error.response?.status === 401) {
const hadToken = !!localStorage.getItem('token')
localStorage.removeItem('token')
localStorage.removeItem('user')
if (hadToken) {
window.location.href = '/login'
}
} else {
ElMessage.error(message)
}
return Promise.reject(error)
},
)
export default api
+155
View File
@@ -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>
+176
View File
@@ -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>
+163
View File
@@ -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>
+95
View File
@@ -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>
+316
View File
@@ -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>
+140
View File
@@ -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>
+516
View File
@@ -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">支持 jpgpnggifwebp 格式</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>
+126
View File
@@ -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>
+159
View File
@@ -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>
+135
View File
@@ -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>
+123
View File
@@ -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>
+119
View File
@@ -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>
+134
View File
@@ -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>
+137
View File
@@ -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>
+517
View File
@@ -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>
+80
View File
@@ -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>
+48
View File
@@ -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>
+109
View File
@@ -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>
+83
View File
@@ -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>
+48
View File
@@ -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>
+111
View File
@@ -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>
+959
View File
@@ -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>
+42
View File
@@ -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>
+218
View File
@@ -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>
+97
View File
@@ -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>
+65
View File
@@ -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>
+186
View File
@@ -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>
+66
View File
@@ -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>
+128
View File
@@ -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>
+286
View File
@@ -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>
+95
View File
@@ -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>
+67
View File
@@ -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>
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "es2023",
"module": "esnext",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 3004,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
})