提交
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/node_modules
|
||||
/dist
|
||||
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
10
.idea/UniappTool.xml
generated
Normal file
10
.idea/UniappTool.xml
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="cn.fjdmy.uniapp.UniappProjectDataService">
|
||||
<option name="generalBasePath" value="$PROJECT_DIR$" />
|
||||
<option name="manifestPath" value="$PROJECT_DIR$/manifest.json" />
|
||||
<option name="pagesPath" value="$PROJECT_DIR$/pages.json" />
|
||||
<option name="scanNum" value="1" />
|
||||
<option name="type" value="store" />
|
||||
</component>
|
||||
</project>
|
||||
12
.idea/jurong_supplier_management.iml
generated
Normal file
12
.idea/jurong_supplier_management.iml
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/jurong_supplier_management.iml" filepath="$PROJECT_DIR$/.idea/jurong_supplier_management.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
80
index.html
Normal file
80
index.html
Normal file
@@ -0,0 +1,80 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>后台管理系统</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
background-color: #f0f2f5;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #409eff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="loading">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
2802
package-lock.json
generated
Normal file
2802
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
package.json
Normal file
29
package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "admin-system",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5176",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||
"axios": "^1.6.2",
|
||||
"dayjs": "^1.11.13",
|
||||
"echarts": "^5.4.3",
|
||||
"element-plus": "^2.4.4",
|
||||
"nprogress": "^0.2.0",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.3.11",
|
||||
"vue-echarts": "^6.6.1",
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.5.2",
|
||||
"sass": "^1.69.5",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
6
public/logo.svg
Normal file
6
public/logo.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="64" height="64" rx="12" fill="#409EFF"/>
|
||||
<path d="M32 16C40.8366 16 48 23.1634 48 32C48 40.8366 40.8366 48 32 48C23.1634 48 16 40.8366 16 32C16 23.1634 23.1634 16 32 16Z" fill="white"/>
|
||||
<path d="M28 24H36V28H32V36H28V24Z" fill="#409EFF"/>
|
||||
<path d="M28 38H36V42H28V38Z" fill="#409EFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 409 B |
237
src/App.vue
Normal file
237
src/App.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
onMounted(() => {
|
||||
// 检查用户登录状态
|
||||
userStore.checkAuth()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 全局样式 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
background-color: #f0f2f5;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Element Plus 样式覆盖 */
|
||||
.el-menu {
|
||||
border-right: none !important;
|
||||
}
|
||||
|
||||
.el-menu-item:hover {
|
||||
background-color: #ecf5ff !important;
|
||||
color: #409eff !important;
|
||||
}
|
||||
|
||||
.el-menu-item.is-active {
|
||||
background-color: #409eff !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.el-submenu__title:hover {
|
||||
background-color: #ecf5ff !important;
|
||||
color: #409eff !important;
|
||||
}
|
||||
|
||||
.el-card {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.el-button {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.el-input__wrapper {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.el-select .el-input__wrapper {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.el-table {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.el-pagination {
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* 自定义按钮样式 */
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #409eff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.empty-state .el-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
.stat-card {
|
||||
background: #ffffff;
|
||||
color: white;
|
||||
border: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.stat-card .stat-icon {
|
||||
font-size: 48px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.stat-card .stat-number {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.stat-card .stat-label {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.el-aside {
|
||||
width: 200px !important;
|
||||
}
|
||||
|
||||
.mobile-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 自定义动画 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-left-enter-active,
|
||||
.slide-left-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-left-enter-from {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-left-leave-to {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-up-enter-from {
|
||||
transform: translateY(30px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-up-leave-to {
|
||||
transform: translateY(-30px);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
298
src/components/Captcha.vue
Normal file
298
src/components/Captcha.vue
Normal file
@@ -0,0 +1,298 @@
|
||||
<template>
|
||||
<div class="captcha-container">
|
||||
<div class="captcha-input-group">
|
||||
<el-input
|
||||
v-model="captchaInput"
|
||||
:placeholder="placeholder"
|
||||
:size="size"
|
||||
clearable
|
||||
@keyup.enter="$emit('verify', { captchaId, captchaText: captchaInput })"
|
||||
@input="handleInput"
|
||||
/>
|
||||
<div class="captcha-image-wrapper" @click="refreshCaptcha">
|
||||
<img
|
||||
v-if="captchaImage"
|
||||
:src="captchaImage"
|
||||
alt="验证码"
|
||||
class="captcha-image"
|
||||
/>
|
||||
<div v-else class="captcha-loading">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
<div class="captcha-refresh-hint">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>点击刷新</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="captcha-actions">
|
||||
<el-button
|
||||
type="text"
|
||||
size="small"
|
||||
@click="refreshCaptcha"
|
||||
:loading="loading"
|
||||
>
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新验证码
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Refresh, Loading } from '@element-plus/icons-vue'
|
||||
import api from '@/utils/api'
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '请输入验证码'
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'large'
|
||||
},
|
||||
autoRefresh: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(['update:modelValue', 'verify', 'refresh'])
|
||||
|
||||
// 响应式数据
|
||||
const captchaInput = ref(props.modelValue)
|
||||
const captchaImage = ref('')
|
||||
const captchaId = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
/**
|
||||
* 获取验证码
|
||||
*/
|
||||
const getCaptcha = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await api.auth.captcha('/captcha/generate')
|
||||
|
||||
if (response.data.success) {
|
||||
captchaImage.value = response.data.data.image
|
||||
captchaId.value = response.data.data.captchaId
|
||||
emit('refresh', { captchaId: captchaId.value })
|
||||
} else {
|
||||
ElMessage.error(response.data.message || '获取验证码失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取验证码失败:', error)
|
||||
ElMessage.error('获取验证码失败,请检查网络连接')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新验证码
|
||||
*/
|
||||
const refreshCaptcha = async () => {
|
||||
captchaInput.value = ''
|
||||
emit('update:modelValue', '')
|
||||
await getCaptcha()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理输入变化
|
||||
*/
|
||||
const handleInput = (value) => {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证验证码
|
||||
* @param {string} inputText 用户输入的验证码
|
||||
* @returns {Promise<boolean>} 验证结果
|
||||
*/
|
||||
const verifyCaptcha = async (inputText) => {
|
||||
if (!captchaId.value) {
|
||||
ElMessage.error('请先获取验证码')
|
||||
return false
|
||||
}
|
||||
|
||||
if (!inputText || inputText.trim() === '') {
|
||||
ElMessage.error('请输入验证码')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.post('/captcha/verify', {
|
||||
captchaId: captchaId.value,
|
||||
captchaText: inputText.trim()
|
||||
})
|
||||
|
||||
if (response.data.success) {
|
||||
return true
|
||||
} else {
|
||||
ElMessage.error(response.data.message || '验证码错误')
|
||||
// 验证失败后刷新验证码
|
||||
await refreshCaptcha()
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('验证验证码失败:', error)
|
||||
ElMessage.error('验证验证码失败,请重试')
|
||||
await refreshCaptcha()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前验证码信息
|
||||
* @returns {Object} 验证码信息
|
||||
*/
|
||||
const getCaptchaInfo = () => {
|
||||
return {
|
||||
captchaId: captchaId.value,
|
||||
captchaText: captchaInput.value
|
||||
}
|
||||
}
|
||||
|
||||
// 监听 modelValue 变化
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
captchaInput.value = newValue
|
||||
})
|
||||
|
||||
// 组件挂载时获取验证码
|
||||
onMounted(() => {
|
||||
if (props.autoRefresh) {
|
||||
getCaptcha()
|
||||
}
|
||||
})
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
refreshCaptcha,
|
||||
verifyCaptcha,
|
||||
getCaptchaInfo
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.captcha-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.captcha-input-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.captcha-input-group .el-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.captcha-image-wrapper {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 40px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.captcha-image-wrapper:hover {
|
||||
border-color: #409eff;
|
||||
box-shadow: 0 0 0 1px #409eff;
|
||||
}
|
||||
|
||||
.captcha-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.captcha-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.captcha-loading .el-icon {
|
||||
font-size: 16px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.captcha-refresh-hint {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.captcha-image-wrapper:hover .captcha-refresh-hint {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.captcha-refresh-hint .el-icon {
|
||||
font-size: 16px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.captcha-actions {
|
||||
margin-top: 8px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.captcha-actions .el-button {
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.captcha-actions .el-button:hover {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.captcha-actions .el-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 480px) {
|
||||
.captcha-input-group {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.captcha-image-wrapper {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
height: 50px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
350
src/components/HorizontalImageDisplay.vue
Normal file
350
src/components/HorizontalImageDisplay.vue
Normal file
@@ -0,0 +1,350 @@
|
||||
<template>
|
||||
<div class="horizontal-image-display">
|
||||
<div class="image-list">
|
||||
<div
|
||||
v-for="(image, index) in images"
|
||||
:key="index"
|
||||
class="image-item"
|
||||
>
|
||||
<img :src="getImageUrl(image)" @click="handlePreview(image)" />
|
||||
<div class="image-actions">
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
circle
|
||||
@click="handleRemove(index)"
|
||||
class="remove-btn"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="images.length < maxCount"
|
||||
class="upload-item"
|
||||
@click="triggerFileInput"
|
||||
>
|
||||
<el-icon class="upload-icon"><Plus /></el-icon>
|
||||
<div class="upload-text">{{ placeholder }}</div>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
:accept="acceptTypes"
|
||||
multiple
|
||||
@change="handleFileChange"
|
||||
class="file-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 预览对话框 -->
|
||||
<el-dialog v-model="previewVisible" title="预览" width="60%">
|
||||
<div class="preview-container">
|
||||
<img v-if="previewType === 'image'" :src="previewUrl" class="preview-image" />
|
||||
<video v-else-if="previewType === 'video'" :src="previewUrl" controls class="preview-video" />
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus, Delete } from '@element-plus/icons-vue'
|
||||
import { getImageUrl } from '@/utils/config'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '点击上传图片'
|
||||
},
|
||||
maxCount: {
|
||||
type: Number,
|
||||
default: 9
|
||||
},
|
||||
maxSize: {
|
||||
type: Number,
|
||||
default: 10 // MB
|
||||
},
|
||||
allowVideo: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
allowedFileTypes: {
|
||||
type: String,
|
||||
default: '' // 例如:'.jpg,.png',为空时使用默认值
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'upload-success', 'upload-error'])
|
||||
|
||||
const images = ref([])
|
||||
const fileInput = ref(null)
|
||||
const previewVisible = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const previewType = ref('image')
|
||||
|
||||
// 监听modelValue变化
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
if (newVal && Array.isArray(newVal)) {
|
||||
images.value = [...newVal]
|
||||
} else {
|
||||
images.value = []
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 上传配置
|
||||
const uploadUrl = computed(() => {
|
||||
return (import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000') + '/api/upload'
|
||||
})
|
||||
|
||||
const acceptTypes = computed(() => {
|
||||
// 如果提供了自定义文件类型限制,则使用它
|
||||
if (props.allowedFileTypes) {
|
||||
return props.allowedFileTypes
|
||||
}
|
||||
|
||||
// 否则使用默认值
|
||||
const imageTypes = '.jpg,.jpeg,.png,.gif,.webp'
|
||||
const videoTypes = '.mp4,.avi,.mov,.wmv,.flv'
|
||||
return props.allowVideo ? `${imageTypes},${videoTypes}` : imageTypes
|
||||
})
|
||||
|
||||
// 触发文件选择
|
||||
const triggerFileInput = () => {
|
||||
fileInput.value.click()
|
||||
}
|
||||
|
||||
// 处理文件选择
|
||||
const handleFileChange = async (event) => {
|
||||
const files = event.target.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
// 检查文件数量
|
||||
if (images.value.length + files.length > props.maxCount) {
|
||||
ElMessage.error(`最多只能上传${props.maxCount}个文件`)
|
||||
return
|
||||
}
|
||||
|
||||
// 上传文件
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
|
||||
// 检查文件大小
|
||||
const isLtMaxSize = file.size / 1024 / 1024 < props.maxSize
|
||||
if (!isLtMaxSize) {
|
||||
ElMessage.error(`文件大小不能超过${props.maxSize}MB`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查文件类型
|
||||
const isImage = file.type.startsWith('image/')
|
||||
const isVideo = file.type.startsWith('video/')
|
||||
|
||||
// 如果设置了自定义文件类型限制,则检查文件扩展名是否符合要求
|
||||
if (props.allowedFileTypes) {
|
||||
const fileExt = `.${file.name.split('.').pop().toLowerCase()}`
|
||||
const allowedExts = props.allowedFileTypes.split(',')
|
||||
|
||||
if (!allowedExts.some(ext => ext.trim().toLowerCase() === fileExt)) {
|
||||
ElMessage.error(`只能上传${props.allowedFileTypes}格式的文件`)
|
||||
continue
|
||||
}
|
||||
} else if (!isImage && (!props.allowVideo || !isVideo)) {
|
||||
// 使用默认的文件类型检查
|
||||
const allowedTypes = props.allowVideo ? '图片或视频' : '图片'
|
||||
ElMessage.error(`只能上传${allowedTypes}文件`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 上传文件
|
||||
await uploadFile(file)
|
||||
}
|
||||
|
||||
// 重置文件输入
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
// 上传文件
|
||||
const uploadFile = async (file) => {
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
// 获取token
|
||||
const token = localStorage.getItem('admin_token')
|
||||
const headers = token ? { Authorization: `Bearer ${token}` } : {}
|
||||
|
||||
// 发送请求
|
||||
const response = await fetch(uploadUrl.value, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success && result.data) {
|
||||
// 处理上传成功
|
||||
handleUploadSuccess(result)
|
||||
} else {
|
||||
// 处理上传失败
|
||||
handleUploadError(new Error(result.message || '上传失败'))
|
||||
}
|
||||
} catch (error) {
|
||||
handleUploadError(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 上传成功
|
||||
const handleUploadSuccess = (response) => {
|
||||
if (response.success && response.data) {
|
||||
let url = ''
|
||||
|
||||
if (response.data.url) {
|
||||
// 返回相对路径,去掉前缀
|
||||
url = response.data.path
|
||||
} else if (response.data.path && Array.isArray(response.data.path)) {
|
||||
// 处理多文件上传返回的URLs数组
|
||||
url = response.data.path[0]
|
||||
}
|
||||
|
||||
if (url) {
|
||||
console.log(url);
|
||||
images.value.push(url)
|
||||
emit('update:modelValue', [...images.value])
|
||||
emit('upload-success', response.data)
|
||||
ElMessage.success('上传成功')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 上传失败
|
||||
const handleUploadError = (error) => {
|
||||
console.error('上传失败:', error)
|
||||
ElMessage.error('上传失败,请重试')
|
||||
emit('upload-error', error)
|
||||
}
|
||||
|
||||
// 移除图片
|
||||
const handleRemove = (index) => {
|
||||
images.value.splice(index, 1)
|
||||
emit('update:modelValue', [...images.value])
|
||||
}
|
||||
|
||||
// 预览图片
|
||||
const handlePreview = (url) => {
|
||||
if (!url) return
|
||||
|
||||
previewUrl.value = getImageUrl(url)
|
||||
|
||||
// 判断文件类型
|
||||
const ext = url.split('.').pop().toLowerCase()
|
||||
previewType.value = ['mp4', 'avi', 'mov', 'wmv', 'flv'].includes(ext) ? 'video' : 'image'
|
||||
|
||||
previewVisible.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.horizontal-image-display {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.image-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.image-item {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
border: 1px solid #e0e0e0;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.image-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.image-actions {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.image-item:hover .image-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
padding: 4px;
|
||||
font-size: 12px;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.upload-item {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 8px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
background-color: #fafafa;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.3s, background-color 0.3s;
|
||||
}
|
||||
|
||||
.upload-item:hover {
|
||||
border-color: #409eff;
|
||||
background-color: #f0f7ff;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 28px;
|
||||
color: #8c939d;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
color: #8c939d;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
max-width: 100%;
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
.preview-video {
|
||||
max-width: 100%;
|
||||
max-height: 500px;
|
||||
}
|
||||
</style>
|
||||
355
src/components/ImageUpload.vue
Normal file
355
src/components/ImageUpload.vue
Normal file
@@ -0,0 +1,355 @@
|
||||
<template>
|
||||
<div class="image-upload">
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
:action="uploadUrl"
|
||||
:headers="uploadHeaders"
|
||||
:data="uploadData"
|
||||
:before-upload="beforeUpload"
|
||||
:on-success="handleSuccess"
|
||||
:on-error="handleError"
|
||||
:on-progress="handleProgress"
|
||||
:show-file-list="false"
|
||||
:auto-upload="true"
|
||||
accept="image/*"
|
||||
class="upload-container"
|
||||
>
|
||||
<div class="upload-area" :class="{ 'uploading': uploading, 'has-image': imageUrl }">
|
||||
<div v-if="!imageUrl && !uploading" class="upload-placeholder">
|
||||
<el-icon class="upload-icon"><Plus /></el-icon>
|
||||
<div class="upload-text">{{ placeholder }}</div>
|
||||
<div class="upload-hint">支持 JPG、PNG 格式,大小不超过 5MB</div>
|
||||
</div>
|
||||
|
||||
<div v-if="uploading" class="upload-progress">
|
||||
<el-icon class="loading-icon"><Loading /></el-icon>
|
||||
<div class="progress-text">上传中... {{ uploadProgress }}%</div>
|
||||
<el-progress :percentage="uploadProgress" :show-text="false" />
|
||||
</div>
|
||||
|
||||
<div v-if="imageUrl && !uploading" class="image-preview">
|
||||
<img :src="imageUrl" :alt="placeholder" class="preview-image" />
|
||||
<div class="image-overlay">
|
||||
<el-button type="primary" size="small" @click.stop="previewImage">
|
||||
<el-icon><ZoomIn /></el-icon>
|
||||
预览
|
||||
</el-button>
|
||||
<el-button type="danger" size="small" @click.stop="removeImage">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
|
||||
<!-- 图片预览对话框 -->
|
||||
<el-dialog v-model="previewVisible" title="图片预览" width="60%" center>
|
||||
<div class="preview-dialog">
|
||||
<img :src="imageUrl" :alt="placeholder" class="preview-dialog-image" />
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus, Loading, ZoomIn, Delete } from '@element-plus/icons-vue'
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '点击上传图片'
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(['update:modelValue', 'upload-success', 'upload-error'])
|
||||
|
||||
// 响应式数据
|
||||
const uploadRef = ref()
|
||||
const uploading = ref(false)
|
||||
const uploadProgress = ref(0)
|
||||
const imageUrl = ref(props.modelValue)
|
||||
const previewVisible = ref(false)
|
||||
|
||||
// 上传配置
|
||||
const uploadUrl = '/api/upload/image'
|
||||
const uploadHeaders = computed(() => {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
})
|
||||
const uploadData = { type: 'product' }
|
||||
|
||||
// 监听 modelValue 变化
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
imageUrl.value = newValue
|
||||
})
|
||||
|
||||
/**
|
||||
* 上传前验证
|
||||
*/
|
||||
const beforeUpload = (file) => {
|
||||
if (props.disabled) {
|
||||
ElMessage.warning('当前状态下不允许上传')
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查文件类型
|
||||
const isImage = file.type.startsWith('image/')
|
||||
if (!isImage) {
|
||||
ElMessage.error('只能上传图片文件!')
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查文件大小(5MB)
|
||||
const isLt5M = file.size / 1024 / 1024 < 5
|
||||
if (!isLt5M) {
|
||||
ElMessage.error('图片大小不能超过 5MB!')
|
||||
return false
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
uploadProgress.value = 0
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传进度
|
||||
*/
|
||||
const handleProgress = (event) => {
|
||||
uploadProgress.value = Math.round(event.percent)
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传成功
|
||||
*/
|
||||
const handleSuccess = (response) => {
|
||||
uploading.value = false
|
||||
uploadProgress.value = 0
|
||||
|
||||
if (response.success) {
|
||||
imageUrl.value = response.data.url
|
||||
emit('update:modelValue', response.data.url)
|
||||
emit('upload-success', response.data)
|
||||
ElMessage.success('图片上传成功')
|
||||
} else {
|
||||
ElMessage.error(response.message || '上传失败')
|
||||
emit('upload-error', response)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传失败
|
||||
*/
|
||||
const handleError = (error) => {
|
||||
uploading.value = false
|
||||
uploadProgress.value = 0
|
||||
console.error('上传失败:', error)
|
||||
ElMessage.error('图片上传失败,请重试')
|
||||
emit('upload-error', error)
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览图片
|
||||
*/
|
||||
const previewImage = () => {
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除图片
|
||||
*/
|
||||
const removeImage = () => {
|
||||
imageUrl.value = ''
|
||||
emit('update:modelValue', '')
|
||||
ElMessage.success('图片已删除')
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发上传
|
||||
*/
|
||||
const triggerUpload = () => {
|
||||
if (!props.disabled) {
|
||||
uploadRef.value?.$el.querySelector('input').click()
|
||||
}
|
||||
}
|
||||
|
||||
// 暴露方法
|
||||
defineExpose({
|
||||
triggerUpload
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-upload {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upload-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
width: 240px;
|
||||
height: 160px;
|
||||
border: 2px dashed #e4e7ed;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #fafbfc 0%, #f5f7fa 100%);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.upload-area:hover {
|
||||
border-color: #409eff;
|
||||
background: linear-gradient(135deg, #f0f9ff 0%, #e6f4ff 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(64, 158, 255, 0.15);
|
||||
}
|
||||
|
||||
.upload-area.uploading {
|
||||
border-color: #409eff;
|
||||
background: linear-gradient(135deg, #f0f9ff 0%, #e6f4ff 100%);
|
||||
cursor: not-allowed;
|
||||
box-shadow: 0 4px 16px rgba(64, 158, 255, 0.2);
|
||||
}
|
||||
|
||||
.upload-area.has-image {
|
||||
border-color: #409eff;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
text-align: center;
|
||||
color: #8c939d;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 36px;
|
||||
color: #409eff;
|
||||
margin-bottom: 12px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.upload-progress {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.loading-icon {
|
||||
font-size: 24px;
|
||||
color: #409eff;
|
||||
margin-bottom: 8px;
|
||||
animation: rotate 2s linear infinite;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.image-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
opacity: 0;
|
||||
transition: all 0.3s ease;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.image-preview:hover .image-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.preview-dialog {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview-dialog-image {
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.upload-area {
|
||||
width: 180px;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
295
src/components/MediaUpload.vue
Normal file
295
src/components/MediaUpload.vue
Normal file
@@ -0,0 +1,295 @@
|
||||
<template>
|
||||
<div class="media-upload">
|
||||
<div class="upload-area">
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
:action="uploadUrl"
|
||||
:headers="uploadHeaders"
|
||||
:file-list="fileList"
|
||||
:on-success="handleUploadSuccess"
|
||||
:on-error="handleUploadError"
|
||||
:on-remove="handleRemove"
|
||||
:on-preview="handlePreview"
|
||||
:before-upload="beforeUpload"
|
||||
:accept="acceptTypes"
|
||||
multiple
|
||||
list-type="picture-card"
|
||||
class="media-uploader"
|
||||
>
|
||||
<el-icon class="upload-icon"><Plus /></el-icon>
|
||||
<div class="upload-text">{{ placeholder }}</div>
|
||||
</el-upload>
|
||||
</div>
|
||||
|
||||
<!-- 预览对话框 -->
|
||||
<el-dialog v-model="previewVisible" title="预览" width="60%">
|
||||
<div class="preview-container">
|
||||
<img v-if="previewType === 'image'" :src="previewUrl" class="preview-image" />
|
||||
<video v-else-if="previewType === 'video'" :src="previewUrl" controls class="preview-video" />
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { getImageUrl } from '@/utils/config'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '点击上传媒体文件'
|
||||
},
|
||||
maxCount: {
|
||||
type: Number,
|
||||
default: 9
|
||||
},
|
||||
maxSize: {
|
||||
type: Number,
|
||||
default: 10 // MB
|
||||
},
|
||||
allowVideo: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'upload-success', 'upload-error'])
|
||||
|
||||
const uploadRef = ref()
|
||||
const fileList = ref([])
|
||||
const previewVisible = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const previewType = ref('image')
|
||||
|
||||
// 上传配置
|
||||
const uploadUrl = computed(() => {
|
||||
return (import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000') + '/api/upload'
|
||||
})
|
||||
|
||||
const uploadHeaders = computed(() => {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
})
|
||||
|
||||
const acceptTypes = computed(() => {
|
||||
const imageTypes = '.jpg,.jpeg,.png,.gif,.webp'
|
||||
const videoTypes = '.mp4,.avi,.mov,.wmv,.flv'
|
||||
return props.allowVideo ? `${imageTypes},${videoTypes}` : imageTypes
|
||||
})
|
||||
|
||||
// 监听modelValue变化
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
if (newVal && Array.isArray(newVal)) {
|
||||
fileList.value = newVal.map((url, index) => ({
|
||||
uid: index,
|
||||
name: `file-${index}`,
|
||||
status: 'success',
|
||||
url: getImageUrl(url),
|
||||
response: { data: { url } }
|
||||
}))
|
||||
} else {
|
||||
fileList.value = []
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 上传前检查
|
||||
const beforeUpload = (file) => {
|
||||
// 检查文件数量
|
||||
if (fileList.value.length >= props.maxCount) {
|
||||
ElMessage.error(`最多只能上传${props.maxCount}个文件`)
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查文件大小
|
||||
const isLtMaxSize = file.size / 1024 / 1024 < props.maxSize
|
||||
if (!isLtMaxSize) {
|
||||
ElMessage.error(`文件大小不能超过${props.maxSize}MB`)
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查文件类型
|
||||
const isImage = file.type.startsWith('image/')
|
||||
const isVideo = file.type.startsWith('video/')
|
||||
|
||||
if (!isImage && (!props.allowVideo || !isVideo)) {
|
||||
const allowedTypes = props.allowVideo ? '图片或视频' : '图片'
|
||||
ElMessage.error(`只能上传${allowedTypes}文件`)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 上传成功
|
||||
const handleUploadSuccess = (response, file) => {
|
||||
console.log('上传响应:', response)
|
||||
if (response.success && response.data) {
|
||||
// 更新文件列表中当前文件的响应数据
|
||||
const currentFileIndex = fileList.value.findIndex(item => item.uid === file.uid)
|
||||
if (currentFileIndex !== -1) {
|
||||
fileList.value[currentFileIndex].response = response
|
||||
}
|
||||
|
||||
// 收集所有已上传文件的URL
|
||||
const urls = fileList.value
|
||||
.filter(item => item.status === 'success')
|
||||
.map(item => {
|
||||
if (item.response?.data?.url) {
|
||||
// 返回相对路径,去掉前缀
|
||||
const url = item.response.data.url
|
||||
return url.replace(/^.*\/uploads\//, '')
|
||||
}
|
||||
if (item.response?.data?.urls && Array.isArray(item.response.data.urls)) {
|
||||
// 处理多文件上传返回的URLs数组
|
||||
return item.response.data.urls.map(url => url.replace(/^.*\/uploads\//, ''))
|
||||
}
|
||||
return item.url?.replace(/^.*\/uploads\//, '') || ''
|
||||
})
|
||||
.flat() // 展平数组,处理多文件情况
|
||||
.filter(url => url) // 过滤空值
|
||||
|
||||
emit('update:modelValue', urls)
|
||||
emit('upload-success', response.data)
|
||||
ElMessage.success('上传成功')
|
||||
} else {
|
||||
console.error('上传失败响应:', response)
|
||||
ElMessage.error(response.message || '上传失败')
|
||||
handleUploadError(new Error(response.message || '上传失败'), file)
|
||||
}
|
||||
}
|
||||
|
||||
// 上传失败
|
||||
const handleUploadError = (error, file) => {
|
||||
console.error('上传失败:', error)
|
||||
ElMessage.error('上传失败,请重试')
|
||||
emit('upload-error', error)
|
||||
}
|
||||
|
||||
// 移除文件
|
||||
const handleRemove = (file) => {
|
||||
const urls = fileList.value
|
||||
.filter(item => item.uid !== file.uid)
|
||||
.map(item => {
|
||||
if (item.response?.data?.url) {
|
||||
// 移除API基础URL,只保留相对路径
|
||||
return item.response.data.url.replace(/^.*\/uploads\//, '')
|
||||
}
|
||||
return item.url?.replace(/^.*\/uploads\//, '') || ''
|
||||
})
|
||||
.filter(url => url) // 过滤空值
|
||||
|
||||
emit('update:modelValue', urls)
|
||||
}
|
||||
|
||||
// 预览文件
|
||||
const handlePreview = (file) => {
|
||||
const url = file.response?.data?.url || file.url
|
||||
if (!url) return
|
||||
|
||||
previewUrl.value = getImageUrl(url)
|
||||
|
||||
// 判断文件类型
|
||||
if (file.name) {
|
||||
const ext = file.name.split('.').pop().toLowerCase()
|
||||
previewType.value = ['mp4', 'avi', 'mov', 'wmv', 'flv'].includes(ext) ? 'video' : 'image'
|
||||
} else {
|
||||
previewType.value = 'image'
|
||||
}
|
||||
|
||||
previewVisible.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.media-upload {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.media-uploader {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 28px;
|
||||
color: #8c939d;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
margin-top: 8px;
|
||||
color: #8c939d;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
max-width: 100%;
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
.preview-video {
|
||||
max-width: 100%;
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
/* 完全重写横向排列样式 */
|
||||
.media-uploader {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.media-uploader :deep(.el-upload-list) {
|
||||
display: flex !important;
|
||||
flex-wrap: wrap !important;
|
||||
gap: 8px !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.media-uploader :deep(.el-upload-list--picture-card) {
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
flex-wrap: wrap !important;
|
||||
gap: 8px !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.media-uploader :deep(.el-upload-list--picture-card .el-upload-list__item) {
|
||||
width: 104px !important;
|
||||
height: 104px !important;
|
||||
margin: 0 !important;
|
||||
display: inline-flex !important;
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
.media-uploader :deep(.el-upload--picture-card) {
|
||||
width: 104px !important;
|
||||
height: 104px !important;
|
||||
margin: 0 !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
/* 覆盖可能的默认样式 */
|
||||
.media-uploader :deep(.el-upload-list__item) {
|
||||
float: none !important;
|
||||
clear: none !important;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
250
src/components/RichTextEditor.vue
Normal file
250
src/components/RichTextEditor.vue
Normal file
@@ -0,0 +1,250 @@
|
||||
<template>
|
||||
<div class="rich-text-editor">
|
||||
<Toolbar
|
||||
style="border-bottom: 1px solid #ccc"
|
||||
:editor="editorRef"
|
||||
:defaultConfig="toolbarConfig"
|
||||
:mode="mode"
|
||||
/>
|
||||
<Editor
|
||||
style="height: 500px; overflow-y: hidden;"
|
||||
v-model="valueHtml"
|
||||
:defaultConfig="editorConfig"
|
||||
:mode="mode"
|
||||
@onCreated="handleCreated"
|
||||
@onChange="handleChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import '@wangeditor/editor/dist/css/style.css'
|
||||
import { onBeforeUnmount, ref, shallowRef, watch } from 'vue'
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '请输入内容...'
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '500px'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
// 编辑器实例,必须用 shallowRef
|
||||
const editorRef = shallowRef()
|
||||
|
||||
// 内容 HTML
|
||||
const valueHtml = ref('')
|
||||
|
||||
// 模式
|
||||
const mode = 'default' // 或 'simple'
|
||||
|
||||
// 工具栏配置
|
||||
const toolbarConfig = {
|
||||
excludeKeys: [
|
||||
'group-video', // 排除视频
|
||||
'fullScreen' // 排除全屏
|
||||
]
|
||||
}
|
||||
|
||||
// 编辑器配置
|
||||
const editorConfig = {
|
||||
placeholder: props.placeholder,
|
||||
MENU_CONF: {
|
||||
// 配置上传图片
|
||||
uploadImage: {
|
||||
server: '/api/upload/image',
|
||||
fieldName: 'file',
|
||||
meta: {
|
||||
token: localStorage.getItem('token') || ''
|
||||
},
|
||||
metaWithUrl: false,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token') || ''}`
|
||||
},
|
||||
maxFileSize: 5 * 1024 * 1024, // 5M
|
||||
allowedFileTypes: ['image/*'],
|
||||
customInsert(res, insertFn) {
|
||||
// 自定义插入图片
|
||||
if (res.code === 200) {
|
||||
insertFn(res.data.url, res.data.alt || '', res.data.url)
|
||||
} else {
|
||||
console.error('图片上传失败:', res.message)
|
||||
}
|
||||
}
|
||||
},
|
||||
// 配置字体
|
||||
fontSize: {
|
||||
fontSizeList: [
|
||||
'12px', '13px', '14px', '15px', '16px', '17px', '18px', '19px', '20px',
|
||||
'22px', '24px', '26px', '28px', '30px', '32px', '34px', '36px'
|
||||
]
|
||||
},
|
||||
// 配置字体族
|
||||
fontFamily: {
|
||||
fontFamilyList: [
|
||||
'黑体', '仿宋', '楷体', '标楷体', '华文仿宋', '华文楷体', '宋体', '微软雅黑',
|
||||
'Arial', 'Tahoma', 'Verdana', 'Times New Roman', 'Courier New'
|
||||
]
|
||||
},
|
||||
// 配置颜色
|
||||
color: {
|
||||
colors: [
|
||||
'#000000', '#333333', '#666666', '#999999', '#cccccc', '#ffffff',
|
||||
'#ff0000', '#ff9900', '#ffff00', '#00ff00', '#00ffff', '#0000ff',
|
||||
'#9900ff', '#ff00ff', '#f4cccc', '#fce5cd', '#fff2cc', '#d9ead3',
|
||||
'#d0e0e3', '#cfe2f3', '#d9d2e9', '#ead1dc'
|
||||
]
|
||||
},
|
||||
// 配置背景色
|
||||
bgColor: {
|
||||
colors: [
|
||||
'#000000', '#333333', '#666666', '#999999', '#cccccc', '#ffffff',
|
||||
'#ff0000', '#ff9900', '#ffff00', '#00ff00', '#00ffff', '#0000ff',
|
||||
'#9900ff', '#ff00ff', '#f4cccc', '#fce5cd', '#fff2cc', '#d9ead3',
|
||||
'#d0e0e3', '#cfe2f3', '#d9d2e9', '#ead1dc'
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 组件销毁时,也及时销毁编辑器
|
||||
onBeforeUnmount(() => {
|
||||
const editor = editorRef.value
|
||||
if (editor == null) return
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
const handleCreated = (editor) => {
|
||||
editorRef.value = editor // 记录 editor 实例,重要!
|
||||
}
|
||||
|
||||
const handleChange = (editor) => {
|
||||
emit('update:modelValue', valueHtml.value)
|
||||
emit('change', valueHtml.value)
|
||||
}
|
||||
|
||||
// 监听外部传入的值变化
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
if (newVal !== valueHtml.value) {
|
||||
valueHtml.value = newVal
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 监听内部值变化
|
||||
watch(valueHtml, (newVal) => {
|
||||
if (newVal !== props.modelValue) {
|
||||
emit('update:modelValue', newVal)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rich-text-editor {
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.rich-text-editor:hover {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 6px 24px rgba(102, 126, 234, 0.15);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.rich-text-editor:focus-within {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 8px 32px rgba(102, 126, 234, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 工具栏样式优化 */
|
||||
:deep(.w-e-toolbar) {
|
||||
background: linear-gradient(135deg, #fafbfc 0%, #f5f7fa 100%);
|
||||
border-bottom: 1px solid rgba(102, 126, 234, 0.1) !important;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
/* 编辑器内容区域样式 */
|
||||
:deep(.w-e-text-container) {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
:deep(.w-e-text-placeholder) {
|
||||
color: #a0a0a0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 适配H5端的样式 */
|
||||
:deep(.w-e-text-container .w-e-text) {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
:deep(.w-e-text-container .w-e-text p) {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
:deep(.w-e-text-container .w-e-text img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:deep(.w-e-text-container .w-e-text h1,
|
||||
.w-e-text-container .w-e-text h2,
|
||||
.w-e-text-container .w-e-text h3,
|
||||
.w-e-text-container .w-e-text h4,
|
||||
.w-e-text-container .w-e-text h5,
|
||||
.w-e-text-container .w-e-text h6) {
|
||||
margin: 16px 0 8px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.w-e-text-container .w-e-text ul,
|
||||
.w-e-text-container .w-e-text ol) {
|
||||
padding-left: 20px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
:deep(.w-e-text-container .w-e-text blockquote) {
|
||||
border-left: 4px solid #667eea;
|
||||
padding-left: 16px;
|
||||
margin: 16px 0;
|
||||
background: rgba(102, 126, 234, 0.05);
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
:deep(.w-e-text-container .w-e-text table) {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
:deep(.w-e-text-container .w-e-text table td,
|
||||
.w-e-text-container .w-e-text table th) {
|
||||
border: 1px solid #e9ecef;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
:deep(.w-e-text-container .w-e-text table th) {
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
535
src/layout/Layout.vue
Normal file
535
src/layout/Layout.vue
Normal file
@@ -0,0 +1,535 @@
|
||||
<template>
|
||||
<el-container class="layout-container">
|
||||
<!-- 侧边栏 -->
|
||||
<el-aside :width="isCollapse ? '64px' : '200px'" class="sidebar">
|
||||
<div class="logo">
|
||||
<img v-if="!isCollapse" src="/logo.svg" alt="Logo" class="logo-img" />
|
||||
<span v-if="!isCollapse" class="logo-text">后台管理</span>
|
||||
<el-icon v-else class="logo-icon"><Setting /></el-icon>
|
||||
</div>
|
||||
|
||||
<el-menu
|
||||
:default-active="$route.path"
|
||||
:collapse="isCollapse"
|
||||
:unique-opened="true"
|
||||
router
|
||||
class="sidebar-menu"
|
||||
>
|
||||
<el-menu-item index="/dashboard">
|
||||
<el-icon><Odometer /></el-icon>
|
||||
<template #title>仪表盘</template>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item v-if="userStore.isAdmin" index="/products">
|
||||
<el-icon><Goods /></el-icon>
|
||||
<template #title>商品管理</template>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item v-if="userStore.isAdmin" index="/orders">
|
||||
<el-icon><List /></el-icon>
|
||||
<template #title>订单管理</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/profile">
|
||||
<el-icon><UserFilled /></el-icon>
|
||||
<template #title>个人资料</template>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<el-container>
|
||||
<!-- 顶部导航 -->
|
||||
<el-header class="header">
|
||||
<div class="header-left">
|
||||
<el-button
|
||||
type="text"
|
||||
@click="toggleCollapse"
|
||||
class="collapse-btn"
|
||||
>
|
||||
<el-icon><Expand v-if="isCollapse" /><Fold v-else /></el-icon>
|
||||
</el-button>
|
||||
|
||||
<el-breadcrumb separator="/" class="breadcrumb">
|
||||
<el-breadcrumb-item
|
||||
v-for="item in breadcrumbs"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
>
|
||||
{{ item.title }}
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<!-- 全屏按钮 -->
|
||||
<el-tooltip content="全屏" placement="bottom">
|
||||
<el-button type="text" @click="toggleFullscreen" class="header-btn">
|
||||
<el-icon><FullScreen /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<el-tooltip content="刷新" placement="bottom">
|
||||
<el-button type="text" @click="refresh" class="header-btn">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
|
||||
<!-- 用户菜单 -->
|
||||
<el-dropdown @command="handleCommand" class="user-dropdown">
|
||||
<div class="user-info">
|
||||
<el-avatar :size="32" :src="userStore.user?.avatar">
|
||||
<el-icon><UserFilled /></el-icon>
|
||||
</el-avatar>
|
||||
<span class="username">{{ userStore.user?.username }}</span>
|
||||
<el-icon class="dropdown-icon"><ArrowDown /></el-icon>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="profile">
|
||||
<el-icon><UserFilled /></el-icon>
|
||||
个人资料
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="changePassword">
|
||||
<el-icon><Lock /></el-icon>
|
||||
修改密码
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided command="logout">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
退出登录
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<!-- 主内容 -->
|
||||
<el-main class="main-content">
|
||||
<transition name="fade" mode="out-in">
|
||||
<router-view />
|
||||
</transition>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
|
||||
<!-- 修改密码对话框 -->
|
||||
<el-dialog
|
||||
v-model="passwordDialogVisible"
|
||||
title="修改密码"
|
||||
width="400px"
|
||||
:before-close="handlePasswordDialogClose"
|
||||
>
|
||||
<el-form
|
||||
ref="passwordFormRef"
|
||||
:model="passwordForm"
|
||||
:rules="passwordRules"
|
||||
label-width="80px"
|
||||
>
|
||||
<el-form-item label="当前密码" prop="currentPassword">
|
||||
<el-input
|
||||
v-model="passwordForm.currentPassword"
|
||||
type="password"
|
||||
placeholder="请输入当前密码"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码" prop="newPassword">
|
||||
<el-input
|
||||
v-model="passwordForm.newPassword"
|
||||
type="password"
|
||||
placeholder="请输入新密码"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码" prop="confirmPassword">
|
||||
<el-input
|
||||
v-model="passwordForm.confirmPassword"
|
||||
type="password"
|
||||
placeholder="请确认新密码"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="passwordDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleChangePassword" :loading="userStore.loading">
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Odometer,
|
||||
User,
|
||||
UserFilled,
|
||||
Setting,
|
||||
Expand,
|
||||
Fold,
|
||||
FullScreen,
|
||||
Refresh,
|
||||
ArrowDown,
|
||||
Lock,
|
||||
SwitchButton,
|
||||
Money,
|
||||
Ticket,
|
||||
Warning,
|
||||
Goods,
|
||||
List,
|
||||
Coin,
|
||||
DocumentChecked,
|
||||
Avatar,
|
||||
DataAnalysis,
|
||||
Monitor,
|
||||
Connection,
|
||||
CreditCard,
|
||||
Bell
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 组件挂载时不再自动验证token,避免登录后立即触发401错误
|
||||
// token验证交给具体的API调用时处理
|
||||
onMounted(() => {
|
||||
// 仅确保用户状态已正确加载
|
||||
console.log('Layout组件已挂载,用户状态:', userStore.isAuthenticated)
|
||||
})
|
||||
|
||||
// 侧边栏折叠状态
|
||||
const isCollapse = ref(false)
|
||||
|
||||
// 修改密码对话框
|
||||
const passwordDialogVisible = ref(false)
|
||||
const passwordFormRef = ref()
|
||||
const passwordForm = ref({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
// 密码验证规则
|
||||
const passwordRules = {
|
||||
currentPassword: [
|
||||
{ required: true, message: '请输入当前密码', trigger: 'blur' }
|
||||
],
|
||||
newPassword: [
|
||||
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' },
|
||||
{
|
||||
pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d@$!%*?&]{6,}$/,
|
||||
message: '密码必须包含大小写字母和数字',
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: '请确认新密码', trigger: 'blur' },
|
||||
{
|
||||
validator: (rule, value, callback) => {
|
||||
if (value !== passwordForm.value.newPassword) {
|
||||
callback(new Error('两次输入的密码不一致'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 面包屑导航
|
||||
const breadcrumbs = computed(() => {
|
||||
const matched = route.matched.filter(item => item.meta && item.meta.title)
|
||||
const breadcrumbList = []
|
||||
|
||||
matched.forEach(item => {
|
||||
if (item.path !== '/') {
|
||||
breadcrumbList.push({
|
||||
title: item.meta.title.replace(' - 后台管理系统', ''),
|
||||
path: item.path
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return breadcrumbList
|
||||
})
|
||||
|
||||
// 切换侧边栏折叠
|
||||
const toggleCollapse = () => {
|
||||
isCollapse.value = !isCollapse.value
|
||||
}
|
||||
|
||||
// 全屏切换
|
||||
const toggleFullscreen = () => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen()
|
||||
} else {
|
||||
document.exitFullscreen()
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新页面
|
||||
const refresh = () => {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
// 处理用户菜单命令
|
||||
const handleCommand = (command) => {
|
||||
switch (command) {
|
||||
case 'profile':
|
||||
router.push('/profile')
|
||||
break
|
||||
case 'changePassword':
|
||||
passwordDialogVisible.value = true
|
||||
break
|
||||
case 'logout':
|
||||
handleLogout()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 退出登录
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要退出登录吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
await userStore.logout()
|
||||
router.push('/login')
|
||||
} catch (error) {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
// 修改密码
|
||||
const handleChangePassword = async () => {
|
||||
try {
|
||||
await passwordFormRef.value.validate()
|
||||
|
||||
const result = await userStore.changePassword({
|
||||
currentPassword: passwordForm.value.currentPassword,
|
||||
newPassword: passwordForm.value.newPassword
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
passwordDialogVisible.value = false
|
||||
resetPasswordForm()
|
||||
|
||||
// 延迟跳转到登录页
|
||||
setTimeout(() => {
|
||||
router.push('/login')
|
||||
}, 2000)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('密码修改失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭密码对话框
|
||||
const handlePasswordDialogClose = () => {
|
||||
resetPasswordForm()
|
||||
passwordDialogVisible.value = false
|
||||
}
|
||||
|
||||
// 重置密码表单
|
||||
const resetPasswordForm = () => {
|
||||
passwordForm.value = {
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
}
|
||||
passwordFormRef.value?.resetFields()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout-container {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-color: #304156;
|
||||
transition: width 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sidebar .el-menu {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
.sidebar .el-menu::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.sidebar .el-menu::-webkit-scrollbar-thumb {
|
||||
background: rgba(144, 147, 153, 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.sidebar .el-menu::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(144, 147, 153, 0.5);
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #2b3a4b;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.logo-img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
border-right: none;
|
||||
background-color: #304156;
|
||||
}
|
||||
|
||||
.sidebar-menu .el-menu-item,
|
||||
.sidebar-menu .el-sub-menu__title {
|
||||
color: #bfcbd9;
|
||||
border-bottom: 1px solid #434a50;
|
||||
}
|
||||
|
||||
.sidebar-menu .el-menu-item:hover,
|
||||
.sidebar-menu .el-sub-menu__title:hover {
|
||||
background-color: #434a50 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.sidebar-menu .el-menu-item.is-active {
|
||||
background-color: #409eff !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.header {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #e6e6e6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
margin-right: 20px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.header-btn {
|
||||
font-size: 16px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.header-btn:hover {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.user-info:hover {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
background-color: #f0f2f5;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 过渡动画 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.username {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
53
src/main.js
Normal file
53
src/main.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import NProgress from 'nprogress'
|
||||
import 'nprogress/nprogress.css'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { useUserStore } from './stores/user'
|
||||
import './style.css'
|
||||
|
||||
// 配置 NProgress
|
||||
NProgress.configure({
|
||||
showSpinner: false,
|
||||
minimum: 0.2,
|
||||
easing: 'ease',
|
||||
speed: 500
|
||||
})
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
// 注册所有图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(ElementPlus, {
|
||||
locale: zhCn,
|
||||
})
|
||||
|
||||
// 全局注册dayjs
|
||||
app.config.globalProperties.$dayjs = dayjs
|
||||
|
||||
// 应用初始化后检查用户状态
|
||||
app.mount('#app')
|
||||
|
||||
// 初始化用户状态检查
|
||||
const userStore = useUserStore()
|
||||
if (userStore.isAuthenticated) {
|
||||
// 如果用户已登录,启动状态检查
|
||||
userStore.checkAuth().then((isValid) => {
|
||||
if (isValid) {
|
||||
userStore.startStatusCheck()
|
||||
}
|
||||
})
|
||||
}
|
||||
174
src/router/index.js
Normal file
174
src/router/index.js
Normal file
@@ -0,0 +1,174 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import NProgress from 'nprogress'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/Login.vue'),
|
||||
meta: {
|
||||
title: '登录 - 炬融圈',
|
||||
requiresAuth: false
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layout/Layout.vue'),
|
||||
redirect: '/dashboard',
|
||||
meta: {
|
||||
requiresAuth: true
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/Dashboard.vue'),
|
||||
meta: {
|
||||
title: '仪表盘 - 炬融圈',
|
||||
icon: 'Odometer'
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
path: 'products',
|
||||
name: 'Products',
|
||||
component: () => import('@/views/Products.vue'),
|
||||
meta: {
|
||||
title: '商品管理 - 积分商城管理系统',
|
||||
icon: 'Goods',
|
||||
requiresAdmin: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'products/create',
|
||||
name: 'CreateProduct',
|
||||
component: () => import('@/views/ProductForm.vue'),
|
||||
meta: {
|
||||
title: '创建商品 - 积分商城管理系统',
|
||||
icon: 'Plus',
|
||||
requiresAdmin: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'products/edit/:id',
|
||||
name: 'EditProduct',
|
||||
component: () => import('@/views/ProductForm.vue'),
|
||||
meta: {
|
||||
title: '编辑商品 - 积分商城管理系统',
|
||||
icon: 'EditPen',
|
||||
requiresAdmin: true
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
path: 'products/:id/spec-combinations',
|
||||
name: 'ProductSpecCombinations',
|
||||
component: () => import('@/views/ProductSpecCombinations.vue'),
|
||||
meta: {
|
||||
title: '商品规格组合管理 - 积分商城管理系统',
|
||||
icon: 'Grid',
|
||||
requiresAdmin: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'orders',
|
||||
name: 'Orders',
|
||||
component: () => import('@/views/Orders.vue'),
|
||||
meta: {
|
||||
title: '订单管理',
|
||||
icon: 'List',
|
||||
requiresAdmin: true
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
path: 'profile',
|
||||
name: 'Profile',
|
||||
component: () => import('@/views/Profile.vue'),
|
||||
meta: {
|
||||
title: '个人资料 - 炬融圈',
|
||||
icon: 'UserFilled'
|
||||
}
|
||||
}
|
||||
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/404',
|
||||
name: 'NotFound',
|
||||
component: () => import('@/views/404.vue'),
|
||||
meta: {
|
||||
title: '页面未找到 - 炬融圈'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/404'
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory('/admin/'),
|
||||
routes
|
||||
})
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
NProgress.start()
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 设置页面标题
|
||||
if (to.meta.title) {
|
||||
document.title = to.meta.title
|
||||
}
|
||||
|
||||
// 检查是否需要认证
|
||||
if (to.meta.requiresAuth !== false) {
|
||||
if (!userStore.isAuthenticated) {
|
||||
// 只在没有token或用户信息时才尝试从本地存储恢复登录状态
|
||||
const token = localStorage.getItem('admin_token')
|
||||
const userStr = localStorage.getItem('admin_user')
|
||||
|
||||
if (token && userStr) {
|
||||
try {
|
||||
const user = JSON.parse(userStr)
|
||||
userStore.token = token
|
||||
userStore.user = user
|
||||
} catch (error) {
|
||||
console.error('解析用户信息失败:', error)
|
||||
localStorage.removeItem('admin_token')
|
||||
localStorage.removeItem('admin_user')
|
||||
}
|
||||
}
|
||||
|
||||
if (!userStore.isAuthenticated) {
|
||||
next('/login')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 检查管理员权限
|
||||
if (to.meta.requiresAdmin && !userStore.isAdmin) {
|
||||
ElMessage.error('您没有权限访问此页面')
|
||||
next('/dashboard')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 如果已登录用户访问登录页,重定向到仪表盘
|
||||
if (to.name === 'Login' && userStore.isAuthenticated) {
|
||||
next('/dashboard')
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
router.afterEach(() => {
|
||||
NProgress.done()
|
||||
})
|
||||
|
||||
export default router
|
||||
186
src/stores/user.js
Normal file
186
src/stores/user.js
Normal file
@@ -0,0 +1,186 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import api from '@/utils/api'
|
||||
|
||||
export const useUserStore = defineStore('user', {
|
||||
state: () => ({
|
||||
user: null,
|
||||
token: localStorage.getItem('admin_token') || null,
|
||||
loading: false,
|
||||
statusCheckInterval: null
|
||||
}),
|
||||
|
||||
getters: {
|
||||
isAuthenticated: (state) => !!state.token && !!state.user,
|
||||
isAdmin: (state) => state.user?.role === 'admin'
|
||||
},
|
||||
|
||||
actions: {
|
||||
// 登录
|
||||
async login(credentials) {
|
||||
this.loading = true
|
||||
try {
|
||||
const response = await api.auth.login(credentials)
|
||||
const { token, user } = response.data
|
||||
|
||||
this.token = token
|
||||
this.user = user
|
||||
|
||||
// 存储到本地存储
|
||||
localStorage.setItem('admin_token', token)
|
||||
localStorage.setItem('admin_user', JSON.stringify(user))
|
||||
|
||||
this.startStatusCheck() // 登录成功后开始状态检查
|
||||
ElMessage.success(`欢迎回来,${user.username}!`)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false }
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
// 登出
|
||||
async logout() {
|
||||
try {
|
||||
this.stopStatusCheck() // 登出时停止状态检查
|
||||
|
||||
// 清除状态
|
||||
this.user = null
|
||||
this.token = null
|
||||
|
||||
// 清除本地存储
|
||||
localStorage.removeItem('admin_token')
|
||||
localStorage.removeItem('admin_user')
|
||||
|
||||
ElMessage.success('退出成功,期待您的再次光临!')
|
||||
} catch (error) {
|
||||
console.error('登出失败:', error)
|
||||
}
|
||||
},
|
||||
|
||||
// 检查认证状态(仅在需要时验证token有效性)
|
||||
async checkAuth() {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
const userStr = localStorage.getItem('admin_user')
|
||||
|
||||
if (!token || !userStr) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
// 先从本地存储恢复状态
|
||||
const user = JSON.parse(userStr)
|
||||
this.token = token
|
||||
this.user = user
|
||||
|
||||
// 可选:验证token是否仍然有效(仅在必要时调用)
|
||||
// 这里不主动验证,让具体的API调用时自然验证
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('认证检查失败:', error)
|
||||
// 清除无效的本地存储数据
|
||||
localStorage.removeItem('admin_token')
|
||||
localStorage.removeItem('admin_user')
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
// 更新个人信息
|
||||
async updateProfile(profileData) {
|
||||
this.loading = true
|
||||
try {
|
||||
const response = await api.users.updateUser(this.user.id, profileData)
|
||||
|
||||
this.user = { ...this.user, ...response.data }
|
||||
localStorage.setItem('admin_user', JSON.stringify(this.user))
|
||||
|
||||
ElMessage.success('个人信息更新成功')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.message || '更新失败'
|
||||
ElMessage.error(message)
|
||||
return { success: false, message }
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
// 修改密码
|
||||
async changePassword(passwordData) {
|
||||
this.loading = true
|
||||
try {
|
||||
await api.auth.changePassword(passwordData)
|
||||
ElMessage.success('密码修改成功,请重新登录')
|
||||
|
||||
// 修改密码后需要重新登录
|
||||
setTimeout(() => {
|
||||
this.logout()
|
||||
}, 1500)
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.message || '密码修改失败'
|
||||
ElMessage.error(message)
|
||||
return { success: false, message }
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
// 获取用户详情
|
||||
async getUserDetails(userId) {
|
||||
try {
|
||||
const response = await api.users.getUserById(userId)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.message || '获取用户详情失败'
|
||||
ElMessage.error(message)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// 获取当前用户信息
|
||||
async fetchUserInfo() {
|
||||
try {
|
||||
const response = await api.auth.getCurrentUser()
|
||||
this.user = response.data.user
|
||||
localStorage.setItem('admin_user', JSON.stringify(this.user))
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.message || '获取用户信息失败'
|
||||
console.error('获取用户信息失败:', error)
|
||||
return { success: false, message }
|
||||
}
|
||||
},
|
||||
|
||||
// 开始状态检查
|
||||
startStatusCheck() {
|
||||
// 如果已经有定时器在运行,先清除
|
||||
if (this.statusCheckInterval) {
|
||||
clearInterval(this.statusCheckInterval)
|
||||
}
|
||||
|
||||
// 每5分钟检查一次用户状态
|
||||
this.statusCheckInterval = setInterval(async () => {
|
||||
if (this.isAuthenticated) {
|
||||
try {
|
||||
await api.auth.getCurrentUser()
|
||||
} catch (error) {
|
||||
// 如果请求失败,说明token可能已失效或用户被拉黑
|
||||
// api拦截器会自动处理这些情况
|
||||
console.log('用户状态检查失败,可能已被拉黑或token失效')
|
||||
}
|
||||
}
|
||||
}, 5 * 60 * 1000) // 5分钟
|
||||
},
|
||||
|
||||
// 停止状态检查
|
||||
stopStatusCheck() {
|
||||
if (this.statusCheckInterval) {
|
||||
clearInterval(this.statusCheckInterval)
|
||||
this.statusCheckInterval = null
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
298
src/style.css
Normal file
298
src/style.css
Normal file
@@ -0,0 +1,298 @@
|
||||
/* 样式重置 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
/* 链接样式 */
|
||||
a {
|
||||
color: #409eff;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #66b1ff;
|
||||
}
|
||||
|
||||
/* 工具类 */
|
||||
.text-left { text-align: left !important; }
|
||||
.text-center { text-align: center !important; }
|
||||
.text-right { text-align: right !important; }
|
||||
.text-justify { text-align: justify !important; }
|
||||
|
||||
.float-left { float: left !important; }
|
||||
.float-right { float: right !important; }
|
||||
.clearfix::after {
|
||||
content: "";
|
||||
display: table;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.d-none { display: none !important; }
|
||||
.d-inline { display: inline !important; }
|
||||
.d-inline-block { display: inline-block !important; }
|
||||
.d-block { display: block !important; }
|
||||
.d-flex { display: flex !important; }
|
||||
.d-inline-flex { display: inline-flex !important; }
|
||||
|
||||
.flex-row { flex-direction: row !important; }
|
||||
.flex-column { flex-direction: column !important; }
|
||||
.flex-wrap { flex-wrap: wrap !important; }
|
||||
.flex-nowrap { flex-wrap: nowrap !important; }
|
||||
|
||||
.justify-content-start { justify-content: flex-start !important; }
|
||||
.justify-content-end { justify-content: flex-end !important; }
|
||||
.justify-content-center { justify-content: center !important; }
|
||||
.justify-content-between { justify-content: space-between !important; }
|
||||
.justify-content-around { justify-content: space-around !important; }
|
||||
|
||||
.align-items-start { align-items: flex-start !important; }
|
||||
.align-items-end { align-items: flex-end !important; }
|
||||
.align-items-center { align-items: center !important; }
|
||||
.align-items-baseline { align-items: baseline !important; }
|
||||
.align-items-stretch { align-items: stretch !important; }
|
||||
|
||||
.flex-fill { flex: 1 1 auto !important; }
|
||||
.flex-grow-0 { flex-grow: 0 !important; }
|
||||
.flex-grow-1 { flex-grow: 1 !important; }
|
||||
.flex-shrink-0 { flex-shrink: 0 !important; }
|
||||
.flex-shrink-1 { flex-shrink: 1 !important; }
|
||||
|
||||
/* 间距工具类 */
|
||||
.m-0 { margin: 0 !important; }
|
||||
.m-1 { margin: 0.25rem !important; }
|
||||
.m-2 { margin: 0.5rem !important; }
|
||||
.m-3 { margin: 1rem !important; }
|
||||
.m-4 { margin: 1.5rem !important; }
|
||||
.m-5 { margin: 3rem !important; }
|
||||
|
||||
.mt-0 { margin-top: 0 !important; }
|
||||
.mt-1 { margin-top: 0.25rem !important; }
|
||||
.mt-2 { margin-top: 0.5rem !important; }
|
||||
.mt-3 { margin-top: 1rem !important; }
|
||||
.mt-4 { margin-top: 1.5rem !important; }
|
||||
.mt-5 { margin-top: 3rem !important; }
|
||||
|
||||
.mb-0 { margin-bottom: 0 !important; }
|
||||
.mb-1 { margin-bottom: 0.25rem !important; }
|
||||
.mb-2 { margin-bottom: 0.5rem !important; }
|
||||
.mb-3 { margin-bottom: 1rem !important; }
|
||||
.mb-4 { margin-bottom: 1.5rem !important; }
|
||||
.mb-5 { margin-bottom: 3rem !important; }
|
||||
|
||||
.ml-0 { margin-left: 0 !important; }
|
||||
.ml-1 { margin-left: 0.25rem !important; }
|
||||
.ml-2 { margin-left: 0.5rem !important; }
|
||||
.ml-3 { margin-left: 1rem !important; }
|
||||
.ml-4 { margin-left: 1.5rem !important; }
|
||||
.ml-5 { margin-left: 3rem !important; }
|
||||
|
||||
.mr-0 { margin-right: 0 !important; }
|
||||
.mr-1 { margin-right: 0.25rem !important; }
|
||||
.mr-2 { margin-right: 0.5rem !important; }
|
||||
.mr-3 { margin-right: 1rem !important; }
|
||||
.mr-4 { margin-right: 1.5rem !important; }
|
||||
.mr-5 { margin-right: 3rem !important; }
|
||||
|
||||
.p-0 { padding: 0 !important; }
|
||||
.p-1 { padding: 0.25rem !important; }
|
||||
.p-2 { padding: 0.5rem !important; }
|
||||
.p-3 { padding: 1rem !important; }
|
||||
.p-4 { padding: 1.5rem !important; }
|
||||
.p-5 { padding: 3rem !important; }
|
||||
|
||||
.pt-0 { padding-top: 0 !important; }
|
||||
.pt-1 { padding-top: 0.25rem !important; }
|
||||
.pt-2 { padding-top: 0.5rem !important; }
|
||||
.pt-3 { padding-top: 1rem !important; }
|
||||
.pt-4 { padding-top: 1.5rem !important; }
|
||||
.pt-5 { padding-top: 3rem !important; }
|
||||
|
||||
.pb-0 { padding-bottom: 0 !important; }
|
||||
.pb-1 { padding-bottom: 0.25rem !important; }
|
||||
.pb-2 { padding-bottom: 0.5rem !important; }
|
||||
.pb-3 { padding-bottom: 1rem !important; }
|
||||
.pb-4 { padding-bottom: 1.5rem !important; }
|
||||
.pb-5 { padding-bottom: 3rem !important; }
|
||||
|
||||
.pl-0 { padding-left: 0 !important; }
|
||||
.pl-1 { padding-left: 0.25rem !important; }
|
||||
.pl-2 { padding-left: 0.5rem !important; }
|
||||
.pl-3 { padding-left: 1rem !important; }
|
||||
.pl-4 { padding-left: 1.5rem !important; }
|
||||
.pl-5 { padding-left: 3rem !important; }
|
||||
|
||||
.pr-0 { padding-right: 0 !important; }
|
||||
.pr-1 { padding-right: 0.25rem !important; }
|
||||
.pr-2 { padding-right: 0.5rem !important; }
|
||||
.pr-3 { padding-right: 1rem !important; }
|
||||
.pr-4 { padding-right: 1.5rem !important; }
|
||||
.pr-5 { padding-right: 3rem !important; }
|
||||
|
||||
/* 宽度高度 */
|
||||
.w-25 { width: 25% !important; }
|
||||
.w-50 { width: 50% !important; }
|
||||
.w-75 { width: 75% !important; }
|
||||
.w-100 { width: 100% !important; }
|
||||
.w-auto { width: auto !important; }
|
||||
|
||||
.h-25 { height: 25% !important; }
|
||||
.h-50 { height: 50% !important; }
|
||||
.h-75 { height: 75% !important; }
|
||||
.h-100 { height: 100% !important; }
|
||||
.h-auto { height: auto !important; }
|
||||
|
||||
/* 颜色 */
|
||||
.text-primary { color: #409eff !important; }
|
||||
.text-success { color: #67c23a !important; }
|
||||
.text-warning { color: #e6a23c !important; }
|
||||
.text-danger { color: #f56c6c !important; }
|
||||
.text-info { color: #909399 !important; }
|
||||
.text-light { color: #f8f9fa !important; }
|
||||
.text-dark { color: #343a40 !important; }
|
||||
.text-muted { color: #6c757d !important; }
|
||||
.text-white { color: #fff !important; }
|
||||
|
||||
.bg-primary { background-color: #409eff !important; }
|
||||
.bg-success { background-color: #67c23a !important; }
|
||||
.bg-warning { background-color: #e6a23c !important; }
|
||||
.bg-danger { background-color: #f56c6c !important; }
|
||||
.bg-info { background-color: #909399 !important; }
|
||||
.bg-light { background-color: #f8f9fa !important; }
|
||||
.bg-dark { background-color: #343a40 !important; }
|
||||
.bg-white { background-color: #fff !important; }
|
||||
|
||||
/* 边框 */
|
||||
.border { border: 1px solid #dee2e6 !important; }
|
||||
.border-top { border-top: 1px solid #dee2e6 !important; }
|
||||
.border-right { border-right: 1px solid #dee2e6 !important; }
|
||||
.border-bottom { border-bottom: 1px solid #dee2e6 !important; }
|
||||
.border-left { border-left: 1px solid #dee2e6 !important; }
|
||||
.border-0 { border: 0 !important; }
|
||||
|
||||
.rounded { border-radius: 0.25rem !important; }
|
||||
.rounded-top { border-top-left-radius: 0.25rem !important; border-top-right-radius: 0.25rem !important; }
|
||||
.rounded-right { border-top-right-radius: 0.25rem !important; border-bottom-right-radius: 0.25rem !important; }
|
||||
.rounded-bottom { border-bottom-right-radius: 0.25rem !important; border-bottom-left-radius: 0.25rem !important; }
|
||||
.rounded-left { border-top-left-radius: 0.25rem !important; border-bottom-left-radius: 0.25rem !important; }
|
||||
.rounded-circle { border-radius: 50% !important; }
|
||||
.rounded-0 { border-radius: 0 !important; }
|
||||
|
||||
/* 阴影 */
|
||||
.shadow-sm { box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075) !important; }
|
||||
.shadow { box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important; }
|
||||
.shadow-lg { box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.175) !important; }
|
||||
.shadow-none { box-shadow: none !important; }
|
||||
|
||||
/* 过渡动画 */
|
||||
.transition { transition: all 0.3s ease !important; }
|
||||
.transition-fast { transition: all 0.15s ease !important; }
|
||||
.transition-slow { transition: all 0.6s ease !important; }
|
||||
|
||||
/* 鼠标样式 */
|
||||
.cursor-pointer { cursor: pointer !important; }
|
||||
.cursor-default { cursor: default !important; }
|
||||
.cursor-not-allowed { cursor: not-allowed !important; }
|
||||
|
||||
/* 溢出处理 */
|
||||
.overflow-auto { overflow: auto !important; }
|
||||
.overflow-hidden { overflow: hidden !important; }
|
||||
.overflow-visible { overflow: visible !important; }
|
||||
.overflow-scroll { overflow: scroll !important; }
|
||||
|
||||
.text-truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.text-break {
|
||||
word-wrap: break-word !important;
|
||||
word-break: break-word !important;
|
||||
}
|
||||
|
||||
/* 响应式隐藏 */
|
||||
@media (max-width: 576px) {
|
||||
.d-sm-none { display: none !important; }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.d-md-none { display: none !important; }
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.d-lg-none { display: none !important; }
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.d-xl-none { display: none !important; }
|
||||
}
|
||||
|
||||
/* 自定义动画 */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideInLeft {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-slideInLeft {
|
||||
animation: slideInLeft 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-slideInRight {
|
||||
animation: slideInRight 0.5s ease-in-out;
|
||||
}
|
||||
85
src/utils/api.js
Normal file
85
src/utils/api.js
Normal file
@@ -0,0 +1,85 @@
|
||||
import {apiRequest, midRequest} from './request'
|
||||
|
||||
|
||||
// API接口定义
|
||||
const api = {
|
||||
// 认证相关
|
||||
auth: {
|
||||
login: (data) => midRequest.post('/auth/login', data),
|
||||
register: (data) => apiRequest.post('/auth/register', data),
|
||||
getCurrentUser: () => apiRequest.get('/auth/me'),
|
||||
changePassword: (data) => apiRequest.put('/auth/change-password', data),
|
||||
captcha: () => midRequest.get('/captcha/generate'),
|
||||
},
|
||||
|
||||
// 用户管理
|
||||
users: {
|
||||
getUsers: (params) => apiRequest.get('/users', {params}),
|
||||
getUserById: (id) => apiRequest.get(`/users/${id}`),
|
||||
createUser: (data) => apiRequest.post('/users', data),
|
||||
updateUser: (id, data) => apiRequest.put(`/users/${id}`, data),
|
||||
deleteUser: (id) => apiRequest.delete(`/users/${id}`),
|
||||
getUserStats: () => apiRequest.get('/users/stats'),
|
||||
getUserGrowthTrend: (params) => apiRequest.get('/users/growth-trend', {params}),
|
||||
getDailyRevenue: (params) => apiRequest.get('/users/daily-revenue', {params}),
|
||||
getAgentOptions: (params) => apiRequest.get('/admin/agents', {params})
|
||||
},
|
||||
|
||||
|
||||
// 文件上传
|
||||
upload: {
|
||||
uploadImage: (file) => {
|
||||
const formData = new FormData()
|
||||
formData.append('image', file)
|
||||
return midRequest.post('/upload/image', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
},
|
||||
uploadFile: (file) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return midRequest.post('/upload/file', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
// 商品管理
|
||||
products: {
|
||||
getProducts: (params) => apiRequest.get('/products', {params}),
|
||||
getProductById: (id) => apiRequest.get(`/products/${id}`),
|
||||
createProduct: (data) => apiRequest.post('/products', data),
|
||||
updateProduct: (id, data) => apiRequest.put(`/products/${id}`, data),
|
||||
deleteProduct: (id) => apiRequest.delete(`/products/${id}`),
|
||||
getCategories: () => apiRequest.get('/products/categories'),
|
||||
|
||||
// 商品属性
|
||||
getAttributes: (productId) => apiRequest.get(`/products/${productId}/attributes`),
|
||||
createAttribute: (productId, data) => apiRequest.post(`/products/${productId}/attributes`, data),
|
||||
updateAttribute: (productId, attrId, data) => apiRequest.put(`/products/${productId}/attributes/${attrId}`, data),
|
||||
deleteAttribute: (productId, attrId) => apiRequest.delete(`/products/${productId}/attributes/${attrId}`)
|
||||
},
|
||||
|
||||
// 新的规格管理系统(笛卡尔积)
|
||||
specifications: {
|
||||
// 规格名称管理
|
||||
getSpecNames: () => apiRequest.get('/specifications/names'),
|
||||
createSpecName: (data) => apiRequest.post('/specifications/names', data),
|
||||
deleteSpecName: (id) => apiRequest.delete(`/specifications/names/${id}`),
|
||||
// 规格值管理
|
||||
getSpecValues: (specNameId) => apiRequest.get('/specifications/values', {params: {spec_name_id: specNameId}}),
|
||||
createSpecValue: (data) => apiRequest.post('/specifications/values', data),
|
||||
deleteSpecValue: (id) => apiRequest.delete(`/specifications/values/${id}`),
|
||||
|
||||
// 规格组合管理
|
||||
getCombinations: (productId) => apiRequest.get(`/specifications/combinations/${productId}`),
|
||||
generateCombinations: (data) => apiRequest.post('/specifications/generate-combinations', data),
|
||||
updateCombination: (id, data) => apiRequest.put(`/specifications/combinations/${id}`, data),
|
||||
deleteCombination: (id) => apiRequest.delete(`/specifications/combinations/${id}`)
|
||||
},
|
||||
}
|
||||
|
||||
export default api
|
||||
68
src/utils/config.js
Normal file
68
src/utils/config.js
Normal file
@@ -0,0 +1,68 @@
|
||||
// 环境配置
|
||||
const config = {
|
||||
development: {
|
||||
baseURL: 'https://minio.zrbjr.com',
|
||||
uploadURL: 'http://localhost:3001/api/upload'
|
||||
},
|
||||
production: {
|
||||
baseURL: 'https://minio.zrbjr.com',
|
||||
uploadURL: `${window.location.origin}/api/upload`
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前环境
|
||||
const env = import.meta.env.MODE || 'development'
|
||||
|
||||
// 导出当前环境的配置
|
||||
export default config[env]
|
||||
|
||||
// 导出具体配置项
|
||||
export const { baseURL, uploadURL } = config[env]
|
||||
|
||||
/**
|
||||
* 获取完整的图片URL
|
||||
* @param {string} imagePath - 图片路径
|
||||
* @returns {string} 完整的图片URL
|
||||
*/
|
||||
export const getImageUrl = (imagePath) => {
|
||||
// console.log('getImageUrl called with:', imagePath)
|
||||
if (!imagePath) return ''
|
||||
if (imagePath.startsWith('http')) return imagePath
|
||||
// console.log(imagePath,'imagePath');
|
||||
|
||||
// 如果图片路径以/uploads开头,直接返回原路径
|
||||
if (imagePath.startsWith('/uploads')) {
|
||||
const cleanBaseURL = baseURL.replace(/\/$/, '')
|
||||
// console.log('Image starts with /uploads, returning original path:', imagePath)
|
||||
return `${imagePath}`
|
||||
}
|
||||
|
||||
// 在开发环境下,也需要根据路径前缀处理
|
||||
if (env === 'development') {
|
||||
const cleanBaseURL = baseURL.replace(/\/$/, '')
|
||||
const cleanImagePath = imagePath.startsWith('/') ? imagePath : `/${imagePath}`
|
||||
const fullUrl = `${cleanBaseURL}${cleanImagePath}`
|
||||
// console.log('Development environment, returning:', fullUrl)
|
||||
return fullUrl
|
||||
}
|
||||
|
||||
// 生产环境下使用完整URL
|
||||
const cleanBaseURL = baseURL.replace(/\/$/, '')
|
||||
const cleanImagePath = imagePath.startsWith('/') ? imagePath : `/${imagePath}`
|
||||
const fullUrl = `${cleanBaseURL}${cleanImagePath}`
|
||||
|
||||
return fullUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取上传配置
|
||||
* @returns {object} 上传配置对象
|
||||
*/
|
||||
export const getUploadConfig = () => {
|
||||
return {
|
||||
action: uploadURL,
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('admin_token')}`
|
||||
}
|
||||
}
|
||||
}
|
||||
164
src/utils/request.js
Normal file
164
src/utils/request.js
Normal file
@@ -0,0 +1,164 @@
|
||||
// 工厂函数(复用拦截器逻辑)
|
||||
import axios from 'axios'
|
||||
import { ElMessage, ElLoading } from 'element-plus'
|
||||
import NProgress from 'nprogress'
|
||||
let loadingInstance = null
|
||||
let requestCount = 0
|
||||
let isLoggingOut = false // 防止重复登出
|
||||
|
||||
export const createRequest = (baseURL) => {
|
||||
const request = axios.create({
|
||||
baseURL,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const showLoading = () => {
|
||||
if (requestCount === 0) {
|
||||
loadingInstance = ElLoading.service({
|
||||
text: '加载中...',
|
||||
background: 'rgba(0, 0, 0, 0.7)'
|
||||
})
|
||||
}
|
||||
requestCount++
|
||||
}
|
||||
|
||||
const hideLoading = () => {
|
||||
requestCount--
|
||||
if (requestCount <= 0) {
|
||||
requestCount = 0
|
||||
if (loadingInstance) {
|
||||
loadingInstance.close()
|
||||
loadingInstance = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 请求拦截器
|
||||
request.interceptors.request.use(
|
||||
(config) => {
|
||||
// 开始进度条
|
||||
NProgress.start()
|
||||
|
||||
// 显示加载动画(除了某些不需要的请求)
|
||||
if (!config.hideLoading) {
|
||||
showLoading()
|
||||
}
|
||||
|
||||
// 添加认证token
|
||||
const token = localStorage.getItem('admin_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
hideLoading()
|
||||
NProgress.done()
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
request.interceptors.response.use(
|
||||
(response) => {
|
||||
hideLoading()
|
||||
NProgress.done()
|
||||
return response
|
||||
},
|
||||
(error) => {
|
||||
hideLoading()
|
||||
NProgress.done()
|
||||
|
||||
const { response } = error
|
||||
|
||||
if (response) {
|
||||
switch (response.status) {
|
||||
case 401:
|
||||
// 防止重复处理401错误
|
||||
if (!isLoggingOut) {
|
||||
isLoggingOut = true
|
||||
|
||||
// 只在非登录页面显示错误消息
|
||||
if (window.location.pathname !== '/admin/login') {
|
||||
ElMessage.error('登录已过期,请重新登录')
|
||||
}
|
||||
|
||||
// 清除本地存储
|
||||
localStorage.removeItem('admin_token')
|
||||
localStorage.removeItem('admin_user')
|
||||
|
||||
// 立即跳转到登录页,减少延迟
|
||||
setTimeout(() => {
|
||||
if (typeof window !== 'undefined' && window.location.pathname !== '/admin/login') {
|
||||
window.location.href = '/admin/login'
|
||||
}
|
||||
// 重置标志
|
||||
setTimeout(() => {
|
||||
isLoggingOut = false
|
||||
}, 1000)
|
||||
}, 500)
|
||||
}
|
||||
break
|
||||
case 403:
|
||||
// 检查是否是用户被拉黑
|
||||
if (response.data.code === 'USER_BLACKLISTED') {
|
||||
// 防止重复处理拉黑错误
|
||||
if (!isLoggingOut) {
|
||||
isLoggingOut = true
|
||||
|
||||
ElMessage.error(response.data.message || '账户已被拉黑,请联系管理员')
|
||||
|
||||
// 清除本地存储
|
||||
localStorage.removeItem('admin_token')
|
||||
localStorage.removeItem('admin_user')
|
||||
|
||||
// 跳转到登录页
|
||||
setTimeout(() => {
|
||||
if (typeof window !== 'undefined' && window.location.pathname !== '/admin/login') {
|
||||
window.location.href = '/admin/login'
|
||||
}
|
||||
// 重置标志
|
||||
setTimeout(() => {
|
||||
isLoggingOut = false
|
||||
}, 1000)
|
||||
}, 500)
|
||||
}
|
||||
} else {
|
||||
ElMessage.error('没有权限访问此资源')
|
||||
}
|
||||
break
|
||||
case 404:
|
||||
ElMessage.error('请求的资源不存在')
|
||||
break
|
||||
case 422:
|
||||
ElMessage.error(response.data.message || '请求参数错误')
|
||||
break
|
||||
case 429:
|
||||
ElMessage.error('请求过于频繁,请稍后再试')
|
||||
break
|
||||
case 500:
|
||||
ElMessage.error('服务器内部错误')
|
||||
break
|
||||
case 400:
|
||||
ElMessage.error(response.data.message || '请求参数错误')
|
||||
break
|
||||
default:
|
||||
ElMessage.error(response.data.error.message || '请求失败')
|
||||
}
|
||||
} else {
|
||||
ElMessage.error('网络错误,请检查网络连接')
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
return request
|
||||
}
|
||||
// 生成不同的实例
|
||||
export const apiRequest = createRequest(import.meta.env.VITE_API_BASE_URL || '/api')
|
||||
export const midRequest = createRequest(import.meta.env.VITE_UPLOAD_BASE_URL || '/mid')
|
||||
export const statsRequest = createRequest(import.meta.env.VITE_STATS_BASE_URL || '/stats')
|
||||
97
src/views/404.vue
Normal file
97
src/views/404.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="not-found-container">
|
||||
<div class="not-found-content">
|
||||
<div class="error-code">404</div>
|
||||
<div class="error-message">页面未找到</div>
|
||||
<div class="error-description">
|
||||
抱歉,您访问的页面不存在或已被删除
|
||||
</div>
|
||||
<div class="error-actions">
|
||||
<el-button type="primary" @click="goHome">
|
||||
<el-icon><HomeFilled /></el-icon>
|
||||
返回首页
|
||||
</el-button>
|
||||
<el-button @click="goBack">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
返回上页
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import { HomeFilled, ArrowLeft } from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const goHome = () => {
|
||||
router.push('/dashboard')
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.go(-1)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.not-found-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.not-found-content {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: 120px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 20px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 32px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.error-description {
|
||||
font-size: 16px;
|
||||
margin-bottom: 40px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.error-actions .el-button {
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.error-code {
|
||||
font-size: 80px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1527
src/views/Dashboard.vue
Normal file
1527
src/views/Dashboard.vue
Normal file
File diff suppressed because it is too large
Load Diff
356
src/views/Login.vue
Normal file
356
src/views/Login.vue
Normal file
@@ -0,0 +1,356 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-background">
|
||||
<div class="bg-shape shape-1"></div>
|
||||
<div class="bg-shape shape-2"></div>
|
||||
<div class="bg-shape shape-3"></div>
|
||||
</div>
|
||||
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<div class="logo">
|
||||
<h1 class="title">炬融圈供应商管理系统</h1>
|
||||
</div>
|
||||
<p class="subtitle">欢迎回来,请登录您的账户</p>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
ref="loginFormRef"
|
||||
:model="loginForm"
|
||||
:rules="loginRules"
|
||||
class="login-form"
|
||||
@keyup.enter="handleLogin"
|
||||
>
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model="loginForm.username"
|
||||
placeholder="电话号码"
|
||||
size="large"
|
||||
prefix-icon="User"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
size="large"
|
||||
prefix-icon="Lock"
|
||||
show-password
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="captcha">
|
||||
<Captcha
|
||||
ref="captchaRef"
|
||||
v-model="loginForm.captcha"
|
||||
placeholder="请输入验证码"
|
||||
size="large"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<div class="login-options">
|
||||
<el-checkbox v-model="loginForm.remember">记住我</el-checkbox>
|
||||
<el-link type="primary" :underline="false">忘记密码?</el-link>
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="login-btn"
|
||||
:loading="userStore.loading"
|
||||
@click="handleLogin"
|
||||
>
|
||||
{{ userStore.loading ? '登录中...' : '登录' }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
<div class="login-footer">
|
||||
<p>还没有账户?
|
||||
<el-link type="primary" :underline="false" @click="goToRegister">立即注册</el-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {ref, reactive, onMounted} from 'vue'
|
||||
import {useRouter} from 'vue-router'
|
||||
import {useUserStore} from '@/stores/user'
|
||||
import {ElMessage} from 'element-plus'
|
||||
import {Setting} from '@element-plus/icons-vue'
|
||||
import Captcha from '@/components/Captcha.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 表单引用
|
||||
const loginFormRef = ref()
|
||||
const captchaRef = ref()
|
||||
|
||||
// 登录表单数据
|
||||
const loginForm = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
captcha: '',
|
||||
remember: false
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const loginRules = {
|
||||
username: [
|
||||
{required: true, message: '请输入电话号码', trigger: 'blur'},
|
||||
{min: 3, message: '用户名长度不能少于3位', trigger: 'blur'}
|
||||
],
|
||||
password: [
|
||||
{required: true, message: '请输入密码', trigger: 'blur'},
|
||||
{min: 6, message: '密码长度不能少于6位', trigger: 'blur'}
|
||||
]
|
||||
}
|
||||
|
||||
// 处理登录
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
await loginFormRef.value.validate()
|
||||
// 验证验证码
|
||||
// const captchaValid = await captchaRef.value.verifyCaptcha(loginForm.captcha)
|
||||
const captchaInfo = captchaRef.value.getCaptchaInfo()
|
||||
const result = await userStore.login({
|
||||
username: loginForm.username,
|
||||
password: loginForm.password,
|
||||
captchaId: captchaInfo.captchaId,
|
||||
captchaText: captchaInfo.captchaText
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
// 保存记住我状态
|
||||
if (loginForm.remember) {
|
||||
localStorage.setItem('admin_remember', 'true')
|
||||
localStorage.setItem('admin_username', loginForm.username)
|
||||
} else {
|
||||
localStorage.removeItem('admin_remember')
|
||||
localStorage.removeItem('admin_username')
|
||||
}
|
||||
|
||||
// 跳转到仪表盘
|
||||
router.push('/dashboard')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到注册页
|
||||
const goToRegister = () => {
|
||||
ElMessage.info('注册功能暂未开放,请联系管理员')
|
||||
}
|
||||
|
||||
// 组件挂载时恢复记住的用户名
|
||||
onMounted(() => {
|
||||
const remember = localStorage.getItem('admin_remember')
|
||||
const username = localStorage.getItem('admin_username')
|
||||
|
||||
if (remember === 'true' && username) {
|
||||
loginForm.username = username
|
||||
loginForm.remember = true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bg-shape {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.shape-1 {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
top: 10%;
|
||||
left: 10%;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.shape-2 {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
top: 60%;
|
||||
right: 10%;
|
||||
animation-delay: 2s;
|
||||
}
|
||||
|
||||
.shape-3 {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
bottom: 20%;
|
||||
left: 20%;
|
||||
animation-delay: 4s;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0px) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-20px) rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.login-card {
|
||||
position: relative;
|
||||
width: 400px;
|
||||
padding: 40px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
font-size: 32px;
|
||||
color: #409eff;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #7f8c8d;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.login-form .el-form-item {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.login-options {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
height: 45px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.quick-login {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.quick-login-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.login-footer p {
|
||||
color: #7f8c8d;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 480px) {
|
||||
.login-card {
|
||||
width: 90%;
|
||||
padding: 30px 20px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.quick-login-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* 输入框样式优化 */
|
||||
.login-form :deep(.el-input__wrapper) {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.login-form :deep(.el-input__wrapper:hover) {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.login-form :deep(.el-input__wrapper.is-focus) {
|
||||
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
|
||||
}
|
||||
|
||||
/* 按钮动画 */
|
||||
.quick-login-buttons .el-button {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.quick-login-buttons .el-button:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
</style>
|
||||
422
src/views/Orders.vue
Normal file
422
src/views/Orders.vue
Normal file
@@ -0,0 +1,422 @@
|
||||
<template>
|
||||
<div class="orders-container">
|
||||
<div class="header">
|
||||
<h2>订单管理</h2>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="订单号">
|
||||
<el-input
|
||||
v-model="filters.orderNumber"
|
||||
placeholder="请输入订单号"
|
||||
clearable
|
||||
@keyup.enter="loadOrders"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户名">
|
||||
<el-input
|
||||
v-model="filters.username"
|
||||
placeholder="请输入用户名"
|
||||
clearable
|
||||
@keyup.enter="loadOrders"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="订单状态">
|
||||
<el-select
|
||||
v-model="filters.status"
|
||||
placeholder="请选择状态"
|
||||
clearable
|
||||
style="display: inline-block; width: 150px"
|
||||
>
|
||||
<el-option label="待发货" value="pending" />
|
||||
<el-option label="已发货" value="shipped" />
|
||||
<el-option label="已完成" value="completed" />
|
||||
<el-option label="已取消" value="cancelled" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建时间">
|
||||
<el-date-picker
|
||||
v-model="filters.dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="loadOrders">搜索</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<el-table :data="orders" v-loading="loading" stripe>
|
||||
<el-table-column prop="order_no" label="订单号" width="200" />
|
||||
<el-table-column prop="username" label="用户" width="120" />
|
||||
<el-table-column prop="total_points" label="总积分" width="100">
|
||||
<template #default="{ row }">
|
||||
<span class="points-text">{{ row.total_points }} 积分</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="total_rongdou" label="总融豆" width="100">
|
||||
<template #default="{ row }">
|
||||
<span class="points-text">{{ row.total_rongdou }} 融豆</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.status)">
|
||||
{{ getStatusText(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="商品信息" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="order-items">
|
||||
<div v-for="item in row.items" :key="item.id" class="order-item">
|
||||
<el-image
|
||||
:src="getImageUrl(item.image_url)"
|
||||
fit="cover"
|
||||
style="width: 40px; height: 40px; border-radius: 4px"
|
||||
/>
|
||||
<div class="item-info">
|
||||
<div class="item-name">{{ item.product_name }}</div>
|
||||
<div class="item-detail">
|
||||
{{ item.points }}积分 × {{ item.quantity }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" size="small" @click="viewOrder(row)">
|
||||
查看详情
|
||||
</el-button>
|
||||
<el-dropdown
|
||||
v-if="row.status !== 'cancelled' && row.status !== 'completed'"
|
||||
>
|
||||
<el-button type="warning" size="small">
|
||||
更新状态
|
||||
<el-icon class="el-icon--right"><arrow-down /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-if="row.status === 'pending'"
|
||||
@click="updateOrderStatus(row, 'shipped')"
|
||||
>
|
||||
标记为已发货
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
v-if="row.status === 'shipped'"
|
||||
@click="updateOrderStatus(row, 'completed')"
|
||||
>
|
||||
标记为已完成
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
@click="updateOrderStatus(row, 'cancelled')"
|
||||
divided
|
||||
>
|
||||
取消订单
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.limit"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="pagination.total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="loadOrders"
|
||||
@current-change="loadOrders"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 订单详情对话框 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
title="订单详情"
|
||||
width="800px"
|
||||
:before-close="closeDialog"
|
||||
>
|
||||
<div v-if="selectedOrder" class="order-detail">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="订单号">{{
|
||||
selectedOrder.order_no
|
||||
}}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户">{{
|
||||
selectedOrder.username
|
||||
}}</el-descriptions-item>
|
||||
<el-descriptions-item label="总积分">
|
||||
<span class="points-text"
|
||||
>{{ selectedOrder.total_points }} 积分</span
|
||||
>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="订单状态">
|
||||
<el-tag :type="getStatusType(selectedOrder.status)">
|
||||
{{ getStatusText(selectedOrder.status) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{
|
||||
formatDate(selectedOrder.created_at)
|
||||
}}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{
|
||||
formatDate(selectedOrder.updated_at)
|
||||
}}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<h4 style="margin: 20px 0 10px 0">商品清单</h4>
|
||||
<el-table :data="selectedOrder.items" border>
|
||||
<el-table-column label="商品图片" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-image
|
||||
:src="getImageUrl(row.image_url)"
|
||||
fit="cover"
|
||||
style="width: 60px; height: 60px; border-radius: 4px"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="product_name" label="商品名称" />
|
||||
<el-table-column prop="points" label="单价">
|
||||
<template #default="{ row }">
|
||||
<span class="points-text">{{ row.points_price }} 积分 | {{ row.rongdou_price }} 融豆</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="quantity" label="数量" width="80" />
|
||||
<el-table-column label="小计" width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="points-text"
|
||||
>{{ row.points_price * row.quantity }} 积分 | {{ row.rongdou_price * row.quantity }} 融豆</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { ArrowDown } from '@element-plus/icons-vue';
|
||||
import api from '@/utils/api';
|
||||
import { getImageUrl } from '@/utils/config';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const loading = ref(false);
|
||||
const orders = ref([]);
|
||||
const dialogVisible = ref(false);
|
||||
const selectedOrder = ref(null);
|
||||
|
||||
const filters = reactive({
|
||||
orderNumber: '',
|
||||
username: '',
|
||||
status: '',
|
||||
dateRange: null,
|
||||
});
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
// 加载订单列表
|
||||
const loadOrders = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.page,
|
||||
limit: pagination.limit,
|
||||
orderNumber: filters.orderNumber,
|
||||
username: filters.username,
|
||||
status: filters.status,
|
||||
};
|
||||
|
||||
if (filters.dateRange && filters.dateRange.length === 2) {
|
||||
params.startDate = filters.dateRange[0];
|
||||
params.endDate = filters.dateRange[1];
|
||||
}
|
||||
|
||||
const { data } = await api.get('/orders', { params });
|
||||
orders.value = data.data.orders;
|
||||
pagination.total = data.data.total;
|
||||
} catch (error) {
|
||||
ElMessage.error('加载订单列表失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 重置筛选条件
|
||||
const resetFilters = () => {
|
||||
Object.assign(filters, {
|
||||
orderNumber: '',
|
||||
username: '',
|
||||
status: '',
|
||||
dateRange: null,
|
||||
});
|
||||
pagination.page = 1;
|
||||
loadOrders();
|
||||
};
|
||||
|
||||
// 查看订单详情
|
||||
const viewOrder = async (order) => {
|
||||
try {
|
||||
const { data } = await api.get(`/orders/${order.id}`);
|
||||
selectedOrder.value = data.data.order;
|
||||
dialogVisible.value = true;
|
||||
} catch (error) {
|
||||
ElMessage.error('加载订单详情失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 更新订单状态
|
||||
const updateOrderStatus = async (order, newStatus) => {
|
||||
const statusText = getStatusText(newStatus);
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要将订单状态更新为「${statusText}」吗?`,
|
||||
'确认操作',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}
|
||||
);
|
||||
|
||||
await api.put(`/orders/${order.id}/status`, { status: newStatus });
|
||||
ElMessage.success('订单状态更新成功');
|
||||
loadOrders();
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('更新订单状态失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 关闭对话框
|
||||
const closeDialog = () => {
|
||||
dialogVisible.value = false;
|
||||
selectedOrder.value = null;
|
||||
};
|
||||
|
||||
// 获取状态类型
|
||||
const getStatusType = (status) => {
|
||||
const types = {
|
||||
pending: 'warning',
|
||||
shipped: 'primary',
|
||||
completed: 'success',
|
||||
cancelled: 'danger',
|
||||
};
|
||||
return types[status] || 'info';
|
||||
};
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status) => {
|
||||
const texts = {
|
||||
pending: '待发货',
|
||||
shipped: '已发货',
|
||||
pre_order: '待支付',
|
||||
cancelled: '已取消',
|
||||
completed: '已完成',
|
||||
};
|
||||
return texts[status] || status;
|
||||
};
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
return dayjs(dateString).format('YYYY-MM-DD HH:mm:ss');
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadOrders();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.orders-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.filters {
|
||||
background: #f5f7fa;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.points-text {
|
||||
color: #e6a23c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.order-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.order-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.item-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.item-detail {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.order-detail {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
438
src/views/ProductAttributes.vue
Normal file
438
src/views/ProductAttributes.vue
Normal file
@@ -0,0 +1,438 @@
|
||||
<template>
|
||||
<div class="attributes-container">
|
||||
<div class="header">
|
||||
<h2>商品属性管理 - {{ productName }}</h2>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" @click="showAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加属性
|
||||
</el-button>
|
||||
<el-button @click="$router.back()">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
返回
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="attributes" v-loading="loading" stripe>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="attribute_key" label="属性名" min-width="150" />
|
||||
<el-table-column prop="attribute_value" label="属性值" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.attribute_value.length <= 50" type="info">
|
||||
{{ row.attribute_value }}
|
||||
</el-tag>
|
||||
<el-tooltip v-else :content="row.attribute_value" placement="top">
|
||||
<el-tag type="info">
|
||||
{{ row.attribute_value.substring(0, 50) }}...
|
||||
</el-tag>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="editAttribute(row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="deleteAttribute(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 添加/编辑属性对话框 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑属性' : '添加属性'"
|
||||
width="500px"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="属性名" prop="attribute_key">
|
||||
<el-input
|
||||
v-model="form.attribute_key"
|
||||
placeholder="请输入属性名,如:品牌、材质、产地"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="属性值" prop="attribute_value">
|
||||
<el-input
|
||||
v-model="form.attribute_value"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="请输入属性值,如:苹果、纯棉、中国制造"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitForm" :loading="submitting">
|
||||
{{ isEdit ? '更新' : '添加' }}
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 批量添加属性对话框 -->
|
||||
<el-dialog
|
||||
v-model="batchDialogVisible"
|
||||
title="批量添加属性"
|
||||
width="600px"
|
||||
>
|
||||
<div class="batch-form">
|
||||
<el-alert
|
||||
title="批量添加说明"
|
||||
type="info"
|
||||
:closable="false"
|
||||
style="margin-bottom: 20px;"
|
||||
>
|
||||
<template #default>
|
||||
<p>每行一个属性,格式:属性名:属性值</p>
|
||||
<p>示例:</p>
|
||||
<p>品牌:苹果</p>
|
||||
<p>颜色:黑色</p>
|
||||
<p>尺寸:6.1英寸</p>
|
||||
</template>
|
||||
</el-alert>
|
||||
<el-input
|
||||
v-model="batchText"
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
placeholder="请按格式输入属性,每行一个"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="batchDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitBatchForm" :loading="submitting">
|
||||
批量添加
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 快捷操作 -->
|
||||
<div class="quick-actions">
|
||||
<el-button type="success" @click="showBatchDialog">
|
||||
<el-icon><DocumentAdd /></el-icon>
|
||||
批量添加
|
||||
</el-button>
|
||||
<el-button type="warning" @click="clearAllAttributes">
|
||||
<el-icon><Delete /></el-icon>
|
||||
清空所有属性
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, ArrowLeft, DocumentAdd, Delete } from '@element-plus/icons-vue'
|
||||
import api from '@/utils/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const formRef = ref()
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const batchDialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const attributes = ref([])
|
||||
const productName = ref('')
|
||||
const batchText = ref('')
|
||||
|
||||
const form = reactive({
|
||||
attribute_key: '',
|
||||
attribute_value: ''
|
||||
})
|
||||
|
||||
const rules = {
|
||||
attribute_key: [
|
||||
{ required: true, message: '请输入属性名', trigger: 'blur' },
|
||||
{ min: 1, max: 50, message: '属性名长度在 1 到 50 个字符', trigger: 'blur' }
|
||||
],
|
||||
attribute_value: [
|
||||
{ required: true, message: '请输入属性值', trigger: 'blur' },
|
||||
{ min: 1, max: 500, message: '属性值长度在 1 到 500 个字符', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 加载商品属性列表
|
||||
const loadAttributes = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.products.getAttributes(route.params.id)
|
||||
attributes.value = data.data.attributes || []
|
||||
} catch (error) {
|
||||
ElMessage.error('加载属性列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载商品信息
|
||||
const loadProduct = async () => {
|
||||
try {
|
||||
const { data } = await api.products.getProductById(route.params.id)
|
||||
productName.value = data.data.product.name
|
||||
} catch (error) {
|
||||
ElMessage.error('加载商品信息失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 显示添加对话框
|
||||
const showAddDialog = () => {
|
||||
isEdit.value = false
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 显示批量添加对话框
|
||||
const showBatchDialog = () => {
|
||||
batchText.value = ''
|
||||
batchDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑属性
|
||||
const editAttribute = (attr) => {
|
||||
isEdit.value = true
|
||||
Object.assign(form, {
|
||||
id: attr.id,
|
||||
attribute_key: attr.attribute_key,
|
||||
attribute_value: attr.attribute_value
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const submitForm = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
submitting.value = true
|
||||
|
||||
const submitData = {
|
||||
attribute_key: form.attribute_key,
|
||||
attribute_value: form.attribute_value
|
||||
}
|
||||
|
||||
if (isEdit.value) {
|
||||
await api.products.updateAttribute(route.params.id, form.id, submitData)
|
||||
ElMessage.success('属性更新成功')
|
||||
} else {
|
||||
await api.products.createAttribute(route.params.id, submitData)
|
||||
ElMessage.success('属性添加成功')
|
||||
}
|
||||
|
||||
dialogVisible.value = false
|
||||
loadAttributes()
|
||||
} catch (error) {
|
||||
if (error.response?.data?.message) {
|
||||
ElMessage.error(error.response.data.message)
|
||||
} else {
|
||||
ElMessage.error(isEdit.value ? '更新属性失败' : '添加属性失败')
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 批量提交表单
|
||||
const submitBatchForm = async () => {
|
||||
if (!batchText.value.trim()) {
|
||||
ElMessage.error('请输入要添加的属性')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
submitting.value = true
|
||||
|
||||
const lines = batchText.value.trim().split('\n')
|
||||
const attributesData = []
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim()
|
||||
if (!trimmedLine) continue
|
||||
|
||||
const colonIndex = trimmedLine.indexOf(':')
|
||||
if (colonIndex === -1) {
|
||||
ElMessage.error(`格式错误:${trimmedLine},请使用"属性名:属性值"格式`)
|
||||
return
|
||||
}
|
||||
|
||||
const key = trimmedLine.substring(0, colonIndex).trim()
|
||||
const value = trimmedLine.substring(colonIndex + 1).trim()
|
||||
|
||||
if (!key || !value) {
|
||||
ElMessage.error(`格式错误:${trimmedLine},属性名和属性值不能为空`)
|
||||
return
|
||||
}
|
||||
|
||||
attributesData.push({
|
||||
attribute_key: key,
|
||||
attribute_value: value
|
||||
})
|
||||
}
|
||||
|
||||
if (attributesData.length === 0) {
|
||||
ElMessage.error('没有有效的属性数据')
|
||||
return
|
||||
}
|
||||
|
||||
// 批量添加属性
|
||||
for (const attrData of attributesData) {
|
||||
await api.products.createAttribute(route.params.id, attrData)
|
||||
}
|
||||
|
||||
ElMessage.success(`成功添加 ${attributesData.length} 个属性`)
|
||||
batchDialogVisible.value = false
|
||||
loadAttributes()
|
||||
} catch (error) {
|
||||
if (error.response?.data?.message) {
|
||||
ElMessage.error(error.response.data.message)
|
||||
} else {
|
||||
ElMessage.error('批量添加属性失败')
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除属性
|
||||
const deleteAttribute = async (attr) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除属性「${attr.attribute_key}: ${attr.attribute_value}」吗?此操作不可恢复!`,
|
||||
'确认删除',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
|
||||
await api.products.deleteAttribute(route.params.id, attr.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadAttributes()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清空所有属性
|
||||
const clearAllAttributes = async () => {
|
||||
if (attributes.value.length === 0) {
|
||||
ElMessage.info('没有属性可以清空')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要清空所有 ${attributes.value.length} 个属性吗?此操作不可恢复!`,
|
||||
'确认清空',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
|
||||
// 批量删除所有属性
|
||||
for (const attr of attributes.value) {
|
||||
await api.products.deleteAttribute(route.params.id, attr.id)
|
||||
}
|
||||
|
||||
ElMessage.success('清空成功')
|
||||
loadAttributes()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('清空失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
Object.assign(form, {
|
||||
id: null,
|
||||
attribute_key: '',
|
||||
attribute_value: ''
|
||||
})
|
||||
if (formRef.value) {
|
||||
formRef.value.resetFields()
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProduct()
|
||||
loadAttributes()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.attributes-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.batch-form {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
1102
src/views/ProductForm.vue
Normal file
1102
src/views/ProductForm.vue
Normal file
File diff suppressed because it is too large
Load Diff
831
src/views/ProductSpecCombinations.vue
Normal file
831
src/views/ProductSpecCombinations.vue
Normal file
@@ -0,0 +1,831 @@
|
||||
<template>
|
||||
<div class="spec-combinations-container">
|
||||
<div class="header">
|
||||
<h2>商品规格组合管理 - {{ productName }}</h2>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" @click="showSpecNamesDialog">
|
||||
<el-icon><Setting /></el-icon>
|
||||
管理规格名称
|
||||
</el-button>
|
||||
<el-button type="success" @click="showGenerateCombinationsDialog">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
生成规格组合
|
||||
</el-button>
|
||||
<el-button @click="$router.back()">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
返回
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 规格名称管理 -->
|
||||
<el-card class="spec-names-card" v-if="specNames.length > 0">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>规格维度</span>
|
||||
<el-button type="primary" size="small" @click="showSpecNamesDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加规格维度
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="spec-names-list">
|
||||
<el-tag
|
||||
v-for="specName in specNames"
|
||||
:key="specName.id"
|
||||
size="large"
|
||||
closable
|
||||
@close="deleteSpecName(specName)"
|
||||
@click="editSpecName(specName)"
|
||||
class="spec-name-tag"
|
||||
>
|
||||
{{ specName.name }}
|
||||
<span class="spec-name-count">({{ getSpecValueCount(specName.id) }}个值)</span>
|
||||
</el-tag>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 规格组合列表 -->
|
||||
<el-table :data="combinations" v-loading="loading" stripe>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="combination_key" label="组合键" width="200" />
|
||||
<el-table-column prop="display_text" label="规格组合" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
v-for="(spec, index) in row.spec_details"
|
||||
:key="index"
|
||||
size="small"
|
||||
:type="getTagType(index)"
|
||||
class="spec-tag"
|
||||
>
|
||||
{{ spec.spec_name }}: {{ spec.value }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="price_adjustment" label="价格调整" width="120">
|
||||
<template #default="{ row }">
|
||||
<span :class="{
|
||||
'price-positive': row.price_adjustment > 0,
|
||||
'price-negative': row.price_adjustment < 0,
|
||||
'price-zero': row.price_adjustment === 0
|
||||
}">
|
||||
{{ row.price_adjustment > 0 ? '+' : '' }}{{ row.price_adjustment }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="points_adjustment" label="积分调整" width="120">
|
||||
<template #default="{ row }">
|
||||
<span :class="{
|
||||
'price-positive': row.points_adjustment > 0,
|
||||
'price-negative': row.points_adjustment < 0,
|
||||
'price-zero': row.points_adjustment === 0
|
||||
}">
|
||||
{{ row.points_adjustment > 0 ? '+' : '' }}{{ row.points_adjustment }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="rongdou_adjustment" label="融豆调整" width="120">
|
||||
<template #default="{ row }">
|
||||
<span :class="{
|
||||
'price-positive': row.rongdou_adjustment > 0,
|
||||
'price-negative': row.rongdou_adjustment < 0,
|
||||
'price-zero': row.rongdou_adjustment === 0
|
||||
}">
|
||||
{{ row.rongdou_adjustment > 0 ? '+' : '' }}{{ row.rongdou_adjustment }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="stock" label="库存" width="100" />
|
||||
<el-table-column prop="is_available" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_available ? 'success' : 'danger'">
|
||||
{{ row.is_available ? '可用' : '不可用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="editCombination(row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="deleteCombination(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 规格名称管理对话框 -->
|
||||
<el-dialog
|
||||
v-model="specNamesDialogVisible"
|
||||
title="规格名称管理"
|
||||
width="800px"
|
||||
>
|
||||
<div class="spec-names-management">
|
||||
<div class="add-spec-name">
|
||||
<el-input
|
||||
v-model="newSpecName"
|
||||
placeholder="请输入规格名称,如:颜色、尺寸、材质"
|
||||
@keyup.enter="addSpecName"
|
||||
/>
|
||||
<el-button type="primary" @click="addSpecName">添加</el-button>
|
||||
</div>
|
||||
|
||||
<div class="spec-names-with-values">
|
||||
<div
|
||||
v-for="specName in specNames"
|
||||
:key="specName.id"
|
||||
class="spec-name-item"
|
||||
>
|
||||
<div class="spec-name-header">
|
||||
<h4>{{ specName.name }}</h4>
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="deleteSpecName(specName)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="spec-values">
|
||||
<div class="add-spec-value">
|
||||
<el-input
|
||||
v-model="newSpecValues[specName.id]"
|
||||
placeholder="请输入规格值"
|
||||
@keyup.enter="addSpecValue(specName.id)"
|
||||
/>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="addSpecValue(specName.id)"
|
||||
>
|
||||
添加值
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="spec-value-list">
|
||||
<el-tag
|
||||
v-for="value in getSpecValues(specName.id)"
|
||||
:key="value.id"
|
||||
closable
|
||||
@close="deleteSpecValue(value)"
|
||||
class="spec-value-tag"
|
||||
>
|
||||
{{ value.value }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 编辑规格组合对话框 -->
|
||||
<el-dialog
|
||||
v-model="combinationDialogVisible"
|
||||
title="编辑规格组合"
|
||||
width="600px"
|
||||
>
|
||||
<el-form
|
||||
ref="combinationFormRef"
|
||||
:model="combinationForm"
|
||||
:rules="combinationRules"
|
||||
label-width="120px"
|
||||
>
|
||||
<el-form-item label="规格组合">
|
||||
<div class="combination-display">
|
||||
<el-tag
|
||||
v-for="(spec, index) in combinationForm.spec_details"
|
||||
:key="index"
|
||||
size="large"
|
||||
:type="getTagType(index)"
|
||||
>
|
||||
{{ spec.spec_name }}: {{ spec.spec_value }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="价格调整" prop="price_adjustment">
|
||||
<el-input-number
|
||||
v-model="combinationForm.price_adjustment"
|
||||
:precision="2"
|
||||
placeholder="价格调整(正数为加价,负数为减价)"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="积分调整" prop="points_adjustment">
|
||||
<el-input-number
|
||||
v-model="combinationForm.points_adjustment"
|
||||
:min="0"
|
||||
placeholder="积分调整"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="融豆调整" prop="rongdou_adjustment">
|
||||
<el-input-number
|
||||
v-model="combinationForm.rongdou_adjustment"
|
||||
:min="0"
|
||||
placeholder="融豆调整"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="库存" prop="stock">
|
||||
<el-input-number
|
||||
v-model="combinationForm.stock"
|
||||
:min="0"
|
||||
placeholder="库存数量"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="是否可用">
|
||||
<el-switch
|
||||
v-model="combinationForm.is_available"
|
||||
active-text="可用"
|
||||
inactive-text="不可用"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="combinationDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitCombinationForm" :loading="submitting">
|
||||
更新
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 生成规格组合选择对话框 -->
|
||||
<el-dialog
|
||||
v-model="generateDialogVisible"
|
||||
title="选择规格名称生成组合"
|
||||
width="600px"
|
||||
>
|
||||
<div class="generate-combinations-content">
|
||||
<p class="dialog-description">
|
||||
请选择要用于生成规格组合的规格名称。系统将基于选中的规格名称及其规格值生成笛卡尔积组合。
|
||||
</p>
|
||||
|
||||
<el-form label-width="120px">
|
||||
<el-form-item label="选择规格名称">
|
||||
<el-checkbox-group v-model="selectedSpecNames">
|
||||
<div class="spec-name-options">
|
||||
<el-checkbox
|
||||
v-for="specName in availableSpecNames"
|
||||
:key="specName.id"
|
||||
:label="specName.id"
|
||||
:disabled="getSpecValues(specName.id).length === 0"
|
||||
class="spec-name-checkbox"
|
||||
>
|
||||
<div class="spec-name-info">
|
||||
<span class="spec-name-text">{{ specName.name }}</span>
|
||||
<span class="spec-value-count">
|
||||
({{ getSpecValues(specName.id).length }}个值)
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="getSpecValues(specName.id).length === 0" class="no-values-tip">
|
||||
该规格名称暂无规格值
|
||||
</div>
|
||||
</el-checkbox>
|
||||
</div>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="默认库存">
|
||||
<el-input-number
|
||||
v-model="defaultStock"
|
||||
:min="0"
|
||||
placeholder="生成组合的默认库存数量"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div v-if="selectedSpecNames.length > 0" class="preview-info">
|
||||
<el-alert
|
||||
:title="`将生成 ${calculateCombinationCount()} 个规格组合`"
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="generateDialogVisible = false">取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="confirmGenerateCombinations"
|
||||
:disabled="selectedSpecNames.length === 0 || generating"
|
||||
:loading="generating"
|
||||
>
|
||||
生成 {{ calculateCombinationCount() }} 个组合
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, ArrowLeft, Setting, Refresh } from '@element-plus/icons-vue'
|
||||
import api from '@/utils/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const combinationFormRef = ref()
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const generating = ref(false)
|
||||
const specNamesDialogVisible = ref(false)
|
||||
const combinationDialogVisible = ref(false)
|
||||
const generateDialogVisible = ref(false)
|
||||
|
||||
const productName = ref('')
|
||||
const specNames = ref([])
|
||||
const specValues = ref([])
|
||||
const combinations = ref([])
|
||||
const newSpecName = ref('')
|
||||
const newSpecValues = reactive({})
|
||||
const selectedSpecNames = ref([])
|
||||
const defaultStock = ref(0)
|
||||
|
||||
const combinationForm = reactive({
|
||||
id: null,
|
||||
spec_details: [],
|
||||
price_adjustment: 0,
|
||||
points_adjustment: 0,
|
||||
rongdou_adjustment: 0,
|
||||
stock: 0,
|
||||
is_available: true
|
||||
})
|
||||
|
||||
const combinationRules = {
|
||||
price_adjustment: [
|
||||
{ type: 'number', message: '价格调整必须是数字', trigger: 'blur' }
|
||||
],
|
||||
points_adjustment: [
|
||||
{ type: 'number', min: 0, message: '积分调整不能小于0', trigger: 'blur' }
|
||||
],
|
||||
rongdou_adjustment: [
|
||||
{ type: 'number', min: 0, message: '融豆调整不能小于0', trigger: 'blur' }
|
||||
],
|
||||
stock: [
|
||||
{ required: true, message: '请输入库存数量', trigger: 'blur' },
|
||||
{ type: 'number', min: 0, message: '库存数量不能小于0', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 计算属性:获取可用的规格名称(有规格值的)
|
||||
const availableSpecNames = computed(() => {
|
||||
return specNames.value.filter(specName => {
|
||||
const values = getSpecValues(specName.id)
|
||||
return values.length > 0
|
||||
})
|
||||
})
|
||||
|
||||
// 获取规格值数量
|
||||
const getSpecValueCount = (specNameId) => {
|
||||
return specValues.value.filter(v => v.spec_name_id === specNameId).length
|
||||
}
|
||||
|
||||
// 获取指定规格名称的规格值
|
||||
const getSpecValues = (specNameId) => {
|
||||
return specValues.value.filter(v => v.spec_name_id === specNameId)
|
||||
}
|
||||
|
||||
// 获取标签类型
|
||||
const getTagType = (index) => {
|
||||
const types = ['primary', 'success', 'warning', 'danger', 'info']
|
||||
return types[index % types.length]
|
||||
}
|
||||
|
||||
// 计算将要生成的组合数量
|
||||
const calculateCombinationCount = () => {
|
||||
if (selectedSpecNames.value.length === 0) return 0
|
||||
|
||||
let count = 1
|
||||
for (const specNameId of selectedSpecNames.value) {
|
||||
const values = getSpecValues(specNameId)
|
||||
count *= values.length
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// 加载商品信息
|
||||
const loadProduct = async () => {
|
||||
try {
|
||||
const { data } = await api.products.getProductById(route.params.id)
|
||||
productName.value = data.data.product.name
|
||||
} catch (error) {
|
||||
ElMessage.error('加载商品信息失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 加载规格名称
|
||||
const loadSpecNames = async () => {
|
||||
try {
|
||||
const { data } = await api.specifications.getSpecNames()
|
||||
specNames.value = data.data || []
|
||||
} catch (error) {
|
||||
console.error('加载规格名称失败:', error)
|
||||
specNames.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 加载规格值
|
||||
const loadSpecValues = async (specNameId = null) => {
|
||||
try {
|
||||
const { data } = await api.specifications.getSpecValues(specNameId)
|
||||
specValues.value = data.data || []
|
||||
} catch (error) {
|
||||
console.error('加载规格值失败:', error)
|
||||
specValues.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 加载规格组合
|
||||
const loadCombinations = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.specifications.getCombinations(route.params.id)
|
||||
combinations.value = data.data || []
|
||||
} catch (error) {
|
||||
ElMessage.error('加载规格组合失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 显示规格名称管理对话框
|
||||
const showSpecNamesDialog = () => {
|
||||
specNamesDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 显示生成规格组合选择对话框
|
||||
const showGenerateCombinationsDialog = () => {
|
||||
// 检查是否有规格名称
|
||||
if (!specNames.value || specNames.value.length === 0) {
|
||||
ElMessage.warning('请先添加规格名称')
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否有可用的规格名称(有规格值的)
|
||||
if (availableSpecNames.value.length === 0) {
|
||||
ElMessage.warning('没有可用的规格名称,请先为规格名称添加规格值')
|
||||
return
|
||||
}
|
||||
|
||||
// 重置选择状态
|
||||
selectedSpecNames.value = []
|
||||
defaultStock.value = 0
|
||||
generateDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 添加规格名称
|
||||
const addSpecName = async () => {
|
||||
if (!newSpecName.value.trim()) {
|
||||
ElMessage.warning('请输入规格名称')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await api.specifications.createSpecName({
|
||||
name: newSpecName.value.trim(),
|
||||
display_name: newSpecName.value.trim()
|
||||
})
|
||||
|
||||
ElMessage.success('添加成功')
|
||||
newSpecName.value = ''
|
||||
loadSpecNames()
|
||||
} catch (error) {
|
||||
ElMessage.error('添加失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 删除规格名称
|
||||
const deleteSpecName = async (specName) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除规格名称「${specName.name}」吗?这将同时删除该规格下的所有值和相关组合!`,
|
||||
'确认删除',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
|
||||
await api.specifications.deleteSpecName(specName.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadSpecNames()
|
||||
loadSpecValues()
|
||||
loadCombinations()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加规格值
|
||||
const addSpecValue = async (specNameId) => {
|
||||
const value = newSpecValues[specNameId]
|
||||
if (!value || !value.trim()) {
|
||||
ElMessage.warning('请输入规格值')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await api.specifications.createSpecValue({
|
||||
spec_name_id: specNameId,
|
||||
value: value.trim(),
|
||||
display_value: value.trim()
|
||||
})
|
||||
|
||||
ElMessage.success('添加成功')
|
||||
newSpecValues[specNameId] = ''
|
||||
loadSpecValues()
|
||||
} catch (error) {
|
||||
ElMessage.error('添加失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 删除规格值
|
||||
const deleteSpecValue = async (specValue) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除规格值「${specValue.value}」吗?这将同时删除相关的规格组合!`,
|
||||
'确认删除',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
|
||||
await api.specifications.deleteSpecValue(specValue.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadSpecValues()
|
||||
loadCombinations()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 确认生成规格组合
|
||||
const confirmGenerateCombinations = async () => {
|
||||
try {
|
||||
const combinationCount = calculateCombinationCount()
|
||||
await ElMessageBox.confirm(
|
||||
`确定要生成 ${combinationCount} 个规格组合吗?这将基于选中的规格名称和值生成笛卡尔积组合。`,
|
||||
'确认生成',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'info'
|
||||
}
|
||||
)
|
||||
|
||||
generating.value = true
|
||||
await api.specifications.generateCombinations({
|
||||
product_id: parseInt(route.params.id),
|
||||
spec_name_ids: selectedSpecNames.value,
|
||||
default_stock: defaultStock.value
|
||||
})
|
||||
|
||||
ElMessage.success(`成功生成 ${combinationCount} 个规格组合`)
|
||||
generateDialogVisible.value = false
|
||||
loadCombinations()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('生成失败')
|
||||
}
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑规格组合
|
||||
const editCombination = (combination) => {
|
||||
Object.assign(combinationForm, {
|
||||
id: combination.id,
|
||||
spec_details: combination.spec_details || [],
|
||||
price_adjustment: combination.price_adjustment || 0,
|
||||
points_adjustment: combination.points_adjustment || 0,
|
||||
rongdou_adjustment: combination.rongdou_adjustment || 0,
|
||||
stock: combination.stock || 0,
|
||||
is_available: combination.is_available !== false
|
||||
})
|
||||
combinationDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 提交规格组合表单
|
||||
const submitCombinationForm = async () => {
|
||||
if (!combinationFormRef.value) return
|
||||
|
||||
try {
|
||||
await combinationFormRef.value.validate()
|
||||
submitting.value = true
|
||||
|
||||
const submitData = {
|
||||
price_adjustment: combinationForm.price_adjustment,
|
||||
points_adjustment: combinationForm.points_adjustment,
|
||||
rongdou_adjustment: combinationForm.rongdou_adjustment,
|
||||
stock: combinationForm.stock,
|
||||
is_available: combinationForm.is_available
|
||||
}
|
||||
|
||||
await api.specifications.updateCombination(combinationForm.id, submitData)
|
||||
ElMessage.success('更新成功')
|
||||
combinationDialogVisible.value = false
|
||||
loadCombinations()
|
||||
} catch (error) {
|
||||
if (error.response?.data?.message) {
|
||||
ElMessage.error(error.response.data.message)
|
||||
} else {
|
||||
ElMessage.error('更新失败')
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除规格组合
|
||||
const deleteCombination = async (combination) => {
|
||||
console.log(combination,'combination');
|
||||
let display_text = combination.spec_details.map(item => `${item.spec_name}: ${item.value}`).join(',')
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除规格组合「${display_text}」吗?此操作不可恢复!`,
|
||||
'确认删除',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
|
||||
await api.specifications.deleteCombination(combination.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadCombinations()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProduct()
|
||||
loadSpecNames()
|
||||
loadSpecValues()
|
||||
loadCombinations()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.spec-combinations-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.spec-names-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.spec-names-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.spec-name-tag {
|
||||
cursor: pointer;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.spec-name-count {
|
||||
margin-left: 5px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.spec-tag {
|
||||
margin-right: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.price-positive {
|
||||
color: #f56c6c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.price-negative {
|
||||
color: #67c23a;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.price-zero {
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.spec-names-management {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.add-spec-name {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.spec-name-item {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.spec-name-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.spec-name-header h4 {
|
||||
margin: 0;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.add-spec-value {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.spec-value-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.spec-value-tag {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.combination-display {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
327
src/views/Products.vue
Normal file
327
src/views/Products.vue
Normal file
@@ -0,0 +1,327 @@
|
||||
<template>
|
||||
<div class="products-container">
|
||||
<div class="header">
|
||||
<h2>商品管理</h2>
|
||||
<el-button type="primary" @click="$router.push('/products/create')">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加商品
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="商品名称">
|
||||
<el-input
|
||||
v-model="filters.search"
|
||||
placeholder="请输入商品名称"
|
||||
clearable
|
||||
@keyup.enter="loadProducts"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-select v-model="filters.category" placeholder="请选择分类" clearable style="display: inline-block; width: 150px;">
|
||||
<el-option
|
||||
v-for="category in categories"
|
||||
:key="category"
|
||||
:label="category"
|
||||
:value="category"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filters.status" placeholder="请选择状态" clearable style="display: inline-block; width: 150px;">
|
||||
<el-option label="上架" value="active" />
|
||||
<el-option label="下架" value="inactive" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="loadProducts">搜索</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<el-table :data="products" v-loading="loading" stripe>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column label="商品图片" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-image
|
||||
:src="getImageUrl(row.image)"
|
||||
:preview-src-list="[getImageUrl(row.image)]"
|
||||
fit="cover"
|
||||
style="width: 60px; height: 60px; border-radius: 4px;"
|
||||
preview-teleported
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="商品名称" min-width="150" />
|
||||
<el-table-column prop="category" label="分类" width="120" />
|
||||
<el-table-column prop="points" label="积分价格" width="100">
|
||||
<template #default="{ row }">
|
||||
<span class="points-text">{{ row.points }} 积分</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="rongdou_price" label="融豆价格" width="100">
|
||||
<template #default="{ row }">
|
||||
<span class="rongdou-text">{{ row.rongdou_price || 0 }} 融豆</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="shop_name" label="店家" width="120">
|
||||
<template #default="{ row }">
|
||||
<div class="shop-info">
|
||||
<el-avatar v-if="row.shop_avatar" :src="getImageUrl(row.shop_avatar)" :size="24" />
|
||||
<span>{{ row.shop_name || '默认店铺' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="stock" label="库存" width="80" />
|
||||
<el-table-column prop="sales" label="销量" width="80" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'danger'">
|
||||
{{ row.status === 'active' ? '上架' : '下架' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="280" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="editProduct(row.id)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="info" size="small" @click="viewSpecCombinations(row.id)">
|
||||
规格管理
|
||||
</el-button>
|
||||
<el-button
|
||||
:type="row.status === 'active' ? 'warning' : 'success'"
|
||||
size="small"
|
||||
@click="toggleStatus(row)"
|
||||
>
|
||||
{{ row.status === 'active' ? '下架' : '上架' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="deleteProduct(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.limit"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="pagination.total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="loadProducts"
|
||||
@current-change="loadProducts"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, ArrowDown, Setting, Grid } from '@element-plus/icons-vue'
|
||||
import api from '@/utils/api'
|
||||
import { getImageUrl } from '@/utils/config'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const products = ref([])
|
||||
const categories = ref([])
|
||||
|
||||
const filters = reactive({
|
||||
search: '',
|
||||
category: '',
|
||||
status: ''
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0
|
||||
})
|
||||
|
||||
// 加载商品列表
|
||||
const loadProducts = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.page,
|
||||
limit: pagination.limit,
|
||||
...filters
|
||||
}
|
||||
|
||||
const {data} = await api.products.getProducts(params)
|
||||
products.value = data.data.products
|
||||
pagination.total = data.data.total
|
||||
} catch (error) {
|
||||
ElMessage.error('加载商品列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载商品分类
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const {data} = await api.products.getCategories()
|
||||
categories.value = data.data.categories
|
||||
} catch (error) {
|
||||
console.error('加载分类失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 重置筛选条件
|
||||
const resetFilters = () => {
|
||||
Object.assign(filters, {
|
||||
search: '',
|
||||
category: '',
|
||||
status: ''
|
||||
})
|
||||
pagination.page = 1
|
||||
loadProducts()
|
||||
}
|
||||
|
||||
// 编辑商品
|
||||
const editProduct = (id) => {
|
||||
router.push(`/products/edit/${id}`)
|
||||
}
|
||||
|
||||
// 切换商品状态
|
||||
const toggleStatus = async (product) => {
|
||||
const action = product.status === 'active' ? '下架' : '上架'
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要${action}商品「${product.name}」吗?`,
|
||||
'确认操作',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
|
||||
const newStatus = product.status === 'active' ? 'inactive' : 'active'
|
||||
await api.products.updateProduct(product.id, { status: newStatus })
|
||||
|
||||
ElMessage.success(`${action}成功`)
|
||||
loadProducts()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(`${action}失败`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除商品
|
||||
const deleteProduct = async (product) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除商品「${product.name}」吗?此操作不可恢复!`,
|
||||
'确认删除',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
|
||||
await api.products.deleteProduct(product.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadProducts()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 查看商品规格组合
|
||||
const viewSpecCombinations = (id) => {
|
||||
router.push(`/products/${id}/spec-combinations`)
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProducts()
|
||||
loadCategories()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.products-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.filters {
|
||||
background: #f5f7fa;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.points-text {
|
||||
color: #e6a23c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rongdou-text {
|
||||
color: #67c23a;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.shop-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.shop-info span {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
807
src/views/Profile.vue
Normal file
807
src/views/Profile.vue
Normal file
@@ -0,0 +1,807 @@
|
||||
<template>
|
||||
<div class="profile-page">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">个人资料</h1>
|
||||
<p class="page-subtitle">管理您的个人信息和账户设置</p>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<!-- 左侧个人信息 -->
|
||||
<el-col :xs="24" :lg="16">
|
||||
<el-card class="profile-card" shadow="never">
|
||||
<template #header>
|
||||
<span class="card-title">基本信息</span>
|
||||
</template>
|
||||
|
||||
<el-form
|
||||
ref="profileFormRef"
|
||||
:model="profileForm"
|
||||
:rules="profileRules"
|
||||
label-width="100px"
|
||||
class="profile-form"
|
||||
>
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input
|
||||
v-model="profileForm.username"
|
||||
placeholder="请输入用户名"
|
||||
maxlength="20"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
|
||||
|
||||
<el-form-item label="昵称" prop="nickname">
|
||||
<el-input
|
||||
v-model="profileForm.nickname"
|
||||
placeholder="请输入昵称(可选)"
|
||||
maxlength="30"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="真实姓名" prop="realName">
|
||||
<el-input
|
||||
v-model="profileForm.realName"
|
||||
placeholder="请输入真实姓名"
|
||||
maxlength="20"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="身份证号" prop="idCard">
|
||||
<el-input
|
||||
v-model="profileForm.idCard"
|
||||
placeholder="请输入身份证号"
|
||||
maxlength="18"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="手机号" prop="phone">
|
||||
<el-input
|
||||
v-model="profileForm.phone"
|
||||
placeholder="请输入手机号"
|
||||
maxlength="11"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="微信收款码">
|
||||
<el-upload
|
||||
class="qr-uploader"
|
||||
:action="uploadAction"
|
||||
:headers="uploadHeaders"
|
||||
:show-file-list="false"
|
||||
:on-success="(response) => handleQrSuccess(response, 'wechatQr')"
|
||||
:before-upload="beforeQrUpload"
|
||||
accept="image/*"
|
||||
>
|
||||
<div class="qr-upload-area">
|
||||
<img v-if="profileForm.wechatQr" :src="getImageUrl(profileForm.wechatQr)" class="qr-image" />
|
||||
<div v-else class="upload-placeholder">
|
||||
<el-icon class="upload-icon"><Upload /></el-icon>
|
||||
<div class="upload-text">点击上传微信收款码</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div class="upload-tip">支持 JPG、PNG 格式,建议尺寸 300x300,大小不超过 2MB</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="支付宝收款码">
|
||||
<el-upload
|
||||
class="qr-uploader"
|
||||
:action="uploadAction"
|
||||
:headers="uploadHeaders"
|
||||
:show-file-list="false"
|
||||
:on-success="(response) => handleQrSuccess(response, 'alipayQr')"
|
||||
:before-upload="beforeQrUpload"
|
||||
accept="image/*"
|
||||
>
|
||||
<div class="qr-upload-area">
|
||||
<img v-if="profileForm.alipayQr" :src="getImageUrl(profileForm.alipayQr)" class="qr-image" />
|
||||
<div v-else class="upload-placeholder">
|
||||
<el-icon class="upload-icon"><Upload /></el-icon>
|
||||
<div class="upload-text">点击上传支付宝收款码</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div class="upload-tip">支持 JPG、PNG 格式,建议尺寸 300x300,大小不超过 2MB</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="银行卡号">
|
||||
<el-input
|
||||
v-model="profileForm.bankCard"
|
||||
placeholder="请输入银行卡号(可选)"
|
||||
maxlength="19"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="云闪付收款码">
|
||||
<el-upload
|
||||
class="qr-uploader"
|
||||
:action="uploadAction"
|
||||
:headers="uploadHeaders"
|
||||
:show-file-list="false"
|
||||
:on-success="(response) => handleQrSuccess(response, 'unionpayQr')"
|
||||
:before-upload="beforeQrUpload"
|
||||
accept="image/*"
|
||||
>
|
||||
<div class="qr-upload-area">
|
||||
<img v-if="profileForm.unionpayQr" :src="getImageUrl(profileForm.unionpayQr)" class="qr-image" />
|
||||
<div v-else class="upload-placeholder">
|
||||
<el-icon class="upload-icon"><Upload /></el-icon>
|
||||
<div class="upload-text">点击上传云闪付收款码</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div class="upload-tip">支持 JPG、PNG 格式,建议尺寸 300x300,大小不超过 2MB</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="updateProfile"
|
||||
:loading="updating"
|
||||
>
|
||||
保存修改
|
||||
</el-button>
|
||||
<el-button @click="resetForm">
|
||||
重置
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 修改密码 -->
|
||||
<el-card class="password-card" shadow="never">
|
||||
<template #header>
|
||||
<span class="card-title">修改密码</span>
|
||||
</template>
|
||||
|
||||
<el-form
|
||||
ref="passwordFormRef"
|
||||
:model="passwordForm"
|
||||
:rules="passwordRules"
|
||||
label-width="100px"
|
||||
class="password-form"
|
||||
>
|
||||
<el-form-item label="当前密码" prop="currentPassword">
|
||||
<el-input
|
||||
v-model="passwordForm.currentPassword"
|
||||
type="password"
|
||||
placeholder="请输入当前密码"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="新密码" prop="newPassword">
|
||||
<el-input
|
||||
v-model="passwordForm.newPassword"
|
||||
type="password"
|
||||
placeholder="请输入新密码"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="确认密码" prop="confirmPassword">
|
||||
<el-input
|
||||
v-model="passwordForm.confirmPassword"
|
||||
type="password"
|
||||
placeholder="请再次输入新密码"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="changePassword"
|
||||
:loading="changingPassword"
|
||||
>
|
||||
修改密码
|
||||
</el-button>
|
||||
<el-button @click="resetPasswordForm">
|
||||
重置
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 右侧头像和统计 -->
|
||||
<el-col :xs="24" :lg="8">
|
||||
<el-card class="avatar-card" shadow="never">
|
||||
<template #header>
|
||||
<span class="card-title">头像设置</span>
|
||||
</template>
|
||||
|
||||
<div class="avatar-section">
|
||||
<div class="avatar-container">
|
||||
<el-avatar
|
||||
:size="120"
|
||||
:src="getImageUrl(profileForm.avatar)"
|
||||
class="user-avatar"
|
||||
>
|
||||
<el-icon><User /></el-icon>
|
||||
</el-avatar>
|
||||
|
||||
<el-upload
|
||||
class="avatar-uploader"
|
||||
:action="uploadAction"
|
||||
:headers="uploadHeaders"
|
||||
:show-file-list="false"
|
||||
:on-success="handleAvatarSuccess"
|
||||
:before-upload="beforeAvatarUpload"
|
||||
accept="image/*"
|
||||
>
|
||||
<el-button size="small" type="primary">
|
||||
<el-icon><Upload /></el-icon>
|
||||
更换头像
|
||||
</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
|
||||
<div class="upload-tip">
|
||||
支持 JPG、PNG 格式,建议尺寸 200x200,大小不超过 2MB
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 账户信息 -->
|
||||
<el-card class="account-card" shadow="never">
|
||||
<template #header>
|
||||
<span class="card-title">账户信息</span>
|
||||
</template>
|
||||
|
||||
<div class="account-info">
|
||||
<div class="info-item">
|
||||
<span class="info-label">用户ID:</span>
|
||||
<span class="info-value">{{ userStore.user?.id }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<span class="info-label">角色:</span>
|
||||
<el-tag
|
||||
:type="userStore.user?.role === 'admin' ? 'danger' : 'primary'"
|
||||
size="small"
|
||||
>
|
||||
{{ userStore.user?.role === 'admin' ? '管理员' : '普通用户' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<span class="info-label">注册时间:</span>
|
||||
<span class="info-value">{{ formatDate(userStore.user?.created_at) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item" v-if="userStore.user?.real_name">
|
||||
<span class="info-label">真实姓名:</span>
|
||||
<span class="info-value">{{ userStore.user.real_name }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item" v-if="userStore.user?.phone">
|
||||
<span class="info-label">手机号:</span>
|
||||
<span class="info-value">{{ userStore.user.phone }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item" v-if="userStore.user?.id_card">
|
||||
<span class="info-label">身份证号:</span>
|
||||
<span class="info-value">{{ userStore.user.id_card.replace(/(\d{6})\d{8}(\d{4})/, '$1********$2') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 收款信息 -->
|
||||
<el-card class="payment-card" shadow="never" v-if="userStore.user?.wechat_qr || userStore.user?.alipay_qr || userStore.user?.bank_card || userStore.user?.unionpay_qr">
|
||||
<template #header>
|
||||
<span class="card-title">收款信息</span>
|
||||
</template>
|
||||
|
||||
<div class="payment-info">
|
||||
<div class="info-item" v-if="userStore.user?.wechat_qr">
|
||||
<span class="info-label">微信收款码:</span>
|
||||
<span class="info-value">已设置</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item" v-if="userStore.user?.alipay_qr">
|
||||
<span class="info-label">支付宝收款码:</span>
|
||||
<span class="info-value">已设置</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item" v-if="userStore.user?.bank_card">
|
||||
<span class="info-label">银行卡号:</span>
|
||||
<span class="info-value">{{ userStore.user.bank_card.replace(/(\d{4})\d{8,11}(\d{4})/, '$1****$2') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item" v-if="userStore.user?.unionpay_qr">
|
||||
<span class="info-label">云闪付收款码:</span>
|
||||
<span class="info-value">已设置</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import api from '@/utils/api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { User, Upload } from '@element-plus/icons-vue'
|
||||
import { getImageUrl } from '@/utils/config'
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 响应式数据
|
||||
const updating = ref(false)
|
||||
const changingPassword = ref(false)
|
||||
|
||||
// 表单引用
|
||||
const profileFormRef = ref()
|
||||
const passwordFormRef = ref()
|
||||
|
||||
// 个人信息表单
|
||||
const profileForm = reactive({
|
||||
username: '',
|
||||
|
||||
nickname: '',
|
||||
realName: '',
|
||||
idCard: '',
|
||||
phone: '',
|
||||
wechatQr: '',
|
||||
alipayQr: '',
|
||||
bankCard: '',
|
||||
unionpayQr: '',
|
||||
avatar: ''
|
||||
})
|
||||
|
||||
// 密码表单
|
||||
const passwordForm = reactive({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const profileRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 3, max: 20, message: '用户名长度在 3 到 20 个字符', trigger: 'blur' },
|
||||
{ pattern: /^[a-zA-Z0-9_]+$/, message: '用户名只能包含字母、数字和下划线', trigger: 'blur' }
|
||||
],
|
||||
|
||||
realName: [
|
||||
{ required: true, message: '请输入真实姓名', trigger: 'blur' },
|
||||
{ min: 2, max: 20, message: '真实姓名长度在 2 到 20 个字符', trigger: 'blur' },
|
||||
{ pattern: /^[\u4e00-\u9fa5a-zA-Z]+$/, message: '真实姓名只能包含中文和英文字母', trigger: 'blur' }
|
||||
],
|
||||
idCard: [
|
||||
{ required: true, message: '请输入身份证号', trigger: 'blur' },
|
||||
{ pattern: /^[1-9]\d{5}(18|19|20)\d{2}((0[1-9])|(1[0-2]))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$/, message: '请输入正确的身份证号格式', trigger: 'blur' }
|
||||
],
|
||||
phone: [
|
||||
{ required: true, message: '请输入手机号', trigger: 'blur' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号格式', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
const passwordRules = {
|
||||
currentPassword: [
|
||||
{ required: true, message: '请输入当前密码', trigger: 'blur' }
|
||||
],
|
||||
newPassword: [
|
||||
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||||
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' },
|
||||
{
|
||||
pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d@$!%*?&]/,
|
||||
message: '密码必须包含大小写字母和数字',
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: '请确认新密码', trigger: 'blur' },
|
||||
{
|
||||
validator: (rule, value, callback) => {
|
||||
if (value !== passwordForm.newPassword) {
|
||||
callback(new Error('两次输入的密码不一致'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 上传配置
|
||||
const uploadAction = '/api/upload'
|
||||
const uploadHeaders = computed(() => ({
|
||||
Authorization: `Bearer ${userStore.token}`
|
||||
}))
|
||||
|
||||
// 初始化表单数据
|
||||
const initFormData = () => {
|
||||
const user = userStore.user
|
||||
if (user) {
|
||||
Object.assign(profileForm, {
|
||||
username: user.username || '',
|
||||
|
||||
nickname: user.nickname || user.username || '',
|
||||
realName: user.real_name || '',
|
||||
idCard: user.id_card || '',
|
||||
phone: user.phone || '',
|
||||
wechatQr: user.wechat_qr || '',
|
||||
alipayQr: user.alipay_qr || '',
|
||||
bankCard: user.bank_card || '',
|
||||
unionpayQr: user.unionpay_qr || '',
|
||||
avatar: user.avatar || ''
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 更新个人信息
|
||||
const updateProfile = async () => {
|
||||
try {
|
||||
await profileFormRef.value.validate()
|
||||
|
||||
updating.value = true
|
||||
|
||||
const updateData = {
|
||||
username: profileForm.username,
|
||||
|
||||
nickname: profileForm.nickname,
|
||||
realName: profileForm.realName,
|
||||
idCard: profileForm.idCard,
|
||||
phone: profileForm.phone,
|
||||
wechatQr: profileForm.wechatQr,
|
||||
alipayQr: profileForm.alipayQr,
|
||||
bankCard: profileForm.bankCard,
|
||||
unionpayQr: profileForm.unionpayQr,
|
||||
avatar: profileForm.avatar
|
||||
}
|
||||
|
||||
// 调用后端API更新个人信息
|
||||
const response = await api.users.updateUser(userStore.user.id, updateData)
|
||||
|
||||
// 更新本地用户信息
|
||||
userStore.user = { ...userStore.user, ...response.data.user }
|
||||
localStorage.setItem('admin_user', JSON.stringify(userStore.user))
|
||||
|
||||
ElMessage.success('个人信息更新成功')
|
||||
} catch (error) {
|
||||
console.error('更新个人信息失败:', error)
|
||||
const message = error.response?.data?.message || '更新失败'
|
||||
ElMessage.error(message)
|
||||
} finally {
|
||||
updating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 修改密码
|
||||
const changePassword = async () => {
|
||||
try {
|
||||
await passwordFormRef.value.validate()
|
||||
|
||||
changingPassword.value = true
|
||||
|
||||
await api.users.changePassword({
|
||||
currentPassword: passwordForm.currentPassword,
|
||||
newPassword: passwordForm.newPassword
|
||||
})
|
||||
|
||||
ElMessage.success('密码修改成功')
|
||||
resetPasswordForm()
|
||||
} catch (error) {
|
||||
console.error('修改密码失败:', error)
|
||||
const message = error.response?.data?.message || '修改密码失败'
|
||||
ElMessage.error(message)
|
||||
} finally {
|
||||
changingPassword.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 头像上传成功
|
||||
const handleAvatarSuccess = (response) => {
|
||||
if (response.success) {
|
||||
profileForm.avatar = response.url
|
||||
|
||||
// 更新用户信息
|
||||
updateProfile()
|
||||
|
||||
ElMessage.success('头像上传成功')
|
||||
} else {
|
||||
ElMessage.error('头像上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 收款码上传成功
|
||||
const handleQrSuccess = (response, field) => {
|
||||
if (response.success) {
|
||||
profileForm[field] = response.url
|
||||
ElMessage.success('收款码上传成功')
|
||||
} else {
|
||||
ElMessage.error('收款码上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 头像上传前验证
|
||||
const beforeAvatarUpload = (file) => {
|
||||
const isImage = file.type.startsWith('image/')
|
||||
const isLt2M = file.size / 1024 / 1024 < 2
|
||||
|
||||
if (!isImage) {
|
||||
ElMessage.error('只能上传图片文件!')
|
||||
}
|
||||
if (!isLt2M) {
|
||||
ElMessage.error('图片大小不能超过 2MB!')
|
||||
}
|
||||
return isImage && isLt2M
|
||||
}
|
||||
|
||||
// 收款码上传前验证
|
||||
const beforeQrUpload = (file) => {
|
||||
const isImage = file.type.startsWith('image/')
|
||||
const isLt2M = file.size / 1024 / 1024 < 2
|
||||
|
||||
if (!isImage) {
|
||||
ElMessage.error('只能上传图片文件!')
|
||||
}
|
||||
if (!isLt2M) {
|
||||
ElMessage.error('图片大小不能超过 2MB!')
|
||||
}
|
||||
return isImage && isLt2M
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
initFormData()
|
||||
profileFormRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
// 重置密码表单
|
||||
const resetPasswordForm = () => {
|
||||
Object.assign(passwordForm, {
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
passwordFormRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-'
|
||||
return new Date(dateString).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 组件挂载时初始化数据
|
||||
onMounted(() => {
|
||||
initFormData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.profile-page {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
color: #7f8c8d;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.profile-card,
|
||||
.password-card,
|
||||
.avatar-card,
|
||||
.account-card,
|
||||
.payment-card,
|
||||
.stats-card {
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #e6e6e6;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.profile-form,
|
||||
.password-form {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.avatar-section {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
border: 3px solid #f0f0f0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.avatar-uploader {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.qr-uploader {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.qr-upload-area {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border: 2px dashed #d9d9d9;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.qr-upload-area:hover {
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
.qr-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #8c939d;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 28px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.upload-tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.4;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.account-info,
|
||||
.payment-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.info-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-weight: 500;
|
||||
color: #606266;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #2c3e50;
|
||||
text-align: right;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-item:hover {
|
||||
background-color: #e9ecef;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #409eff;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 992px) {
|
||||
.avatar-container {
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.info-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.avatar-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1420
src/views/Users.vue
Normal file
1420
src/views/Users.vue
Normal file
File diff suppressed because it is too large
Load Diff
46
vite.config.js
Normal file
46
vite.config.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import {defineConfig} from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import {resolve} from 'path'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
base: '/',
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5176,
|
||||
host: '0.0.0.0',
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://192.168.0.11:3000',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/mid': {
|
||||
target: 'http://localhost:3005',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/mid/, '')
|
||||
},
|
||||
'/uploads': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vendor: ['vue', 'vue-router', 'pinia'],
|
||||
elementPlus: ['element-plus'],
|
||||
charts: ['echarts', 'vue-echarts']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user