Initial commit
This commit is contained in:
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OpenClaw Skills - 数字员工交易平台</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1896
frontend/package-lock.json
generated
Normal file
1896
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
frontend/package.json
Normal file
23
frontend/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "openclaw-skills-platform",
|
||||
"version": "1.0.0",
|
||||
"description": "OpenClaw Skills 数字员工交易平台",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.3.0",
|
||||
"pinia": "^2.1.7",
|
||||
"element-plus": "^2.6.1",
|
||||
"@element-plus/icons-vue": "^2.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"vite": "^5.1.6",
|
||||
"sass": "^1.71.1"
|
||||
}
|
||||
}
|
||||
12
frontend/src/App.vue
Normal file
12
frontend/src/App.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
236
frontend/src/components/DownloadSuccessDialog.vue
Normal file
236
frontend/src/components/DownloadSuccessDialog.vue
Normal file
@@ -0,0 +1,236 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="获取成功"
|
||||
width="480px"
|
||||
:close-on-click-modal="false"
|
||||
class="download-success-dialog"
|
||||
>
|
||||
<div class="success-content">
|
||||
<div class="success-icon">
|
||||
<el-icon :size="64" color="#67c23a"><CircleCheckFilled /></el-icon>
|
||||
</div>
|
||||
<h3 class="success-title">恭喜您成功获取 {{ skillName }}!</h3>
|
||||
<p class="success-desc">Skill已添加到您的账户,可在"我的Skill"中查看使用</p>
|
||||
|
||||
<div class="group-invite">
|
||||
<div class="invite-header">
|
||||
<el-icon :size="20" color="#e6a23c"><Present /></el-icon>
|
||||
<span class="invite-title">加入技术交流群,领取专属福利</span>
|
||||
</div>
|
||||
<div class="invite-benefits">
|
||||
<div class="benefit-item">
|
||||
<el-icon color="#67c23a"><Check /></el-icon>
|
||||
<span>入群即送 <strong>50积分</strong>,可兑换更多Skill</span>
|
||||
</div>
|
||||
<div class="benefit-item">
|
||||
<el-icon color="#67c23a"><Check /></el-icon>
|
||||
<span>不定时技术分享、大牛直播</span>
|
||||
</div>
|
||||
<div class="benefit-item">
|
||||
<el-icon color="#67c23a"><Check /></el-icon>
|
||||
<span>专属技术答疑、问题快速响应</span>
|
||||
</div>
|
||||
<div class="benefit-item">
|
||||
<el-icon color="#67c23a"><Check /></el-icon>
|
||||
<span>第一时间获取新Skill上线通知</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="qrcode-section">
|
||||
<div class="qrcode-wrapper">
|
||||
<img src="https://picsum.photos/200/200?random=group" alt="技术交流群二维码" class="qrcode-img" />
|
||||
<p class="qrcode-tip">扫码加入技术交流群</p>
|
||||
</div>
|
||||
<div class="join-steps">
|
||||
<p class="step-title">入群步骤:</p>
|
||||
<ol class="step-list">
|
||||
<li>扫描左侧二维码加入群聊</li>
|
||||
<li>联系群管理员验证身份</li>
|
||||
<li>验证成功后积分自动到账</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="handleClose">稍后再说</el-button>
|
||||
<el-button type="primary" @click="handleJoined">我已入群</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
skillName: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'joined'])
|
||||
|
||||
const userStore = useUserStore()
|
||||
const visible = ref(false)
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
visible.value = val
|
||||
})
|
||||
|
||||
watch(visible, (val) => {
|
||||
emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
const handleJoined = () => {
|
||||
if (userStore.user?.joinedGroup) {
|
||||
ElMessage.info('您已加入过社群')
|
||||
visible.value = false
|
||||
return
|
||||
}
|
||||
const result = userStore.joinGroup()
|
||||
if (result.success) {
|
||||
ElMessage.success(result.message)
|
||||
emit('joined')
|
||||
}
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.download-success-dialog {
|
||||
:deep(.el-dialog__header) {
|
||||
text-align: center;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.el-dialog__body) {
|
||||
padding-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.success-content {
|
||||
text-align: center;
|
||||
|
||||
.success-icon {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.success-title {
|
||||
font-size: 20px;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.success-desc {
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.group-invite {
|
||||
background: linear-gradient(135deg, #fdf6ec 0%, #fef9f0 100%);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
text-align: left;
|
||||
|
||||
.invite-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.invite-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #e6a23c;
|
||||
}
|
||||
}
|
||||
|
||||
.invite-benefits {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.benefit-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
|
||||
strong {
|
||||
color: #e6a23c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.qrcode-section {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
|
||||
.qrcode-wrapper {
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
.qrcode-img {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.qrcode-tip {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
.join-steps {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
.step-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.step-list {
|
||||
padding-left: 20px;
|
||||
|
||||
li {
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
line-height: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
165
frontend/src/components/SkillCard.vue
Normal file
165
frontend/src/components/SkillCard.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<div class="skill-card" @click="goToDetail">
|
||||
<div class="card-cover">
|
||||
<img :src="skill.cover" :alt="skill.name" />
|
||||
<div class="card-tags">
|
||||
<el-tag v-if="skill.isNew" type="success" size="small">新品</el-tag>
|
||||
<el-tag v-if="skill.price === 0" type="warning" size="small">免费</el-tag>
|
||||
<el-tag v-if="skill.isHot" type="danger" size="small">热门</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<h3 class="card-title text-ellipsis">{{ skill.name }}</h3>
|
||||
<p class="card-desc text-ellipsis-2">{{ skill.description }}</p>
|
||||
<div class="card-meta">
|
||||
<div class="meta-left">
|
||||
<span class="rating">
|
||||
<el-icon><StarFilled /></el-icon>
|
||||
{{ skill.rating }}
|
||||
</span>
|
||||
<span class="downloads">{{ formatNumber(skill.downloadCount) }}次下载</span>
|
||||
</div>
|
||||
<div class="meta-right">
|
||||
<span v-if="skill.price === 0" class="price free">免费</span>
|
||||
<span v-else class="price">
|
||||
<span class="current">¥{{ skill.price }}</span>
|
||||
<span v-if="skill.originalPrice > skill.price" class="original">¥{{ skill.originalPrice }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const props = defineProps({
|
||||
skill: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const goToDetail = () => {
|
||||
router.push(`/skill/${props.skill.id}`)
|
||||
}
|
||||
|
||||
const formatNumber = (num) => {
|
||||
if (num >= 10000) {
|
||||
return (num / 10000).toFixed(1) + '万'
|
||||
}
|
||||
return num
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.skill-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.card-cover img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.card-cover {
|
||||
position: relative;
|
||||
height: 180px;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.card-tags {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 16px;
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
line-height: 1.5;
|
||||
height: 39px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.meta-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
|
||||
.rating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
color: #f7ba2a;
|
||||
|
||||
.el-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.meta-right {
|
||||
.price {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
|
||||
&.free {
|
||||
color: #67c23a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.current {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.original {
|
||||
font-size: 12px;
|
||||
color: #c0c4cc;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
800
frontend/src/data/mockData.js
Normal file
800
frontend/src/data/mockData.js
Normal file
@@ -0,0 +1,800 @@
|
||||
const STORAGE_KEYS = {
|
||||
USERS: 'openclaw_users',
|
||||
SKILLS: 'openclaw_skills',
|
||||
ORDERS: 'openclaw_orders',
|
||||
POINT_RECORDS: 'openclaw_point_records',
|
||||
CATEGORIES: 'openclaw_categories',
|
||||
COMMENTS: 'openclaw_comments',
|
||||
NOTIFICATIONS: 'openclaw_notifications',
|
||||
CURRENT_USER: 'openclaw_current_user',
|
||||
ADMIN_USERS: 'openclaw_admin_users',
|
||||
SYSTEM_CONFIG: 'openclaw_system_config'
|
||||
}
|
||||
|
||||
const defaultCategories = [
|
||||
{ id: 1, name: '办公自动化', icon: 'Document', description: '提升办公效率的自动化工具', sort: 1 },
|
||||
{ id: 2, name: '数据处理', icon: 'DataAnalysis', description: '数据分析与处理工具', sort: 2 },
|
||||
{ id: 3, name: '客服助手', icon: 'Service', description: '智能客服与对话工具', sort: 3 },
|
||||
{ id: 4, name: '内容创作', icon: 'Edit', description: '文案写作与内容生成', sort: 4 },
|
||||
{ id: 5, name: '营销推广', icon: 'TrendCharts', description: '营销活动与推广工具', sort: 5 },
|
||||
{ id: 6, name: '其他工具', icon: 'Tools', description: '其他实用工具', sort: 6 }
|
||||
]
|
||||
|
||||
const defaultSkills = [
|
||||
{
|
||||
id: 1,
|
||||
name: '智能文档助手',
|
||||
cover: 'https://picsum.photos/400/300?random=1',
|
||||
description: '自动生成各类商务文档,支持Word、PDF等多种格式输出,智能排版与格式优化。',
|
||||
categoryId: 1,
|
||||
author: 'OpenClaw官方',
|
||||
authorId: 1,
|
||||
version: '2.1.0',
|
||||
price: 0,
|
||||
pointPrice: 0,
|
||||
originalPrice: 0,
|
||||
downloadCount: 12580,
|
||||
rating: 4.8,
|
||||
ratingCount: 356,
|
||||
status: 'active',
|
||||
isFeatured: true,
|
||||
isHot: true,
|
||||
isNew: false,
|
||||
createdAt: '2024-01-15 10:00:00',
|
||||
updatedAt: '2024-03-10 15:30:00',
|
||||
tags: ['文档', '自动化', '办公'],
|
||||
features: ['自动生成文档', '多格式支持', '智能排版', '模板库'],
|
||||
requirements: { system: 'Windows/Mac', version: 'v1.0+' },
|
||||
detailImages: [
|
||||
'https://picsum.photos/800/600?random=11',
|
||||
'https://picsum.photos/800/600?random=12'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '数据分析大师',
|
||||
cover: 'https://picsum.photos/400/300?random=2',
|
||||
description: '强大的数据分析工具,支持Excel、CSV等多种数据源,自动生成可视化图表和报告。',
|
||||
categoryId: 2,
|
||||
author: '数据科技',
|
||||
authorId: 2,
|
||||
version: '3.0.1',
|
||||
price: 99,
|
||||
pointPrice: 990,
|
||||
originalPrice: 199,
|
||||
downloadCount: 8920,
|
||||
rating: 4.9,
|
||||
ratingCount: 234,
|
||||
status: 'active',
|
||||
isFeatured: true,
|
||||
isHot: true,
|
||||
isNew: true,
|
||||
createdAt: '2024-02-20 09:00:00',
|
||||
updatedAt: '2024-03-12 10:00:00',
|
||||
tags: ['数据分析', '可视化', '报表'],
|
||||
features: ['多数据源支持', '自动图表生成', '智能分析报告', '数据清洗'],
|
||||
requirements: { system: 'Windows/Mac/Linux', version: 'v2.0+' },
|
||||
detailImages: [
|
||||
'https://picsum.photos/800/600?random=21',
|
||||
'https://picsum.photos/800/600?random=22'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '智能客服机器人',
|
||||
cover: 'https://picsum.photos/400/300?random=3',
|
||||
description: '基于AI的智能客服系统,支持多平台接入,自动回复常见问题,提升客服效率。',
|
||||
categoryId: 3,
|
||||
author: 'AI科技',
|
||||
authorId: 3,
|
||||
version: '1.5.0',
|
||||
price: 199,
|
||||
pointPrice: 1990,
|
||||
originalPrice: 299,
|
||||
downloadCount: 5670,
|
||||
rating: 4.7,
|
||||
ratingCount: 189,
|
||||
status: 'active',
|
||||
isFeatured: true,
|
||||
isHot: false,
|
||||
isNew: true,
|
||||
createdAt: '2024-03-01 14:00:00',
|
||||
updatedAt: '2024-03-14 09:00:00',
|
||||
tags: ['客服', 'AI', '自动化'],
|
||||
features: ['智能对话', '多平台接入', '知识库管理', '数据分析'],
|
||||
requirements: { system: 'Web', version: '任意浏览器' },
|
||||
detailImages: [
|
||||
'https://picsum.photos/800/600?random=31',
|
||||
'https://picsum.photos/800/600?random=32'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: '内容创作精灵',
|
||||
cover: 'https://picsum.photos/400/300?random=4',
|
||||
description: 'AI驱动的内容创作工具,支持文章、广告文案、社交媒体内容等多种类型创作。',
|
||||
categoryId: 4,
|
||||
author: '创意工坊',
|
||||
authorId: 4,
|
||||
version: '2.0.0',
|
||||
price: 149,
|
||||
pointPrice: 1490,
|
||||
originalPrice: 249,
|
||||
downloadCount: 7230,
|
||||
rating: 4.6,
|
||||
ratingCount: 267,
|
||||
status: 'active',
|
||||
isFeatured: false,
|
||||
isHot: true,
|
||||
isNew: false,
|
||||
createdAt: '2024-01-25 11:00:00',
|
||||
updatedAt: '2024-03-08 16:00:00',
|
||||
tags: ['内容创作', 'AI写作', '文案'],
|
||||
features: ['AI写作', '多风格模板', 'SEO优化', '批量生成'],
|
||||
requirements: { system: 'Web', version: '任意浏览器' },
|
||||
detailImages: [
|
||||
'https://picsum.photos/800/600?random=41',
|
||||
'https://picsum.photos/800/600?random=42'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: '营销活动策划师',
|
||||
cover: 'https://picsum.photos/400/300?random=5',
|
||||
description: '一站式营销活动策划工具,提供活动模板、数据分析、效果追踪等完整解决方案。',
|
||||
categoryId: 5,
|
||||
author: '营销专家',
|
||||
authorId: 5,
|
||||
version: '1.8.0',
|
||||
price: 299,
|
||||
pointPrice: 2990,
|
||||
originalPrice: 499,
|
||||
downloadCount: 4560,
|
||||
rating: 4.5,
|
||||
ratingCount: 145,
|
||||
status: 'active',
|
||||
isFeatured: false,
|
||||
isHot: false,
|
||||
isNew: false,
|
||||
createdAt: '2024-02-10 10:00:00',
|
||||
updatedAt: '2024-03-05 14:00:00',
|
||||
tags: ['营销', '活动策划', '数据分析'],
|
||||
features: ['活动模板', '效果追踪', '数据分析', '自动化执行'],
|
||||
requirements: { system: 'Windows/Mac', version: 'v1.5+' },
|
||||
detailImages: [
|
||||
'https://picsum.photos/800/600?random=51',
|
||||
'https://picsum.photos/800/600?random=52'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: '邮件自动回复助手',
|
||||
cover: 'https://picsum.photos/400/300?random=6',
|
||||
description: '智能邮件处理工具,自动分类、回复邮件,支持自定义规则和模板。',
|
||||
categoryId: 1,
|
||||
author: '效率工具',
|
||||
authorId: 6,
|
||||
version: '1.2.0',
|
||||
price: 0,
|
||||
pointPrice: 0,
|
||||
originalPrice: 0,
|
||||
downloadCount: 9870,
|
||||
rating: 4.4,
|
||||
ratingCount: 312,
|
||||
status: 'active',
|
||||
isFeatured: false,
|
||||
isHot: true,
|
||||
isNew: false,
|
||||
createdAt: '2024-01-08 09:00:00',
|
||||
updatedAt: '2024-02-28 11:00:00',
|
||||
tags: ['邮件', '自动化', '办公'],
|
||||
features: ['智能分类', '自动回复', '模板管理', '定时发送'],
|
||||
requirements: { system: 'Windows/Mac', version: 'v1.0+' },
|
||||
detailImages: [
|
||||
'https://picsum.photos/800/600?random=61',
|
||||
'https://picsum.photos/800/600?random=62'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: 'Excel数据处理专家',
|
||||
cover: 'https://picsum.photos/400/300?random=7',
|
||||
description: '专业的Excel数据处理工具,支持批量操作、数据清洗、格式转换等功能。',
|
||||
categoryId: 2,
|
||||
author: '数据科技',
|
||||
authorId: 2,
|
||||
version: '2.5.0',
|
||||
price: 79,
|
||||
pointPrice: 790,
|
||||
originalPrice: 149,
|
||||
downloadCount: 11230,
|
||||
rating: 4.7,
|
||||
ratingCount: 456,
|
||||
status: 'active',
|
||||
isFeatured: true,
|
||||
isHot: true,
|
||||
isNew: false,
|
||||
createdAt: '2024-01-20 15:00:00',
|
||||
updatedAt: '2024-03-11 10:00:00',
|
||||
tags: ['Excel', '数据处理', '自动化'],
|
||||
features: ['批量处理', '数据清洗', '格式转换', '公式助手'],
|
||||
requirements: { system: 'Windows', version: 'Office 2016+' },
|
||||
detailImages: [
|
||||
'https://picsum.photos/800/600?random=71',
|
||||
'https://picsum.photos/800/600?random=72'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: '社交媒体管理器',
|
||||
cover: 'https://picsum.photos/400/300?random=8',
|
||||
description: '多平台社交媒体管理工具,支持内容发布、数据分析、粉丝互动等功能。',
|
||||
categoryId: 5,
|
||||
author: '营销专家',
|
||||
authorId: 5,
|
||||
version: '1.6.0',
|
||||
price: 0,
|
||||
pointPrice: 0,
|
||||
originalPrice: 0,
|
||||
downloadCount: 6780,
|
||||
rating: 4.3,
|
||||
ratingCount: 198,
|
||||
status: 'active',
|
||||
isFeatured: false,
|
||||
isHot: false,
|
||||
isNew: true,
|
||||
createdAt: '2024-03-05 10:00:00',
|
||||
updatedAt: '2024-03-13 15:00:00',
|
||||
tags: ['社交媒体', '营销', '自动化'],
|
||||
features: ['多平台管理', '定时发布', '数据分析', '互动管理'],
|
||||
requirements: { system: 'Web', version: '任意浏览器' },
|
||||
detailImages: [
|
||||
'https://picsum.photos/800/600?random=81',
|
||||
'https://picsum.photos/800/600?random=82'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
name: 'PDF智能处理工具',
|
||||
cover: 'https://picsum.photos/400/300?random=9',
|
||||
description: '全能PDF处理工具,支持转换、编辑、合并、拆分、压缩等多种操作。',
|
||||
categoryId: 1,
|
||||
author: '文档工具',
|
||||
authorId: 7,
|
||||
version: '3.2.0',
|
||||
price: 49,
|
||||
pointPrice: 490,
|
||||
originalPrice: 99,
|
||||
downloadCount: 15670,
|
||||
rating: 4.8,
|
||||
ratingCount: 567,
|
||||
status: 'active',
|
||||
isFeatured: true,
|
||||
isHot: true,
|
||||
isNew: false,
|
||||
createdAt: '2024-01-12 08:00:00',
|
||||
updatedAt: '2024-03-09 14:00:00',
|
||||
tags: ['PDF', '文档', '转换'],
|
||||
features: ['格式转换', '编辑修改', '合并拆分', '压缩优化'],
|
||||
requirements: { system: 'Windows/Mac', version: 'v2.0+' },
|
||||
detailImages: [
|
||||
'https://picsum.photos/800/600?random=91',
|
||||
'https://picsum.photos/800/600?random=92'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
name: 'AI翻译助手',
|
||||
cover: 'https://picsum.photos/400/300?random=10',
|
||||
description: '基于AI的智能翻译工具,支持100+语言互译,专业术语精准翻译。',
|
||||
categoryId: 6,
|
||||
author: '语言科技',
|
||||
authorId: 8,
|
||||
version: '2.0.0',
|
||||
price: 0,
|
||||
pointPrice: 0,
|
||||
originalPrice: 0,
|
||||
downloadCount: 8950,
|
||||
rating: 4.6,
|
||||
ratingCount: 389,
|
||||
status: 'active',
|
||||
isFeatured: false,
|
||||
isHot: true,
|
||||
isNew: false,
|
||||
createdAt: '2024-02-05 11:00:00',
|
||||
updatedAt: '2024-03-07 16:00:00',
|
||||
tags: ['翻译', 'AI', '多语言'],
|
||||
features: ['多语言支持', '专业术语', '批量翻译', '实时翻译'],
|
||||
requirements: { system: 'Web/Windows/Mac', version: '任意' },
|
||||
detailImages: [
|
||||
'https://picsum.photos/800/600?random=101',
|
||||
'https://picsum.photos/800/600?random=102'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
name: '项目进度追踪器',
|
||||
cover: 'https://picsum.photos/400/300?random=11',
|
||||
description: '可视化项目进度管理工具,支持甘特图、看板、任务分配等功能。',
|
||||
categoryId: 1,
|
||||
author: '项目管理',
|
||||
authorId: 9,
|
||||
version: '1.4.0',
|
||||
price: 129,
|
||||
pointPrice: 1290,
|
||||
originalPrice: 199,
|
||||
downloadCount: 5430,
|
||||
rating: 4.5,
|
||||
ratingCount: 167,
|
||||
status: 'active',
|
||||
isFeatured: false,
|
||||
isHot: false,
|
||||
isNew: false,
|
||||
createdAt: '2024-02-15 14:00:00',
|
||||
updatedAt: '2024-03-06 10:00:00',
|
||||
tags: ['项目管理', '进度追踪', '团队协作'],
|
||||
features: ['甘特图', '看板视图', '任务分配', '进度报告'],
|
||||
requirements: { system: 'Web', version: '任意浏览器' },
|
||||
detailImages: [
|
||||
'https://picsum.photos/800/600?random=111',
|
||||
'https://picsum.photos/800/600?random=112'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
name: '图片批量处理工具',
|
||||
cover: 'https://picsum.photos/400/300?random=12',
|
||||
description: '专业的图片批量处理工具,支持格式转换、尺寸调整、水印添加等功能。',
|
||||
categoryId: 6,
|
||||
author: '图像工具',
|
||||
authorId: 10,
|
||||
version: '2.1.0',
|
||||
price: 59,
|
||||
pointPrice: 590,
|
||||
originalPrice: 99,
|
||||
downloadCount: 7890,
|
||||
rating: 4.4,
|
||||
ratingCount: 234,
|
||||
status: 'active',
|
||||
isFeatured: false,
|
||||
isHot: false,
|
||||
isNew: true,
|
||||
createdAt: '2024-03-08 09:00:00',
|
||||
updatedAt: '2024-03-14 11:00:00',
|
||||
tags: ['图片处理', '批量操作', '格式转换'],
|
||||
features: ['批量处理', '格式转换', '尺寸调整', '水印添加'],
|
||||
requirements: { system: 'Windows/Mac', version: 'v1.5+' },
|
||||
detailImages: [
|
||||
'https://picsum.photos/800/600?random=121',
|
||||
'https://picsum.photos/800/600?random=122'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const defaultUsers = [
|
||||
{
|
||||
id: 1,
|
||||
phone: '13800138000',
|
||||
password: '123456',
|
||||
nickname: '演示用户',
|
||||
avatar: 'https://picsum.photos/200/200?random=user1',
|
||||
email: 'demo@openclaw.com',
|
||||
points: 2500,
|
||||
totalPoints: 5000,
|
||||
level: 2,
|
||||
levelName: '黄金会员',
|
||||
growthValue: 2500,
|
||||
inviteCode: 'DEMO001',
|
||||
invitedBy: null,
|
||||
inviteCount: 5,
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01 10:00:00',
|
||||
lastLoginAt: '2024-03-15 09:00:00',
|
||||
isVip: true,
|
||||
vipExpireAt: '2025-01-01 00:00:00',
|
||||
settings: {
|
||||
notification: true,
|
||||
emailNotify: true,
|
||||
smsNotify: false
|
||||
},
|
||||
signedToday: false,
|
||||
continuousSignDays: 7,
|
||||
totalSignDays: 45,
|
||||
joinedGroup: true
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
phone: '13900139000',
|
||||
password: '123456',
|
||||
nickname: '测试用户',
|
||||
avatar: 'https://picsum.photos/200/200?random=user2',
|
||||
email: 'test@openclaw.com',
|
||||
points: 800,
|
||||
totalPoints: 1200,
|
||||
level: 1,
|
||||
levelName: '白银会员',
|
||||
growthValue: 800,
|
||||
inviteCode: 'TEST001',
|
||||
invitedBy: 'DEMO001',
|
||||
inviteCount: 2,
|
||||
status: 'active',
|
||||
createdAt: '2024-02-15 14:00:00',
|
||||
lastLoginAt: '2024-03-14 16:00:00',
|
||||
isVip: false,
|
||||
vipExpireAt: null,
|
||||
settings: {
|
||||
notification: true,
|
||||
emailNotify: false,
|
||||
smsNotify: false
|
||||
},
|
||||
signedToday: true,
|
||||
continuousSignDays: 3,
|
||||
totalSignDays: 15,
|
||||
joinedGroup: false
|
||||
}
|
||||
]
|
||||
|
||||
const defaultAdminUsers = [
|
||||
{
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
password: 'admin123',
|
||||
nickname: '超级管理员',
|
||||
avatar: 'https://picsum.photos/200/200?random=admin',
|
||||
role: 'super_admin',
|
||||
permissions: ['all'],
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01 00:00:00',
|
||||
lastLoginAt: '2024-03-15 08:00:00'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
username: 'operator',
|
||||
password: 'operator123',
|
||||
nickname: '运营管理员',
|
||||
avatar: 'https://picsum.photos/200/200?random=operator',
|
||||
role: 'operator',
|
||||
permissions: ['user', 'skill', 'order', 'content'],
|
||||
status: 'active',
|
||||
createdAt: '2024-02-01 00:00:00',
|
||||
lastLoginAt: '2024-03-14 10:00:00'
|
||||
}
|
||||
]
|
||||
|
||||
const defaultOrders = [
|
||||
{
|
||||
id: 'ORD202403150001',
|
||||
userId: 1,
|
||||
skillId: 2,
|
||||
skillName: '数据分析大师',
|
||||
skillCover: 'https://picsum.photos/400/300?random=2',
|
||||
price: 99,
|
||||
pointPrice: 990,
|
||||
payType: 'points',
|
||||
status: 'completed',
|
||||
createdAt: '2024-03-10 14:30:00',
|
||||
paidAt: '2024-03-10 14:31:00',
|
||||
completedAt: '2024-03-10 14:31:00'
|
||||
},
|
||||
{
|
||||
id: 'ORD202403140001',
|
||||
userId: 1,
|
||||
skillId: 3,
|
||||
skillName: '智能客服机器人',
|
||||
skillCover: 'https://picsum.photos/400/300?random=3',
|
||||
price: 199,
|
||||
pointPrice: 1990,
|
||||
payType: 'mixed',
|
||||
paidPoints: 1000,
|
||||
paidAmount: 99,
|
||||
status: 'completed',
|
||||
createdAt: '2024-03-08 10:00:00',
|
||||
paidAt: '2024-03-08 10:05:00',
|
||||
completedAt: '2024-03-08 10:05:00'
|
||||
},
|
||||
{
|
||||
id: 'ORD202403130001',
|
||||
userId: 1,
|
||||
skillId: 7,
|
||||
skillName: 'Excel数据处理专家',
|
||||
skillCover: 'https://picsum.photos/400/300?random=7',
|
||||
price: 79,
|
||||
pointPrice: 790,
|
||||
payType: 'points',
|
||||
status: 'completed',
|
||||
createdAt: '2024-03-05 16:20:00',
|
||||
paidAt: '2024-03-05 16:21:00',
|
||||
completedAt: '2024-03-05 16:21:00'
|
||||
}
|
||||
]
|
||||
|
||||
const defaultPointRecords = [
|
||||
{
|
||||
id: 1,
|
||||
userId: 1,
|
||||
type: 'income',
|
||||
amount: 300,
|
||||
balance: 2800,
|
||||
source: 'register',
|
||||
description: '新用户注册奖励',
|
||||
createdAt: '2024-01-01 10:00:00'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
userId: 1,
|
||||
type: 'income',
|
||||
amount: 10,
|
||||
balance: 2810,
|
||||
source: 'signin',
|
||||
description: '每日签到奖励',
|
||||
createdAt: '2024-01-02 09:00:00'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
userId: 1,
|
||||
type: 'income',
|
||||
amount: 100,
|
||||
balance: 2910,
|
||||
source: 'invite',
|
||||
description: '邀请好友奖励(用户ID: 2)',
|
||||
createdAt: '2024-02-15 14:00:00'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
userId: 1,
|
||||
type: 'income',
|
||||
amount: 50,
|
||||
balance: 2960,
|
||||
source: 'group',
|
||||
description: '加入技术交流群奖励',
|
||||
createdAt: '2024-02-20 10:00:00'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
userId: 1,
|
||||
type: 'income',
|
||||
amount: 150,
|
||||
balance: 3110,
|
||||
source: 'recharge',
|
||||
description: '充值赠送(充值100元)',
|
||||
createdAt: '2024-02-25 15:00:00'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
userId: 1,
|
||||
type: 'expense',
|
||||
amount: 990,
|
||||
balance: 2120,
|
||||
source: 'purchase',
|
||||
description: '购买Skill:数据分析大师',
|
||||
relatedId: 'ORD202403150001',
|
||||
createdAt: '2024-03-10 14:30:00'
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
userId: 1,
|
||||
type: 'expense',
|
||||
amount: 1000,
|
||||
balance: 1120,
|
||||
source: 'purchase',
|
||||
description: '购买Skill:智能客服机器人(积分部分)',
|
||||
relatedId: 'ORD202403140001',
|
||||
createdAt: '2024-03-08 10:05:00'
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
userId: 1,
|
||||
type: 'income',
|
||||
amount: 800,
|
||||
balance: 1920,
|
||||
source: 'recharge',
|
||||
description: '充值赠送(充值500元)',
|
||||
createdAt: '2024-03-12 11:00:00'
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
userId: 1,
|
||||
type: 'income',
|
||||
amount: 10,
|
||||
balance: 1930,
|
||||
source: 'signin',
|
||||
description: '每日签到奖励(连续签到第7天)',
|
||||
createdAt: '2024-03-14 09:00:00'
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
userId: 1,
|
||||
type: 'income',
|
||||
amount: 20,
|
||||
balance: 1950,
|
||||
source: 'signin',
|
||||
description: '每日签到奖励(连续签到第8天)',
|
||||
createdAt: '2024-03-15 08:00:00'
|
||||
}
|
||||
]
|
||||
|
||||
const defaultComments = [
|
||||
{
|
||||
id: 1,
|
||||
skillId: 1,
|
||||
userId: 1,
|
||||
userName: '演示用户',
|
||||
userAvatar: 'https://picsum.photos/200/200?random=user1',
|
||||
rating: 5,
|
||||
content: '非常好用的文档工具,大大提高了我的工作效率!强烈推荐!',
|
||||
images: [],
|
||||
likes: 23,
|
||||
isLiked: false,
|
||||
status: 'active',
|
||||
createdAt: '2024-03-10 15:00:00'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
skillId: 1,
|
||||
userId: 2,
|
||||
userName: '测试用户',
|
||||
userAvatar: 'https://picsum.photos/200/200?random=user2',
|
||||
rating: 4,
|
||||
content: '功能很强大,就是有些高级功能需要学习一下才能用好。',
|
||||
images: [],
|
||||
likes: 12,
|
||||
isLiked: false,
|
||||
status: 'active',
|
||||
createdAt: '2024-03-08 11:00:00'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
skillId: 2,
|
||||
userId: 1,
|
||||
userName: '演示用户',
|
||||
userAvatar: 'https://picsum.photos/200/200?random=user1',
|
||||
rating: 5,
|
||||
content: '数据分析功能太强大了,生成的图表非常专业,省了很多时间!',
|
||||
images: ['https://picsum.photos/400/300?random=comment1'],
|
||||
likes: 45,
|
||||
isLiked: true,
|
||||
status: 'active',
|
||||
createdAt: '2024-03-12 16:00:00'
|
||||
}
|
||||
]
|
||||
|
||||
const defaultNotifications = [
|
||||
{
|
||||
id: 1,
|
||||
userId: 1,
|
||||
type: 'system',
|
||||
title: '欢迎使用OpenClaw Skills',
|
||||
content: '感谢您注册成为OpenClaw Skills用户,开始探索数字员工的世界吧!',
|
||||
isRead: true,
|
||||
createdAt: '2024-01-01 10:00:00'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
userId: 1,
|
||||
type: 'point',
|
||||
title: '积分到账通知',
|
||||
content: '您获得300积分新用户注册奖励,当前积分余额2800。',
|
||||
isRead: true,
|
||||
createdAt: '2024-01-01 10:00:00'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
userId: 1,
|
||||
type: 'order',
|
||||
title: '订单支付成功',
|
||||
content: '您的订单ORD202403150001已支付成功,Skill已添加到您的账户。',
|
||||
isRead: false,
|
||||
createdAt: '2024-03-10 14:31:00'
|
||||
}
|
||||
]
|
||||
|
||||
const defaultSystemConfig = {
|
||||
siteName: 'OpenClaw Skills',
|
||||
siteDescription: '数字员工交易平台',
|
||||
pointRules: {
|
||||
register: 300,
|
||||
dailySign: 10,
|
||||
continuousSignBonus: [10, 15, 20, 25, 30, 35, 50],
|
||||
invite: 100,
|
||||
inviteFirstPurchase: 50,
|
||||
joinGroup: 50,
|
||||
completeProfile: 30,
|
||||
review: 10,
|
||||
reviewWithImage: 20
|
||||
},
|
||||
rechargeTiers: [
|
||||
{ amount: 10, bonus: 10 },
|
||||
{ amount: 50, bonus: 60 },
|
||||
{ amount: 100, bonus: 150 },
|
||||
{ amount: 500, bonus: 800 },
|
||||
{ amount: 1000, bonus: 2000 }
|
||||
],
|
||||
levelRules: [
|
||||
{ level: 0, name: '普通会员', minGrowth: 0, maxGrowth: 499 },
|
||||
{ level: 1, name: '白银会员', minGrowth: 500, maxGrowth: 1999 },
|
||||
{ level: 2, name: '黄金会员', minGrowth: 2000, maxGrowth: 4999 },
|
||||
{ level: 3, name: '钻石会员', minGrowth: 5000, maxGrowth: 99999 }
|
||||
],
|
||||
vipPrice: 299,
|
||||
vipDuration: 365
|
||||
}
|
||||
|
||||
function initializeData() {
|
||||
if (!localStorage.getItem(STORAGE_KEYS.USERS)) {
|
||||
localStorage.setItem(STORAGE_KEYS.USERS, JSON.stringify(defaultUsers))
|
||||
}
|
||||
if (!localStorage.getItem(STORAGE_KEYS.SKILLS)) {
|
||||
localStorage.setItem(STORAGE_KEYS.SKILLS, JSON.stringify(defaultSkills))
|
||||
}
|
||||
if (!localStorage.getItem(STORAGE_KEYS.ORDERS)) {
|
||||
localStorage.setItem(STORAGE_KEYS.ORDERS, JSON.stringify(defaultOrders))
|
||||
}
|
||||
if (!localStorage.getItem(STORAGE_KEYS.POINT_RECORDS)) {
|
||||
localStorage.setItem(STORAGE_KEYS.POINT_RECORDS, JSON.stringify(defaultPointRecords))
|
||||
}
|
||||
if (!localStorage.getItem(STORAGE_KEYS.CATEGORIES)) {
|
||||
localStorage.setItem(STORAGE_KEYS.CATEGORIES, JSON.stringify(defaultCategories))
|
||||
}
|
||||
if (!localStorage.getItem(STORAGE_KEYS.COMMENTS)) {
|
||||
localStorage.setItem(STORAGE_KEYS.COMMENTS, JSON.stringify(defaultComments))
|
||||
}
|
||||
if (!localStorage.getItem(STORAGE_KEYS.NOTIFICATIONS)) {
|
||||
localStorage.setItem(STORAGE_KEYS.NOTIFICATIONS, JSON.stringify(defaultNotifications))
|
||||
}
|
||||
if (!localStorage.getItem(STORAGE_KEYS.ADMIN_USERS)) {
|
||||
localStorage.setItem(STORAGE_KEYS.ADMIN_USERS, JSON.stringify(defaultAdminUsers))
|
||||
}
|
||||
if (!localStorage.getItem(STORAGE_KEYS.SYSTEM_CONFIG)) {
|
||||
localStorage.setItem(STORAGE_KEYS.SYSTEM_CONFIG, JSON.stringify(defaultSystemConfig))
|
||||
}
|
||||
}
|
||||
|
||||
function getData(key) {
|
||||
const data = localStorage.getItem(key)
|
||||
return data ? JSON.parse(data) : null
|
||||
}
|
||||
|
||||
function setData(key, data) {
|
||||
localStorage.setItem(key, JSON.stringify(data))
|
||||
}
|
||||
|
||||
function generateId() {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2, 9)
|
||||
}
|
||||
|
||||
function generateOrderNo() {
|
||||
const now = new Date()
|
||||
const dateStr = now.getFullYear().toString() +
|
||||
(now.getMonth() + 1).toString().padStart(2, '0') +
|
||||
now.getDate().toString().padStart(2, '0')
|
||||
const random = Math.floor(Math.random() * 10000).toString().padStart(4, '0')
|
||||
return `ORD${dateStr}${random}`
|
||||
}
|
||||
|
||||
function generateInviteCode() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||
let code = ''
|
||||
for (let i = 0; i < 6; i++) {
|
||||
code += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
export {
|
||||
STORAGE_KEYS,
|
||||
initializeData,
|
||||
getData,
|
||||
setData,
|
||||
generateId,
|
||||
generateOrderNo,
|
||||
generateInviteCode,
|
||||
defaultCategories,
|
||||
defaultSkills,
|
||||
defaultUsers,
|
||||
defaultAdminUsers,
|
||||
defaultOrders,
|
||||
defaultPointRecords,
|
||||
defaultComments,
|
||||
defaultNotifications,
|
||||
defaultSystemConfig
|
||||
}
|
||||
243
frontend/src/layouts/AdminLayout.vue
Normal file
243
frontend/src/layouts/AdminLayout.vue
Normal file
@@ -0,0 +1,243 @@
|
||||
<template>
|
||||
<div class="admin-layout">
|
||||
<aside class="admin-sidebar" :class="{ collapsed: isCollapsed }">
|
||||
<div class="sidebar-header">
|
||||
<el-icon :size="24"><Setting /></el-icon>
|
||||
<span v-show="!isCollapsed" class="sidebar-title">管理后台</span>
|
||||
</div>
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
:collapse="isCollapsed"
|
||||
background-color="#001529"
|
||||
text-color="#rgba(255,255,255,0.65)"
|
||||
active-text-color="#fff"
|
||||
router
|
||||
>
|
||||
<el-menu-item index="/admin">
|
||||
<el-icon><DataAnalysis /></el-icon>
|
||||
<template #title>控制台</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/users">
|
||||
<el-icon><User /></el-icon>
|
||||
<template #title>用户管理</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/skills">
|
||||
<el-icon><Grid /></el-icon>
|
||||
<template #title>Skill管理</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/orders">
|
||||
<el-icon><Document /></el-icon>
|
||||
<template #title>订单管理</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/comments">
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
<template #title>评论管理</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/points">
|
||||
<el-icon><Coin /></el-icon>
|
||||
<template #title>积分管理</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/statistics">
|
||||
<el-icon><TrendCharts /></el-icon>
|
||||
<template #title>数据统计</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/settings">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<template #title>系统设置</template>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
<div class="sidebar-footer">
|
||||
<el-button text @click="isCollapsed = !isCollapsed">
|
||||
<el-icon :size="18">
|
||||
<Fold v-if="!isCollapsed" />
|
||||
<Expand v-else />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="admin-main">
|
||||
<header class="admin-header">
|
||||
<div class="header-left">
|
||||
<el-breadcrumb separator="/">
|
||||
<el-breadcrumb-item :to="{ path: '/admin' }">首页</el-breadcrumb-item>
|
||||
<el-breadcrumb-item v-if="$route.meta.title">{{ $route.meta.title }}</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button text @click="$router.push('/')">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
<span>前台首页</span>
|
||||
</el-button>
|
||||
<el-dropdown trigger="click" @command="handleCommand">
|
||||
<div class="admin-info">
|
||||
<el-avatar :size="32" :src="admin?.avatar">
|
||||
{{ admin?.nickname?.charAt(0) }}
|
||||
</el-avatar>
|
||||
<span class="admin-name">{{ admin?.nickname }}</span>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="logout">
|
||||
<el-icon><SwitchButton /></el-icon>退出登录
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</header>
|
||||
<main class="admin-content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const isCollapsed = ref(false)
|
||||
const admin = ref(null)
|
||||
|
||||
const activeMenu = computed(() => route.path)
|
||||
|
||||
onMounted(() => {
|
||||
const adminData = sessionStorage.getItem('admin_user')
|
||||
if (adminData) {
|
||||
admin.value = JSON.parse(adminData)
|
||||
}
|
||||
})
|
||||
|
||||
const handleCommand = (command) => {
|
||||
if (command === 'logout') {
|
||||
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
sessionStorage.removeItem('admin_user')
|
||||
router.push('/admin/login')
|
||||
ElMessage.success('已退出登录')
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.admin-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
width: 220px;
|
||||
background: #001529;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: width 0.3s;
|
||||
|
||||
&.collapsed {
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
.sidebar-title {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-menu) {
|
||||
border-right: none;
|
||||
flex: 1;
|
||||
|
||||
.el-menu-item {
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background: #409eff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
.el-button {
|
||||
width: 100%;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
height: 64px;
|
||||
background: #fff;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.admin-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.admin-name {
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
361
frontend/src/layouts/MainLayout.vue
Normal file
361
frontend/src/layouts/MainLayout.vue
Normal file
@@ -0,0 +1,361 @@
|
||||
<template>
|
||||
<div class="main-layout">
|
||||
<header class="main-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<router-link to="/" class="logo">
|
||||
<el-icon :size="28"><Lightning /></el-icon>
|
||||
<span class="logo-text">OpenClaw Skills</span>
|
||||
</router-link>
|
||||
<nav class="main-nav">
|
||||
<router-link to="/" class="nav-item" :class="{ active: $route.path === '/' }">首页</router-link>
|
||||
<router-link to="/skills" class="nav-item" :class="{ active: $route.path.startsWith('/skills') || $route.path.startsWith('/skill') }">Skill商城</router-link>
|
||||
<router-link to="/customize" class="nav-item" :class="{ active: $route.path === '/customize' }">Skill定制</router-link>
|
||||
<router-link to="/join-us" class="nav-item" :class="{ active: $route.path === '/join-us' }">加入我们</router-link>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="header-center">
|
||||
<div class="search-box">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索Skill..."
|
||||
@keyup.enter="handleSearch"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<template v-if="userStore.isLoggedIn">
|
||||
<el-badge :value="userStore.unreadCount" :hidden="userStore.unreadCount === 0" class="notification-badge">
|
||||
<el-button text @click="$router.push('/user/notifications')">
|
||||
<el-icon :size="20"><Bell /></el-icon>
|
||||
</el-button>
|
||||
</el-badge>
|
||||
<el-dropdown trigger="click" @command="handleCommand">
|
||||
<div class="user-info">
|
||||
<el-avatar :size="36" :src="userStore.user?.avatar">
|
||||
{{ userStore.user?.nickname?.charAt(0) }}
|
||||
</el-avatar>
|
||||
<div class="user-detail">
|
||||
<span class="user-name">{{ userStore.user?.nickname }}</span>
|
||||
<span class="user-level">{{ userStore.user?.levelName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="profile">
|
||||
<el-icon><User /></el-icon>个人中心
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="orders">
|
||||
<el-icon><Document /></el-icon>我的订单
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="skills">
|
||||
<el-icon><Grid /></el-icon>我的Skill
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="points">
|
||||
<el-icon><Coin /></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>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button type="primary" @click="$router.push('/login')">登录</el-button>
|
||||
<el-button @click="$router.push('/register')">注册</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="main-content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</main>
|
||||
<footer class="main-footer">
|
||||
<div class="footer-content">
|
||||
<div class="footer-links">
|
||||
<a href="javascript:;">关于我们</a>
|
||||
<a href="javascript:;">帮助中心</a>
|
||||
<a href="javascript:;">用户协议</a>
|
||||
<a href="javascript:;">隐私政策</a>
|
||||
<a href="javascript:;">联系我们</a>
|
||||
</div>
|
||||
<div class="footer-copyright">
|
||||
<p>© 2024 OpenClaw Skills. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<el-backtop :bottom="100">
|
||||
<el-icon :size="20"><CaretTop /></el-icon>
|
||||
</el-backtop>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const searchKeyword = ref('')
|
||||
|
||||
const handleSearch = () => {
|
||||
if (searchKeyword.value.trim()) {
|
||||
router.push({ path: '/search', query: { keyword: searchKeyword.value } })
|
||||
}
|
||||
}
|
||||
|
||||
const handleCommand = (command) => {
|
||||
switch (command) {
|
||||
case 'profile':
|
||||
router.push('/user')
|
||||
break
|
||||
case 'orders':
|
||||
router.push('/user/orders')
|
||||
break
|
||||
case 'skills':
|
||||
router.push('/user/skills')
|
||||
break
|
||||
case 'points':
|
||||
router.push('/user/points')
|
||||
break
|
||||
case 'logout':
|
||||
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
userStore.logout()
|
||||
ElMessage.success('已退出登录')
|
||||
router.push('/')
|
||||
}).catch(() => {})
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.main-layout {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-header {
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
|
||||
.header-content {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #409eff;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
|
||||
.logo-text {
|
||||
background: linear-gradient(135deg, #409eff, #67c23a);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.main-nav {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
|
||||
.nav-item {
|
||||
color: #606266;
|
||||
font-size: 15px;
|
||||
padding: 8px 0;
|
||||
position: relative;
|
||||
transition: color 0.3s;
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
&.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: #409eff;
|
||||
border-radius: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-center {
|
||||
flex: 1;
|
||||
max-width: 400px;
|
||||
margin: 0 40px;
|
||||
|
||||
.search-box {
|
||||
:deep(.el-input__wrapper) {
|
||||
border-radius: 20px;
|
||||
background: #f5f7fa;
|
||||
box-shadow: none;
|
||||
|
||||
&:focus-within {
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 1px #409eff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.notification-badge {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 20px;
|
||||
transition: background 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.user-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.user-level {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.main-footer {
|
||||
background: #fff;
|
||||
border-top: 1px solid #ebeef5;
|
||||
padding: 30px 0;
|
||||
|
||||
.footer-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 30px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
a {
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
transition: color 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: #409eff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer-copyright {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-header {
|
||||
.header-content {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
.logo-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.main-nav {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-center {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
.user-detail {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.main-footer {
|
||||
.footer-links {
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
32
frontend/src/main.js
Normal file
32
frontend/src/main.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||
import 'element-plus/dist/index.css'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { initializeData } from './data/mockData'
|
||||
import { useUserStore } from './stores'
|
||||
import './styles/index.scss'
|
||||
|
||||
initializeData()
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(ElementPlus, {
|
||||
locale: zhCn
|
||||
})
|
||||
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
const userStore = useUserStore()
|
||||
userStore.initUser()
|
||||
|
||||
app.mount('#app')
|
||||
233
frontend/src/router/index.js
Normal file
233
frontend/src/router/index.js
Normal file
@@ -0,0 +1,233 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { getData, STORAGE_KEYS } from '@/data/mockData'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layouts/MainLayout.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'Home',
|
||||
component: () => import('@/views/home/index.vue'),
|
||||
meta: { title: '首页' }
|
||||
},
|
||||
{
|
||||
path: 'skills',
|
||||
name: 'SkillList',
|
||||
component: () => import('@/views/skill/list.vue'),
|
||||
meta: { title: 'Skill商城' }
|
||||
},
|
||||
{
|
||||
path: 'skill/:id',
|
||||
name: 'SkillDetail',
|
||||
component: () => import('@/views/skill/detail.vue'),
|
||||
meta: { title: 'Skill详情' }
|
||||
},
|
||||
{
|
||||
path: 'search',
|
||||
name: 'Search',
|
||||
component: () => import('@/views/skill/search.vue'),
|
||||
meta: { title: '搜索' }
|
||||
},
|
||||
{
|
||||
path: 'login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/user/login.vue'),
|
||||
meta: { title: '登录' }
|
||||
},
|
||||
{
|
||||
path: 'register',
|
||||
name: 'Register',
|
||||
component: () => import('@/views/user/register.vue'),
|
||||
meta: { title: '注册' }
|
||||
},
|
||||
{
|
||||
path: 'user',
|
||||
name: 'UserCenter',
|
||||
component: () => import('@/views/user/center.vue'),
|
||||
meta: { title: '个人中心', requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'UserProfile',
|
||||
component: () => import('@/views/user/profile.vue'),
|
||||
meta: { title: '个人资料', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'orders',
|
||||
name: 'UserOrders',
|
||||
component: () => import('@/views/user/orders.vue'),
|
||||
meta: { title: '我的订单', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'skills',
|
||||
name: 'UserSkills',
|
||||
component: () => import('@/views/user/skills.vue'),
|
||||
meta: { title: '我的Skill', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'points',
|
||||
name: 'UserPoints',
|
||||
component: () => import('@/views/user/points.vue'),
|
||||
meta: { title: '积分中心', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'recharge',
|
||||
name: 'UserRecharge',
|
||||
component: () => import('@/views/user/recharge.vue'),
|
||||
meta: { title: '积分充值', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'invite',
|
||||
name: 'UserInvite',
|
||||
component: () => import('@/views/user/invite.vue'),
|
||||
meta: { title: '邀请好友', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'notifications',
|
||||
name: 'UserNotifications',
|
||||
component: () => import('@/views/user/notifications.vue'),
|
||||
meta: { title: '消息通知', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'UserSettings',
|
||||
component: () => import('@/views/user/settings.vue'),
|
||||
meta: { title: '账号设置', requiresAuth: true }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'order/:id',
|
||||
name: 'OrderDetail',
|
||||
component: () => import('@/views/order/detail.vue'),
|
||||
meta: { title: '订单详情', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'pay/:orderId',
|
||||
name: 'Pay',
|
||||
component: () => import('@/views/order/pay.vue'),
|
||||
meta: { title: '支付', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'customize',
|
||||
name: 'Customize',
|
||||
component: () => import('@/views/customize/index.vue'),
|
||||
meta: { title: 'Skill定制' }
|
||||
},
|
||||
{
|
||||
path: 'join-us',
|
||||
name: 'JoinUs',
|
||||
component: () => import('@/views/join-us/index.vue'),
|
||||
meta: { title: '加入我们' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
component: () => import('@/layouts/AdminLayout.vue'),
|
||||
meta: { requiresAdmin: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'AdminDashboard',
|
||||
component: () => import('@/views/admin/dashboard.vue'),
|
||||
meta: { title: '控制台' }
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'AdminUsers',
|
||||
component: () => import('@/views/admin/users.vue'),
|
||||
meta: { title: '用户管理' }
|
||||
},
|
||||
{
|
||||
path: 'skills',
|
||||
name: 'AdminSkills',
|
||||
component: () => import('@/views/admin/skills.vue'),
|
||||
meta: { title: 'Skill管理' }
|
||||
},
|
||||
{
|
||||
path: 'orders',
|
||||
name: 'AdminOrders',
|
||||
component: () => import('@/views/admin/orders.vue'),
|
||||
meta: { title: '订单管理' }
|
||||
},
|
||||
{
|
||||
path: 'comments',
|
||||
name: 'AdminComments',
|
||||
component: () => import('@/views/admin/comments.vue'),
|
||||
meta: { title: '评论管理' }
|
||||
},
|
||||
{
|
||||
path: 'points',
|
||||
name: 'AdminPoints',
|
||||
component: () => import('@/views/admin/points.vue'),
|
||||
meta: { title: '积分管理' }
|
||||
},
|
||||
{
|
||||
path: 'statistics',
|
||||
name: 'AdminStatistics',
|
||||
component: () => import('@/views/admin/statistics.vue'),
|
||||
meta: { title: '数据统计' }
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'AdminSettings',
|
||||
component: () => import('@/views/admin/settings.vue'),
|
||||
meta: { title: '系统设置' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/admin/login',
|
||||
name: 'AdminLogin',
|
||||
component: () => import('@/views/admin/login.vue'),
|
||||
meta: { title: '管理员登录' }
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: () => import('@/views/error/404.vue'),
|
||||
meta: { title: '页面不存在' }
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
if (savedPosition) {
|
||||
return savedPosition
|
||||
} else {
|
||||
return { top: 0 }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
document.title = to.meta.title ? `${to.meta.title} - OpenClaw Skills` : 'OpenClaw Skills'
|
||||
|
||||
if (to.meta.requiresAuth) {
|
||||
const user = getData(STORAGE_KEYS.CURRENT_USER)
|
||||
if (!user) {
|
||||
next({
|
||||
path: '/login',
|
||||
query: { redirect: to.fullPath }
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (to.meta.requiresAdmin) {
|
||||
const admin = sessionStorage.getItem('admin_user')
|
||||
if (!admin) {
|
||||
next('/admin/login')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
966
frontend/src/service/localService.js
Normal file
966
frontend/src/service/localService.js
Normal file
@@ -0,0 +1,966 @@
|
||||
import {
|
||||
STORAGE_KEYS,
|
||||
getData,
|
||||
setData,
|
||||
generateId,
|
||||
generateOrderNo,
|
||||
generateInviteCode
|
||||
} from '@/data/mockData'
|
||||
|
||||
const userService = {
|
||||
register(phone, password, nickname, inviteCode = null) {
|
||||
const users = getData(STORAGE_KEYS.USERS) || []
|
||||
const existUser = users.find(u => u.phone === phone)
|
||||
if (existUser) {
|
||||
return { success: false, message: '该手机号已注册' }
|
||||
}
|
||||
|
||||
const config = getData(STORAGE_KEYS.SYSTEM_CONFIG)
|
||||
const now = new Date().toISOString().replace('T', ' ').substr(0, 19)
|
||||
|
||||
let inviterId = null
|
||||
if (inviteCode) {
|
||||
const inviter = users.find(u => u.inviteCode === inviteCode)
|
||||
if (inviter) {
|
||||
inviterId = inviter.id
|
||||
inviter.inviteCount = (inviter.inviteCount || 0) + 1
|
||||
this.addPoints(inviter.id, config.pointRules.invite, 'invite', `邀请好友奖励(手机号: ${phone.substr(0, 3)}****${phone.substr(7)})`)
|
||||
}
|
||||
}
|
||||
|
||||
const newUser = {
|
||||
id: users.length + 1,
|
||||
phone,
|
||||
password,
|
||||
nickname: nickname || `用户${phone.substr(7)}`,
|
||||
avatar: `https://picsum.photos/200/200?random=${Date.now()}`,
|
||||
email: '',
|
||||
points: config.pointRules.register,
|
||||
totalPoints: config.pointRules.register,
|
||||
level: 0,
|
||||
levelName: '普通会员',
|
||||
growthValue: 0,
|
||||
inviteCode: generateInviteCode(),
|
||||
invitedBy: inviteCode || null,
|
||||
inviterId,
|
||||
inviteCount: 0,
|
||||
status: 'active',
|
||||
createdAt: now,
|
||||
lastLoginAt: now,
|
||||
isVip: false,
|
||||
vipExpireAt: null,
|
||||
settings: {
|
||||
notification: true,
|
||||
emailNotify: true,
|
||||
smsNotify: false
|
||||
},
|
||||
signedToday: false,
|
||||
continuousSignDays: 0,
|
||||
totalSignDays: 0,
|
||||
joinedGroup: false,
|
||||
mySkills: [],
|
||||
favorites: []
|
||||
}
|
||||
|
||||
users.push(newUser)
|
||||
setData(STORAGE_KEYS.USERS, users)
|
||||
|
||||
this.addPointRecord(newUser.id, config.pointRules.register, 'register', '新用户注册奖励')
|
||||
|
||||
const { password: _, ...userWithoutPassword } = newUser
|
||||
return { success: true, data: userWithoutPassword, message: '注册成功' }
|
||||
},
|
||||
|
||||
login(phone, password) {
|
||||
const users = getData(STORAGE_KEYS.USERS) || []
|
||||
const user = users.find(u => u.phone === phone && u.password === password)
|
||||
|
||||
if (!user) {
|
||||
return { success: false, message: '手机号或密码错误' }
|
||||
}
|
||||
|
||||
if (user.status === 'banned') {
|
||||
return { success: false, message: '账号已被封禁,请联系客服' }
|
||||
}
|
||||
|
||||
const now = new Date().toISOString().replace('T', ' ').substr(0, 19)
|
||||
user.lastLoginAt = now
|
||||
setData(STORAGE_KEYS.USERS, users)
|
||||
|
||||
const { password: _, ...userWithoutPassword } = user
|
||||
setData(STORAGE_KEYS.CURRENT_USER, userWithoutPassword)
|
||||
|
||||
return { success: true, data: userWithoutPassword, message: '登录成功' }
|
||||
},
|
||||
|
||||
logout() {
|
||||
localStorage.removeItem(STORAGE_KEYS.CURRENT_USER)
|
||||
return { success: true, message: '退出成功' }
|
||||
},
|
||||
|
||||
getCurrentUser() {
|
||||
return getData(STORAGE_KEYS.CURRENT_USER)
|
||||
},
|
||||
|
||||
updateUser(userId, updates) {
|
||||
const users = getData(STORAGE_KEYS.USERS) || []
|
||||
const index = users.findIndex(u => u.id === userId)
|
||||
|
||||
if (index === -1) {
|
||||
return { success: false, message: '用户不存在' }
|
||||
}
|
||||
|
||||
users[index] = { ...users[index], ...updates }
|
||||
setData(STORAGE_KEYS.USERS, users)
|
||||
|
||||
const currentUser = this.getCurrentUser()
|
||||
if (currentUser && currentUser.id === userId) {
|
||||
const { password: _, ...userWithoutPassword } = users[index]
|
||||
setData(STORAGE_KEYS.CURRENT_USER, userWithoutPassword)
|
||||
}
|
||||
|
||||
return { success: true, data: users[index], message: '更新成功' }
|
||||
},
|
||||
|
||||
getUserById(userId) {
|
||||
const users = getData(STORAGE_KEYS.USERS) || []
|
||||
return users.find(u => u.id === userId)
|
||||
},
|
||||
|
||||
getUserByPhone(phone) {
|
||||
const users = getData(STORAGE_KEYS.USERS) || []
|
||||
return users.find(u => u.phone === phone)
|
||||
},
|
||||
|
||||
getAllUsers() {
|
||||
return getData(STORAGE_KEYS.USERS) || []
|
||||
},
|
||||
|
||||
addPoints(userId, amount, source, description, relatedId = null) {
|
||||
const users = getData(STORAGE_KEYS.USERS) || []
|
||||
const user = users.find(u => u.id === userId)
|
||||
|
||||
if (!user) return false
|
||||
|
||||
user.points = (user.points || 0) + amount
|
||||
if (amount > 0) {
|
||||
user.totalPoints = (user.totalPoints || 0) + amount
|
||||
}
|
||||
|
||||
this.addPointRecord(userId, amount, source, description, relatedId)
|
||||
this.updateUserLevel(userId)
|
||||
setData(STORAGE_KEYS.USERS, users)
|
||||
|
||||
return true
|
||||
},
|
||||
|
||||
deductPoints(userId, amount, source, description, relatedId = null) {
|
||||
const users = getData(STORAGE_KEYS.USERS) || []
|
||||
const user = users.find(u => u.id === userId)
|
||||
|
||||
if (!user) return { success: false, message: '用户不存在' }
|
||||
if (user.points < amount) return { success: false, message: '积分不足' }
|
||||
|
||||
user.points -= amount
|
||||
this.addPointRecord(userId, -amount, source, description, relatedId)
|
||||
setData(STORAGE_KEYS.USERS, users)
|
||||
|
||||
return { success: true, balance: user.points }
|
||||
},
|
||||
|
||||
addPointRecord(userId, amount, source, description, relatedId = null) {
|
||||
const records = getData(STORAGE_KEYS.POINT_RECORDS) || []
|
||||
const user = this.getUserById(userId)
|
||||
|
||||
const record = {
|
||||
id: records.length + 1,
|
||||
userId,
|
||||
type: amount > 0 ? 'income' : 'expense',
|
||||
amount: Math.abs(amount),
|
||||
balance: user ? user.points : 0,
|
||||
source,
|
||||
description,
|
||||
relatedId,
|
||||
createdAt: new Date().toISOString().replace('T', ' ').substr(0, 19)
|
||||
}
|
||||
|
||||
records.unshift(record)
|
||||
setData(STORAGE_KEYS.POINT_RECORDS, records)
|
||||
},
|
||||
|
||||
updateUserLevel(userId) {
|
||||
const users = getData(STORAGE_KEYS.USERS) || []
|
||||
const config = getData(STORAGE_KEYS.SYSTEM_CONFIG)
|
||||
const user = users.find(u => u.id === userId)
|
||||
|
||||
if (!user) return
|
||||
|
||||
const levelRules = config.levelRules
|
||||
for (let i = levelRules.length - 1; i >= 0; i--) {
|
||||
if (user.growthValue >= levelRules[i].minGrowth) {
|
||||
user.level = levelRules[i].level
|
||||
user.levelName = levelRules[i].name
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
dailySign(userId) {
|
||||
const users = getData(STORAGE_KEYS.USERS) || []
|
||||
const user = users.find(u => u.id === userId)
|
||||
const config = getData(STORAGE_KEYS.SYSTEM_CONFIG)
|
||||
|
||||
if (!user) return { success: false, message: '用户不存在' }
|
||||
if (user.signedToday) return { success: false, message: '今日已签到' }
|
||||
|
||||
const continuousDays = (user.continuousSignDays || 0) + 1
|
||||
const bonusIndex = Math.min(continuousDays - 1, config.pointRules.continuousSignBonus.length - 1)
|
||||
const signPoints = config.pointRules.continuousSignBonus[bonusIndex]
|
||||
|
||||
user.signedToday = true
|
||||
user.continuousSignDays = continuousDays
|
||||
user.totalSignDays = (user.totalSignDays || 0) + 1
|
||||
user.growthValue = (user.growthValue || 0) + 1
|
||||
|
||||
this.addPoints(userId, signPoints, 'signin', `每日签到奖励(连续签到第${continuousDays}天)`)
|
||||
setData(STORAGE_KEYS.USERS, users)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
points: signPoints,
|
||||
continuousDays,
|
||||
totalDays: user.totalSignDays
|
||||
},
|
||||
message: `签到成功,获得${signPoints}积分`
|
||||
}
|
||||
},
|
||||
|
||||
resetDailySign() {
|
||||
const users = getData(STORAGE_KEYS.USERS) || []
|
||||
users.forEach(user => {
|
||||
if (!user.signedToday) {
|
||||
user.continuousSignDays = 0
|
||||
}
|
||||
user.signedToday = false
|
||||
})
|
||||
setData(STORAGE_KEYS.USERS, users)
|
||||
},
|
||||
|
||||
joinGroup(userId) {
|
||||
const users = getData(STORAGE_KEYS.USERS) || []
|
||||
const user = users.find(u => u.id === userId)
|
||||
const config = getData(STORAGE_KEYS.SYSTEM_CONFIG)
|
||||
|
||||
if (!user) return { success: false, message: '用户不存在' }
|
||||
if (user.joinedGroup) return { success: false, message: '您已加入过社群' }
|
||||
|
||||
user.joinedGroup = true
|
||||
this.addPoints(userId, config.pointRules.joinGroup, 'group', '加入技术交流群奖励')
|
||||
setData(STORAGE_KEYS.USERS, users)
|
||||
|
||||
return { success: true, message: `加入成功,获得${config.pointRules.joinGroup}积分` }
|
||||
},
|
||||
|
||||
getInviteRecords(userId) {
|
||||
const users = getData(STORAGE_KEYS.USERS) || []
|
||||
return users.filter(u => u.inviterId === userId).map(u => ({
|
||||
id: u.id,
|
||||
nickname: u.nickname,
|
||||
avatar: u.avatar,
|
||||
createdAt: u.createdAt,
|
||||
hasPurchased: this.hasUserPurchased(u.id)
|
||||
}))
|
||||
},
|
||||
|
||||
hasUserPurchased(userId) {
|
||||
const orders = getData(STORAGE_KEYS.ORDERS) || []
|
||||
return orders.some(o => o.userId === userId && o.status === 'completed')
|
||||
},
|
||||
|
||||
banUser(userId) {
|
||||
return this.updateUser(userId, { status: 'banned' })
|
||||
},
|
||||
|
||||
unbanUser(userId) {
|
||||
return this.updateUser(userId, { status: 'active' })
|
||||
}
|
||||
}
|
||||
|
||||
const skillService = {
|
||||
getAllSkills() {
|
||||
return getData(STORAGE_KEYS.SKILLS) || []
|
||||
},
|
||||
|
||||
getSkillById(skillId) {
|
||||
const skills = this.getAllSkills()
|
||||
return skills.find(s => s.id === skillId)
|
||||
},
|
||||
|
||||
getSkillsByCategory(categoryId) {
|
||||
const skills = this.getAllSkills()
|
||||
return skills.filter(s => s.categoryId === categoryId && s.status === 'active')
|
||||
},
|
||||
|
||||
getFeaturedSkills() {
|
||||
const skills = this.getAllSkills()
|
||||
return skills.filter(s => s.isFeatured && s.status === 'active')
|
||||
},
|
||||
|
||||
getHotSkills() {
|
||||
const skills = this.getAllSkills()
|
||||
return skills.filter(s => s.isHot && s.status === 'active')
|
||||
},
|
||||
|
||||
getNewSkills() {
|
||||
const skills = this.getAllSkills()
|
||||
return skills.filter(s => s.isNew && s.status === 'active')
|
||||
},
|
||||
|
||||
searchSkills(keyword, filters = {}) {
|
||||
let skills = this.getAllSkills().filter(s => s.status === 'active')
|
||||
|
||||
if (keyword) {
|
||||
const lowerKeyword = keyword.toLowerCase()
|
||||
skills = skills.filter(s =>
|
||||
s.name.toLowerCase().includes(lowerKeyword) ||
|
||||
s.description.toLowerCase().includes(lowerKeyword) ||
|
||||
s.tags.some(t => t.toLowerCase().includes(lowerKeyword))
|
||||
)
|
||||
}
|
||||
|
||||
if (filters.categoryId) {
|
||||
skills = skills.filter(s => s.categoryId === filters.categoryId)
|
||||
}
|
||||
|
||||
if (filters.priceType === 'free') {
|
||||
skills = skills.filter(s => s.price === 0)
|
||||
} else if (filters.priceType === 'paid') {
|
||||
skills = skills.filter(s => s.price > 0)
|
||||
}
|
||||
|
||||
if (filters.minPrice !== undefined) {
|
||||
skills = skills.filter(s => s.price >= filters.minPrice)
|
||||
}
|
||||
if (filters.maxPrice !== undefined) {
|
||||
skills = skills.filter(s => s.price <= filters.maxPrice)
|
||||
}
|
||||
|
||||
if (filters.minRating !== undefined) {
|
||||
skills = skills.filter(s => s.rating >= filters.minRating)
|
||||
}
|
||||
|
||||
if (filters.sortBy === 'newest') {
|
||||
skills.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||
} else if (filters.sortBy === 'downloads') {
|
||||
skills.sort((a, b) => b.downloadCount - a.downloadCount)
|
||||
} else if (filters.sortBy === 'rating') {
|
||||
skills.sort((a, b) => b.rating - a.rating)
|
||||
} else if (filters.sortBy === 'price_asc') {
|
||||
skills.sort((a, b) => a.price - b.price)
|
||||
} else if (filters.sortBy === 'price_desc') {
|
||||
skills.sort((a, b) => b.price - a.price)
|
||||
}
|
||||
|
||||
return skills
|
||||
},
|
||||
|
||||
getCategories() {
|
||||
return getData(STORAGE_KEYS.CATEGORIES) || []
|
||||
},
|
||||
|
||||
getCategoryById(categoryId) {
|
||||
const categories = this.getCategories()
|
||||
return categories.find(c => c.id === categoryId)
|
||||
},
|
||||
|
||||
incrementDownload(skillId) {
|
||||
const skills = this.getAllSkills()
|
||||
const skill = skills.find(s => s.id === skillId)
|
||||
if (skill) {
|
||||
skill.downloadCount = (skill.downloadCount || 0) + 1
|
||||
setData(STORAGE_KEYS.SKILLS, skills)
|
||||
}
|
||||
},
|
||||
|
||||
addSkill(skillData) {
|
||||
const skills = this.getAllSkills()
|
||||
const newSkill = {
|
||||
id: skills.length + 1,
|
||||
...skillData,
|
||||
downloadCount: 0,
|
||||
rating: 0,
|
||||
ratingCount: 0,
|
||||
status: 'pending',
|
||||
isFeatured: false,
|
||||
isHot: false,
|
||||
isNew: true,
|
||||
createdAt: new Date().toISOString().replace('T', ' ').substr(0, 19),
|
||||
updatedAt: new Date().toISOString().replace('T', ' ').substr(0, 19)
|
||||
}
|
||||
skills.push(newSkill)
|
||||
setData(STORAGE_KEYS.SKILLS, skills)
|
||||
return { success: true, data: newSkill }
|
||||
},
|
||||
|
||||
updateSkill(skillId, updates) {
|
||||
const skills = this.getAllSkills()
|
||||
const index = skills.findIndex(s => s.id === skillId)
|
||||
if (index === -1) {
|
||||
return { success: false, message: 'Skill不存在' }
|
||||
}
|
||||
skills[index] = {
|
||||
...skills[index],
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString().replace('T', ' ').substr(0, 19)
|
||||
}
|
||||
setData(STORAGE_KEYS.SKILLS, skills)
|
||||
return { success: true, data: skills[index] }
|
||||
},
|
||||
|
||||
deleteSkill(skillId) {
|
||||
const skills = this.getAllSkills()
|
||||
const index = skills.findIndex(s => s.id === skillId)
|
||||
if (index === -1) {
|
||||
return { success: false, message: 'Skill不存在' }
|
||||
}
|
||||
skills.splice(index, 1)
|
||||
setData(STORAGE_KEYS.SKILLS, skills)
|
||||
return { success: true }
|
||||
},
|
||||
|
||||
approveSkill(skillId) {
|
||||
return this.updateSkill(skillId, { status: 'active' })
|
||||
},
|
||||
|
||||
rejectSkill(skillId, reason) {
|
||||
return this.updateSkill(skillId, { status: 'rejected', rejectReason: reason })
|
||||
},
|
||||
|
||||
setFeatured(skillId, featured) {
|
||||
return this.updateSkill(skillId, { isFeatured: featured })
|
||||
},
|
||||
|
||||
setHot(skillId, hot) {
|
||||
return this.updateSkill(skillId, { isHot: hot })
|
||||
}
|
||||
}
|
||||
|
||||
const orderService = {
|
||||
createOrder(userId, skillId, payType, pointsToUse = 0) {
|
||||
const skill = skillService.getSkillById(skillId)
|
||||
const user = userService.getUserById(userId)
|
||||
|
||||
if (!skill) {
|
||||
return { success: false, message: 'Skill不存在' }
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return { success: false, message: '用户不存在' }
|
||||
}
|
||||
|
||||
const existingOrder = this.getUserOrders(userId).find(
|
||||
o => o.skillId === skillId && o.status === 'completed'
|
||||
)
|
||||
if (existingOrder) {
|
||||
return { success: false, message: '您已购买过该Skill' }
|
||||
}
|
||||
|
||||
const now = new Date().toISOString().replace('T', ' ').substr(0, 19)
|
||||
let orderData = {
|
||||
id: generateOrderNo(),
|
||||
userId,
|
||||
skillId,
|
||||
skillName: skill.name,
|
||||
skillCover: skill.cover,
|
||||
price: skill.price,
|
||||
pointPrice: skill.pointPrice,
|
||||
originalPrice: skill.originalPrice,
|
||||
payType,
|
||||
status: 'pending',
|
||||
createdAt: now
|
||||
}
|
||||
|
||||
if (payType === 'points') {
|
||||
if (user.points < skill.pointPrice) {
|
||||
return { success: false, message: '积分不足' }
|
||||
}
|
||||
orderData.paidPoints = skill.pointPrice
|
||||
} else if (payType === 'cash') {
|
||||
orderData.paidAmount = skill.price
|
||||
} else if (payType === 'mixed') {
|
||||
const remainingPoints = user.points
|
||||
const pointsValue = Math.min(pointsToUse, remainingPoints)
|
||||
const cashNeeded = skill.price - (pointsValue / 10)
|
||||
|
||||
if (cashNeeded < 0) {
|
||||
return { success: false, message: '积分使用过多' }
|
||||
}
|
||||
|
||||
orderData.paidPoints = pointsValue
|
||||
orderData.paidAmount = cashNeeded
|
||||
}
|
||||
|
||||
const orders = getData(STORAGE_KEYS.ORDERS) || []
|
||||
orders.unshift(orderData)
|
||||
setData(STORAGE_KEYS.ORDERS, orders)
|
||||
|
||||
return { success: true, data: orderData }
|
||||
},
|
||||
|
||||
payOrder(orderId, userId) {
|
||||
const orders = getData(STORAGE_KEYS.ORDERS) || []
|
||||
const order = orders.find(o => o.id === orderId)
|
||||
|
||||
if (!order) {
|
||||
return { success: false, message: '订单不存在' }
|
||||
}
|
||||
|
||||
if (order.userId !== userId) {
|
||||
return { success: false, message: '无权操作此订单' }
|
||||
}
|
||||
|
||||
if (order.status !== 'pending') {
|
||||
return { success: false, message: '订单状态不正确' }
|
||||
}
|
||||
|
||||
const user = userService.getUserById(userId)
|
||||
if (!user) {
|
||||
return { success: false, message: '用户不存在' }
|
||||
}
|
||||
|
||||
if (order.payType === 'points' || order.paidPoints > 0) {
|
||||
const deductResult = userService.deductPoints(
|
||||
userId,
|
||||
order.paidPoints,
|
||||
'purchase',
|
||||
`购买Skill:${order.skillName}`,
|
||||
orderId
|
||||
)
|
||||
if (!deductResult.success) {
|
||||
return deductResult
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date().toISOString().replace('T', ' ').substr(0, 19)
|
||||
order.status = 'completed'
|
||||
order.paidAt = now
|
||||
order.completedAt = now
|
||||
|
||||
setData(STORAGE_KEYS.ORDERS, orders)
|
||||
|
||||
skillService.incrementDownload(order.skillId)
|
||||
|
||||
userService.addNotification(userId, 'order', '订单支付成功', `您的订单${orderId}已支付成功,Skill已添加到您的账户。`)
|
||||
|
||||
return { success: true, data: order, message: '支付成功' }
|
||||
},
|
||||
|
||||
cancelOrder(orderId, userId) {
|
||||
const orders = getData(STORAGE_KEYS.ORDERS) || []
|
||||
const order = orders.find(o => o.id === orderId)
|
||||
|
||||
if (!order) {
|
||||
return { success: false, message: '订单不存在' }
|
||||
}
|
||||
|
||||
if (order.userId !== userId) {
|
||||
return { success: false, message: '无权操作此订单' }
|
||||
}
|
||||
|
||||
if (order.status !== 'pending') {
|
||||
return { success: false, message: '只能取消待支付订单' }
|
||||
}
|
||||
|
||||
order.status = 'cancelled'
|
||||
order.cancelledAt = new Date().toISOString().replace('T', ' ').substr(0, 19)
|
||||
setData(STORAGE_KEYS.ORDERS, orders)
|
||||
|
||||
return { success: true, message: '订单已取消' }
|
||||
},
|
||||
|
||||
refundOrder(orderId, reason) {
|
||||
const orders = getData(STORAGE_KEYS.ORDERS) || []
|
||||
const order = orders.find(o => o.id === orderId)
|
||||
|
||||
if (!order) {
|
||||
return { success: false, message: '订单不存在' }
|
||||
}
|
||||
|
||||
if (order.status !== 'completed') {
|
||||
return { success: false, message: '只能退款已完成的订单' }
|
||||
}
|
||||
|
||||
order.status = 'refunded'
|
||||
order.refundReason = reason
|
||||
order.refundedAt = new Date().toISOString().replace('T', ' ').substr(0, 19)
|
||||
|
||||
if (order.paidPoints > 0) {
|
||||
userService.addPoints(order.userId, order.paidPoints, 'refund', `订单退款:${order.skillName}`, orderId)
|
||||
}
|
||||
|
||||
setData(STORAGE_KEYS.ORDERS, orders)
|
||||
userService.addNotification(order.userId, 'order', '订单退款成功', `您的订单${orderId}已退款成功,积分已返还。`)
|
||||
|
||||
return { success: true, message: '退款成功' }
|
||||
},
|
||||
|
||||
getUserOrders(userId) {
|
||||
const orders = getData(STORAGE_KEYS.ORDERS) || []
|
||||
return orders.filter(o => o.userId === userId)
|
||||
},
|
||||
|
||||
getOrderById(orderId) {
|
||||
const orders = getData(STORAGE_KEYS.ORDERS) || []
|
||||
return orders.find(o => o.id === orderId)
|
||||
},
|
||||
|
||||
getAllOrders() {
|
||||
return getData(STORAGE_KEYS.ORDERS) || []
|
||||
},
|
||||
|
||||
getUserPurchasedSkills(userId) {
|
||||
const orders = this.getUserOrders(userId)
|
||||
return orders
|
||||
.filter(o => o.status === 'completed')
|
||||
.map(o => {
|
||||
const skill = skillService.getSkillById(o.skillId)
|
||||
return {
|
||||
...skill,
|
||||
purchasedAt: o.completedAt,
|
||||
orderId: o.id
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const pointService = {
|
||||
getPointRecords(userId, filters = {}) {
|
||||
let records = getData(STORAGE_KEYS.POINT_RECORDS) || []
|
||||
|
||||
records = records.filter(r => r.userId === userId)
|
||||
|
||||
if (filters.type) {
|
||||
records = records.filter(r => r.type === filters.type)
|
||||
}
|
||||
|
||||
if (filters.source) {
|
||||
records = records.filter(r => r.source === filters.source)
|
||||
}
|
||||
|
||||
if (filters.startDate) {
|
||||
records = records.filter(r => r.createdAt >= filters.startDate)
|
||||
}
|
||||
if (filters.endDate) {
|
||||
records = records.filter(r => r.createdAt <= filters.endDate)
|
||||
}
|
||||
|
||||
return records
|
||||
},
|
||||
|
||||
getAllPointRecords() {
|
||||
return getData(STORAGE_KEYS.POINT_RECORDS) || []
|
||||
},
|
||||
|
||||
recharge(userId, amount) {
|
||||
const config = getData(STORAGE_KEYS.SYSTEM_CONFIG)
|
||||
const tier = config.rechargeTiers.find(t => t.amount === amount)
|
||||
|
||||
if (!tier) {
|
||||
return { success: false, message: '无效的充值金额' }
|
||||
}
|
||||
|
||||
const totalPoints = amount * 10 + tier.bonus
|
||||
|
||||
userService.addPoints(userId, totalPoints, 'recharge', `充值赠送(充值${amount}元)`)
|
||||
|
||||
const now = new Date().toISOString().replace('T', ' ').substr(0, 19)
|
||||
const rechargeRecord = {
|
||||
id: generateId(),
|
||||
userId,
|
||||
amount,
|
||||
points: totalPoints,
|
||||
bonus: tier.bonus,
|
||||
status: 'completed',
|
||||
createdAt: now
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
points: totalPoints,
|
||||
bonus: tier.bonus
|
||||
},
|
||||
message: `充值成功,获得${totalPoints}积分`
|
||||
}
|
||||
},
|
||||
|
||||
getRechargeTiers() {
|
||||
const config = getData(STORAGE_KEYS.SYSTEM_CONFIG)
|
||||
return config.rechargeTiers
|
||||
},
|
||||
|
||||
getPointRules() {
|
||||
const config = getData(STORAGE_KEYS.SYSTEM_CONFIG)
|
||||
return config.pointRules
|
||||
}
|
||||
}
|
||||
|
||||
const commentService = {
|
||||
getCommentsBySkillId(skillId) {
|
||||
const comments = getData(STORAGE_KEYS.COMMENTS) || []
|
||||
return comments.filter(c => c.skillId === skillId && c.status === 'active')
|
||||
},
|
||||
|
||||
addComment(userId, skillId, rating, content, images = []) {
|
||||
const user = userService.getUserById(userId)
|
||||
const skill = skillService.getSkillById(skillId)
|
||||
|
||||
if (!user || !skill) {
|
||||
return { success: false, message: '用户或Skill不存在' }
|
||||
}
|
||||
|
||||
const comments = getData(STORAGE_KEYS.COMMENTS) || []
|
||||
|
||||
const existingComment = comments.find(c => c.skillId === skillId && c.userId === userId)
|
||||
if (existingComment) {
|
||||
return { success: false, message: '您已评价过该Skill' }
|
||||
}
|
||||
|
||||
const config = getData(STORAGE_KEYS.SYSTEM_CONFIG)
|
||||
const now = new Date().toISOString().replace('T', ' ').substr(0, 19)
|
||||
|
||||
const newComment = {
|
||||
id: comments.length + 1,
|
||||
skillId,
|
||||
userId,
|
||||
userName: user.nickname,
|
||||
userAvatar: user.avatar,
|
||||
rating,
|
||||
content,
|
||||
images,
|
||||
likes: 0,
|
||||
isLiked: false,
|
||||
status: 'active',
|
||||
createdAt: now
|
||||
}
|
||||
|
||||
comments.unshift(newComment)
|
||||
setData(STORAGE_KEYS.COMMENTS, comments)
|
||||
|
||||
const pointReward = images.length > 0 ? config.pointRules.reviewWithImage : config.pointRules.review
|
||||
userService.addPoints(userId, pointReward, 'review', `评价Skill:${skill.name}`)
|
||||
|
||||
this.updateSkillRating(skillId)
|
||||
|
||||
return { success: true, data: newComment, message: `评价成功,获得${pointReward}积分` }
|
||||
},
|
||||
|
||||
updateSkillRating(skillId) {
|
||||
const comments = this.getCommentsBySkillId(skillId)
|
||||
if (comments.length === 0) return
|
||||
|
||||
const totalRating = comments.reduce((sum, c) => sum + c.rating, 0)
|
||||
const avgRating = totalRating / comments.length
|
||||
|
||||
skillService.updateSkill(skillId, {
|
||||
rating: Math.round(avgRating * 10) / 10,
|
||||
ratingCount: comments.length
|
||||
})
|
||||
},
|
||||
|
||||
likeComment(commentId, userId) {
|
||||
const comments = getData(STORAGE_KEYS.COMMENTS) || []
|
||||
const comment = comments.find(c => c.id === commentId)
|
||||
|
||||
if (!comment) {
|
||||
return { success: false, message: '评论不存在' }
|
||||
}
|
||||
|
||||
if (comment.isLiked) {
|
||||
comment.likes = Math.max(0, comment.likes - 1)
|
||||
comment.isLiked = false
|
||||
} else {
|
||||
comment.likes += 1
|
||||
comment.isLiked = true
|
||||
}
|
||||
|
||||
setData(STORAGE_KEYS.COMMENTS, comments)
|
||||
return { success: true, data: comment }
|
||||
},
|
||||
|
||||
deleteComment(commentId) {
|
||||
const comments = getData(STORAGE_KEYS.COMMENTS) || []
|
||||
const index = comments.findIndex(c => c.id === commentId)
|
||||
|
||||
if (index === -1) {
|
||||
return { success: false, message: '评论不存在' }
|
||||
}
|
||||
|
||||
comments[index].status = 'deleted'
|
||||
setData(STORAGE_KEYS.COMMENTS, comments)
|
||||
return { success: true }
|
||||
},
|
||||
|
||||
getAllComments() {
|
||||
return getData(STORAGE_KEYS.COMMENTS) || []
|
||||
}
|
||||
}
|
||||
|
||||
const notificationService = {
|
||||
getUserNotifications(userId) {
|
||||
const notifications = getData(STORAGE_KEYS.NOTIFICATIONS) || []
|
||||
return notifications.filter(n => n.userId === userId)
|
||||
},
|
||||
|
||||
addNotification(userId, type, title, content) {
|
||||
const notifications = getData(STORAGE_KEYS.NOTIFICATIONS) || []
|
||||
|
||||
const newNotification = {
|
||||
id: notifications.length + 1,
|
||||
userId,
|
||||
type,
|
||||
title,
|
||||
content,
|
||||
isRead: false,
|
||||
createdAt: new Date().toISOString().replace('T', ' ').substr(0, 19)
|
||||
}
|
||||
|
||||
notifications.unshift(newNotification)
|
||||
setData(STORAGE_KEYS.NOTIFICATIONS, notifications)
|
||||
|
||||
return newNotification
|
||||
},
|
||||
|
||||
markAsRead(notificationId) {
|
||||
const notifications = getData(STORAGE_KEYS.NOTIFICATIONS) || []
|
||||
const notification = notifications.find(n => n.id === notificationId)
|
||||
|
||||
if (notification) {
|
||||
notification.isRead = true
|
||||
setData(STORAGE_KEYS.NOTIFICATIONS, notifications)
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
},
|
||||
|
||||
markAllAsRead(userId) {
|
||||
const notifications = getData(STORAGE_KEYS.NOTIFICATIONS) || []
|
||||
notifications.forEach(n => {
|
||||
if (n.userId === userId) {
|
||||
n.isRead = true
|
||||
}
|
||||
})
|
||||
setData(STORAGE_KEYS.NOTIFICATIONS, notifications)
|
||||
return { success: true }
|
||||
},
|
||||
|
||||
getUnreadCount(userId) {
|
||||
const notifications = this.getUserNotifications(userId)
|
||||
return notifications.filter(n => !n.isRead).length
|
||||
},
|
||||
|
||||
deleteNotification(notificationId) {
|
||||
const notifications = getData(STORAGE_KEYS.NOTIFICATIONS) || []
|
||||
const index = notifications.findIndex(n => n.id === notificationId)
|
||||
|
||||
if (index !== -1) {
|
||||
notifications.splice(index, 1)
|
||||
setData(STORAGE_KEYS.NOTIFICATIONS, notifications)
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
}
|
||||
|
||||
const adminService = {
|
||||
login(username, password) {
|
||||
const admins = getData(STORAGE_KEYS.ADMIN_USERS) || []
|
||||
const admin = admins.find(a => a.username === username && a.password === password)
|
||||
|
||||
if (!admin) {
|
||||
return { success: false, message: '用户名或密码错误' }
|
||||
}
|
||||
|
||||
if (admin.status !== 'active') {
|
||||
return { success: false, message: '账号已被禁用' }
|
||||
}
|
||||
|
||||
const { password: _, ...adminWithoutPassword } = admin
|
||||
return { success: true, data: adminWithoutPassword }
|
||||
},
|
||||
|
||||
getDashboardStats() {
|
||||
const users = userService.getAllUsers()
|
||||
const skills = skillService.getAllSkills()
|
||||
const orders = orderService.getAllOrders()
|
||||
const pointRecords = pointService.getAllPointRecords()
|
||||
|
||||
const totalRevenue = orders
|
||||
.filter(o => o.status === 'completed')
|
||||
.reduce((sum, o) => sum + (o.paidAmount || 0), 0)
|
||||
|
||||
const totalPointsIssued = pointRecords
|
||||
.filter(r => r.type === 'income')
|
||||
.reduce((sum, r) => sum + r.amount, 0)
|
||||
|
||||
const totalPointsConsumed = pointRecords
|
||||
.filter(r => r.type === 'expense')
|
||||
.reduce((sum, r) => sum + r.amount, 0)
|
||||
|
||||
return {
|
||||
totalUsers: users.length,
|
||||
activeUsers: users.filter(u => u.status === 'active').length,
|
||||
totalSkills: skills.length,
|
||||
activeSkills: skills.filter(s => s.status === 'active').length,
|
||||
totalOrders: orders.length,
|
||||
completedOrders: orders.filter(o => o.status === 'completed').length,
|
||||
totalRevenue,
|
||||
totalPointsIssued,
|
||||
totalPointsConsumed,
|
||||
todayNewUsers: users.filter(u => {
|
||||
const today = new Date().toISOString().substr(0, 10)
|
||||
return u.createdAt.substr(0, 10) === today
|
||||
}).length,
|
||||
todayOrders: orders.filter(o => {
|
||||
const today = new Date().toISOString().substr(0, 10)
|
||||
return o.createdAt.substr(0, 10) === today
|
||||
}).length
|
||||
}
|
||||
},
|
||||
|
||||
getAllUsers() {
|
||||
return userService.getAllUsers()
|
||||
},
|
||||
|
||||
getAllSkills() {
|
||||
return skillService.getAllSkills()
|
||||
},
|
||||
|
||||
getAllOrders() {
|
||||
return orderService.getAllOrders()
|
||||
},
|
||||
|
||||
getAllComments() {
|
||||
return commentService.getAllComments()
|
||||
},
|
||||
|
||||
getSystemConfig() {
|
||||
return getData(STORAGE_KEYS.SYSTEM_CONFIG)
|
||||
},
|
||||
|
||||
updateSystemConfig(config) {
|
||||
setData(STORAGE_KEYS.SYSTEM_CONFIG, config)
|
||||
return { success: true }
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
userService,
|
||||
skillService,
|
||||
orderService,
|
||||
pointService,
|
||||
commentService,
|
||||
notificationService,
|
||||
adminService
|
||||
}
|
||||
101
frontend/src/stores/admin.js
Normal file
101
frontend/src/stores/admin.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { adminService } from '@/service/localService'
|
||||
|
||||
export const useAdminStore = defineStore('admin', {
|
||||
state: () => ({
|
||||
admin: null,
|
||||
isLoggedIn: false,
|
||||
dashboardStats: null,
|
||||
users: [],
|
||||
skills: [],
|
||||
orders: [],
|
||||
comments: [],
|
||||
systemConfig: null
|
||||
}),
|
||||
|
||||
actions: {
|
||||
login(username, password) {
|
||||
const result = adminService.login(username, password)
|
||||
if (result.success) {
|
||||
this.admin = result.data
|
||||
this.isLoggedIn = true
|
||||
}
|
||||
return result
|
||||
},
|
||||
|
||||
logout() {
|
||||
this.admin = null
|
||||
this.isLoggedIn = false
|
||||
},
|
||||
|
||||
loadDashboardStats() {
|
||||
this.dashboardStats = adminService.getDashboardStats()
|
||||
return this.dashboardStats
|
||||
},
|
||||
|
||||
loadUsers() {
|
||||
this.users = adminService.getAllUsers()
|
||||
return this.users
|
||||
},
|
||||
|
||||
loadSkills() {
|
||||
this.skills = adminService.getAllSkills()
|
||||
return this.skills
|
||||
},
|
||||
|
||||
loadOrders() {
|
||||
this.orders = adminService.getAllOrders()
|
||||
return this.orders
|
||||
},
|
||||
|
||||
loadComments() {
|
||||
this.comments = adminService.getAllComments()
|
||||
return this.comments
|
||||
},
|
||||
|
||||
loadSystemConfig() {
|
||||
this.systemConfig = adminService.getSystemConfig()
|
||||
return this.systemConfig
|
||||
},
|
||||
|
||||
updateSystemConfig(config) {
|
||||
const result = adminService.updateSystemConfig(config)
|
||||
if (result.success) {
|
||||
this.systemConfig = config
|
||||
}
|
||||
return result
|
||||
},
|
||||
|
||||
banUser(userId) {
|
||||
const result = adminService.banUser(userId)
|
||||
if (result.success) {
|
||||
this.loadUsers()
|
||||
}
|
||||
return result
|
||||
},
|
||||
|
||||
unbanUser(userId) {
|
||||
const result = adminService.unbanUser(userId)
|
||||
if (result.success) {
|
||||
this.loadUsers()
|
||||
}
|
||||
return result
|
||||
},
|
||||
|
||||
approveSkill(skillId) {
|
||||
const result = adminService.approveSkill(skillId)
|
||||
if (result.success) {
|
||||
this.loadSkills()
|
||||
}
|
||||
return result
|
||||
},
|
||||
|
||||
rejectSkill(skillId, reason) {
|
||||
const result = adminService.rejectSkill(skillId, reason)
|
||||
if (result.success) {
|
||||
this.loadSkills()
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
})
|
||||
35
frontend/src/stores/app.js
Normal file
35
frontend/src/stores/app.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useAppStore = defineStore('app', {
|
||||
state: () => ({
|
||||
sidebarCollapsed: false,
|
||||
theme: 'light',
|
||||
loading: false,
|
||||
pageTitle: 'OpenClaw Skills',
|
||||
breadcrumbs: []
|
||||
}),
|
||||
|
||||
actions: {
|
||||
toggleSidebar() {
|
||||
this.sidebarCollapsed = !this.sidebarCollapsed
|
||||
},
|
||||
|
||||
setTheme(theme) {
|
||||
this.theme = theme
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
},
|
||||
|
||||
setLoading(loading) {
|
||||
this.loading = loading
|
||||
},
|
||||
|
||||
setPageTitle(title) {
|
||||
this.pageTitle = title
|
||||
document.title = title + ' - OpenClaw Skills'
|
||||
},
|
||||
|
||||
setBreadcrumbs(breadcrumbs) {
|
||||
this.breadcrumbs = breadcrumbs
|
||||
}
|
||||
}
|
||||
})
|
||||
6
frontend/src/stores/index.js
Normal file
6
frontend/src/stores/index.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export { useUserStore } from './user'
|
||||
export { useSkillStore } from './skill'
|
||||
export { useOrderStore } from './order'
|
||||
export { usePointStore } from './point'
|
||||
export { useAdminStore } from './admin'
|
||||
export { useAppStore } from './app'
|
||||
76
frontend/src/stores/order.js
Normal file
76
frontend/src/stores/order.js
Normal file
@@ -0,0 +1,76 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { orderService } from '@/service/localService'
|
||||
|
||||
export const useOrderStore = defineStore('order', {
|
||||
state: () => ({
|
||||
orders: [],
|
||||
currentOrder: null,
|
||||
loading: false
|
||||
}),
|
||||
|
||||
getters: {
|
||||
pendingOrders: (state) => state.orders.filter(o => o.status === 'pending'),
|
||||
completedOrders: (state) => state.orders.filter(o => o.status === 'completed'),
|
||||
refundedOrders: (state) => state.orders.filter(o => o.status === 'refunded')
|
||||
},
|
||||
|
||||
actions: {
|
||||
loadUserOrders(userId) {
|
||||
this.orders = orderService.getUserOrders(userId)
|
||||
},
|
||||
|
||||
loadAllOrders() {
|
||||
this.orders = orderService.getAllOrders()
|
||||
},
|
||||
|
||||
createOrder(userId, skillId, payType, pointsToUse = 0) {
|
||||
const result = orderService.createOrder(userId, skillId, payType, pointsToUse)
|
||||
if (result.success) {
|
||||
this.currentOrder = result.data
|
||||
this.orders.unshift(result.data)
|
||||
}
|
||||
return result
|
||||
},
|
||||
|
||||
payOrder(orderId, userId) {
|
||||
const result = orderService.payOrder(orderId, userId)
|
||||
if (result.success) {
|
||||
const index = this.orders.findIndex(o => o.id === orderId)
|
||||
if (index !== -1) {
|
||||
this.orders[index] = result.data
|
||||
}
|
||||
}
|
||||
return result
|
||||
},
|
||||
|
||||
cancelOrder(orderId, userId) {
|
||||
const result = orderService.cancelOrder(orderId, userId)
|
||||
if (result.success) {
|
||||
const index = this.orders.findIndex(o => o.id === orderId)
|
||||
if (index !== -1) {
|
||||
this.orders[index].status = 'cancelled'
|
||||
}
|
||||
}
|
||||
return result
|
||||
},
|
||||
|
||||
refundOrder(orderId, reason) {
|
||||
const result = orderService.refundOrder(orderId, reason)
|
||||
if (result.success) {
|
||||
const index = this.orders.findIndex(o => o.id === orderId)
|
||||
if (index !== -1) {
|
||||
this.orders[index].status = 'refunded'
|
||||
}
|
||||
}
|
||||
return result
|
||||
},
|
||||
|
||||
getOrderById(orderId) {
|
||||
return orderService.getOrderById(orderId)
|
||||
},
|
||||
|
||||
getUserPurchasedSkills(userId) {
|
||||
return orderService.getUserPurchasedSkills(userId)
|
||||
}
|
||||
}
|
||||
})
|
||||
48
frontend/src/stores/point.js
Normal file
48
frontend/src/stores/point.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { pointService, userService } from '@/service/localService'
|
||||
|
||||
export const usePointStore = defineStore('point', {
|
||||
state: () => ({
|
||||
records: [],
|
||||
rechargeTiers: [],
|
||||
pointRules: null,
|
||||
loading: false
|
||||
}),
|
||||
|
||||
getters: {
|
||||
incomeRecords: (state) => state.records.filter(r => r.type === 'income'),
|
||||
expenseRecords: (state) => state.records.filter(r => r.type === 'expense'),
|
||||
totalIncome: (state) => state.records.filter(r => r.type === 'income').reduce((sum, r) => sum + r.amount, 0),
|
||||
totalExpense: (state) => state.records.filter(r => r.type === 'expense').reduce((sum, r) => sum + r.amount, 0)
|
||||
},
|
||||
|
||||
actions: {
|
||||
loadUserRecords(userId, filters = {}) {
|
||||
this.records = pointService.getPointRecords(userId, filters)
|
||||
},
|
||||
|
||||
loadAllRecords() {
|
||||
this.records = pointService.getAllPointRecords()
|
||||
},
|
||||
|
||||
loadRechargeTiers() {
|
||||
this.rechargeTiers = pointService.getRechargeTiers()
|
||||
},
|
||||
|
||||
loadPointRules() {
|
||||
this.pointRules = pointService.getPointRules()
|
||||
},
|
||||
|
||||
recharge(userId, amount) {
|
||||
const result = pointService.recharge(userId, amount)
|
||||
if (result.success) {
|
||||
this.loadUserRecords(userId)
|
||||
}
|
||||
return result
|
||||
},
|
||||
|
||||
getInviteRecords(userId) {
|
||||
return userService.getInviteRecords(userId)
|
||||
}
|
||||
}
|
||||
})
|
||||
90
frontend/src/stores/skill.js
Normal file
90
frontend/src/stores/skill.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { skillService, orderService, commentService } from '@/service/localService'
|
||||
|
||||
export const useSkillStore = defineStore('skill', {
|
||||
state: () => ({
|
||||
skills: [],
|
||||
categories: [],
|
||||
currentSkill: null,
|
||||
searchResults: [],
|
||||
filters: {
|
||||
keyword: '',
|
||||
categoryId: null,
|
||||
priceType: null,
|
||||
minPrice: undefined,
|
||||
maxPrice: undefined,
|
||||
minRating: undefined,
|
||||
sortBy: 'default'
|
||||
},
|
||||
loading: false
|
||||
}),
|
||||
|
||||
getters: {
|
||||
featuredSkills: (state) => state.skills.filter(s => s.isFeatured && s.status === 'active'),
|
||||
hotSkills: (state) => state.skills.filter(s => s.isHot && s.status === 'active'),
|
||||
newSkills: (state) => state.skills.filter(s => s.isNew && s.status === 'active'),
|
||||
freeSkills: (state) => state.skills.filter(s => s.price === 0 && s.status === 'active'),
|
||||
paidSkills: (state) => state.skills.filter(s => s.price > 0 && s.status === 'active')
|
||||
},
|
||||
|
||||
actions: {
|
||||
loadSkills() {
|
||||
this.skills = skillService.getAllSkills()
|
||||
this.categories = skillService.getCategories()
|
||||
},
|
||||
|
||||
loadSkillById(skillId) {
|
||||
this.currentSkill = skillService.getSkillById(skillId)
|
||||
return this.currentSkill
|
||||
},
|
||||
|
||||
searchSkills(keyword, filters = {}) {
|
||||
this.filters = { ...this.filters, keyword, ...filters }
|
||||
this.searchResults = skillService.searchSkills(keyword, this.filters)
|
||||
return this.searchResults
|
||||
},
|
||||
|
||||
setFilters(filters) {
|
||||
this.filters = { ...this.filters, ...filters }
|
||||
this.searchResults = skillService.searchSkills(this.filters.keyword, this.filters)
|
||||
},
|
||||
|
||||
clearFilters() {
|
||||
this.filters = {
|
||||
keyword: '',
|
||||
categoryId: null,
|
||||
priceType: null,
|
||||
minPrice: undefined,
|
||||
maxPrice: undefined,
|
||||
minRating: undefined,
|
||||
sortBy: 'default'
|
||||
}
|
||||
this.searchResults = []
|
||||
},
|
||||
|
||||
getSkillsByCategory(categoryId) {
|
||||
return skillService.getSkillsByCategory(categoryId)
|
||||
},
|
||||
|
||||
getComments(skillId) {
|
||||
return commentService.getCommentsBySkillId(skillId)
|
||||
},
|
||||
|
||||
addComment(userId, skillId, rating, content, images) {
|
||||
const result = commentService.addComment(userId, skillId, rating, content, images)
|
||||
if (result.success) {
|
||||
this.loadSkillById(skillId)
|
||||
}
|
||||
return result
|
||||
},
|
||||
|
||||
likeComment(commentId) {
|
||||
return commentService.likeComment(commentId)
|
||||
},
|
||||
|
||||
hasUserPurchased(userId, skillId) {
|
||||
const purchasedSkills = orderService.getUserPurchasedSkills(userId)
|
||||
return purchasedSkills.some(s => s.id === skillId)
|
||||
}
|
||||
}
|
||||
})
|
||||
126
frontend/src/stores/user.js
Normal file
126
frontend/src/stores/user.js
Normal file
@@ -0,0 +1,126 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { userService, notificationService } from '@/service/localService'
|
||||
import { getData, STORAGE_KEYS } from '@/data/mockData'
|
||||
|
||||
export const useUserStore = defineStore('user', {
|
||||
state: () => ({
|
||||
user: null,
|
||||
isLoggedIn: false,
|
||||
notifications: [],
|
||||
unreadCount: 0
|
||||
}),
|
||||
|
||||
getters: {
|
||||
userInfo: (state) => state.user,
|
||||
userPoints: (state) => state.user?.points || 0,
|
||||
userLevel: (state) => state.user?.levelName || '普通会员',
|
||||
isVip: (state) => state.user?.isVip || false
|
||||
},
|
||||
|
||||
actions: {
|
||||
initUser() {
|
||||
const savedUser = getData(STORAGE_KEYS.CURRENT_USER)
|
||||
if (savedUser) {
|
||||
const users = getData(STORAGE_KEYS.USERS) || []
|
||||
const latestUser = users.find(u => u.id === savedUser.id)
|
||||
if (latestUser && latestUser.status === 'active') {
|
||||
this.user = latestUser
|
||||
this.isLoggedIn = true
|
||||
this.loadNotifications()
|
||||
} else {
|
||||
this.logout()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async login(phone, password) {
|
||||
const result = userService.login(phone, password)
|
||||
if (result.success) {
|
||||
this.user = result.data
|
||||
this.isLoggedIn = true
|
||||
this.loadNotifications()
|
||||
}
|
||||
return result
|
||||
},
|
||||
|
||||
async register(data) {
|
||||
const result = userService.register(data.phone, data.password, data.nickname, data.inviteCode)
|
||||
if (result.success) {
|
||||
this.user = result.data
|
||||
this.isLoggedIn = true
|
||||
this.loadNotifications()
|
||||
}
|
||||
return result
|
||||
},
|
||||
|
||||
logout() {
|
||||
userService.logout()
|
||||
this.user = null
|
||||
this.isLoggedIn = false
|
||||
this.notifications = []
|
||||
this.unreadCount = 0
|
||||
},
|
||||
|
||||
updateUserInfo(updates) {
|
||||
if (this.user) {
|
||||
const result = userService.updateUser(this.user.id, updates)
|
||||
if (result.success) {
|
||||
this.user = result.data
|
||||
}
|
||||
return result
|
||||
}
|
||||
return { success: false, message: '未登录' }
|
||||
},
|
||||
|
||||
refreshUser() {
|
||||
if (this.user) {
|
||||
const users = getData(STORAGE_KEYS.USERS) || []
|
||||
const latestUser = users.find(u => u.id === this.user.id)
|
||||
if (latestUser) {
|
||||
this.user = latestUser
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
loadNotifications() {
|
||||
if (this.user) {
|
||||
this.notifications = notificationService.getUserNotifications(this.user.id)
|
||||
this.unreadCount = notificationService.getUnreadCount(this.user.id)
|
||||
}
|
||||
},
|
||||
|
||||
markNotificationRead(notificationId) {
|
||||
notificationService.markAsRead(notificationId)
|
||||
this.loadNotifications()
|
||||
},
|
||||
|
||||
markAllNotificationsRead() {
|
||||
if (this.user) {
|
||||
notificationService.markAllAsRead(this.user.id)
|
||||
this.loadNotifications()
|
||||
}
|
||||
},
|
||||
|
||||
dailySign() {
|
||||
if (this.user) {
|
||||
const result = userService.dailySign(this.user.id)
|
||||
if (result.success) {
|
||||
this.refreshUser()
|
||||
}
|
||||
return result
|
||||
}
|
||||
return { success: false, message: '未登录' }
|
||||
},
|
||||
|
||||
joinGroup() {
|
||||
if (this.user) {
|
||||
const result = userService.joinGroup(this.user.id)
|
||||
if (result.success) {
|
||||
this.refreshUser()
|
||||
}
|
||||
return result
|
||||
}
|
||||
return { success: false, message: '未登录' }
|
||||
}
|
||||
}
|
||||
})
|
||||
218
frontend/src/styles/index.scss
Normal file
218
frontend/src/styles/index.scss
Normal file
@@ -0,0 +1,218 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
outline: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.clearfix::after {
|
||||
content: '';
|
||||
display: table;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.text-ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.text-ellipsis-2 {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.text-ellipsis-3 {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.flex-between {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.flex-wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.primary-color {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.success-color {
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.warning-color {
|
||||
color: #e6a23c;
|
||||
}
|
||||
|
||||
.danger-color {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.info-color {
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 16px;
|
||||
padding-left: 12px;
|
||||
border-left: 3px solid #409eff;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.empty-state .el-empty {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-fade-enter-active {
|
||||
transition: all 0.3s ease-out;
|
||||
}
|
||||
|
||||
.slide-fade-leave-active {
|
||||
transition: all 0.2s cubic-bezier(1, 0.5, 0.8, 1);
|
||||
}
|
||||
|
||||
.slide-fade-enter-from,
|
||||
.slide-fade-leave-to {
|
||||
transform: translateX(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.el-button--primary {
|
||||
--el-button-bg-color: #409eff;
|
||||
--el-button-border-color: #409eff;
|
||||
}
|
||||
|
||||
.el-button--success {
|
||||
--el-button-bg-color: #67c23a;
|
||||
--el-button-border-color: #67c23a;
|
||||
}
|
||||
|
||||
.el-button--warning {
|
||||
--el-button-bg-color: #e6a23c;
|
||||
--el-button-border-color: #e6a23c;
|
||||
}
|
||||
|
||||
.el-button--danger {
|
||||
--el-button-bg-color: #f56c6c;
|
||||
--el-button-border-color: #f56c6c;
|
||||
}
|
||||
|
||||
.el-tag--success {
|
||||
--el-tag-bg-color: #f0f9eb;
|
||||
--el-tag-border-color: #e1f3d8;
|
||||
--el-tag-text-color: #67c23a;
|
||||
}
|
||||
|
||||
.el-tag--warning {
|
||||
--el-tag-bg-color: #fdf6ec;
|
||||
--el-tag-border-color: #faecd8;
|
||||
--el-tag-text-color: #e6a23c;
|
||||
}
|
||||
|
||||
.el-tag--danger {
|
||||
--el-tag-bg-color: #fef0f0;
|
||||
--el-tag-border-color: #fde2e2;
|
||||
--el-tag-text-color: #f56c6c;
|
||||
}
|
||||
|
||||
.el-tag--info {
|
||||
--el-tag-bg-color: #f4f4f5;
|
||||
--el-tag-border-color: #e9e9eb;
|
||||
--el-tag-text-color: #909399;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
145
frontend/src/views/admin/comments.vue
Normal file
145
frontend/src/views/admin/comments.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<div class="admin-comments-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">评论管理</h2>
|
||||
<div class="header-actions">
|
||||
<el-select v-model="statusFilter" placeholder="状态筛选" clearable style="width: 120px">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="正常" value="active" />
|
||||
<el-option label="已删除" value="deleted" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="filteredComments" style="width: 100%" v-loading="loading">
|
||||
<el-table-column label="用户" width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="user-cell">
|
||||
<el-avatar :size="32" :src="row.userAvatar">{{ row.userName?.charAt(0) }}</el-avatar>
|
||||
<span>{{ row.userName }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Skill" width="150">
|
||||
<template #default="{ row }">
|
||||
{{ getSkillName(row.skillId) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="rating" label="评分" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-rate v-model="row.rating" disabled size="small" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="content" label="内容" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="content-cell">{{ row.content }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="likes" label="点赞" width="80" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 'active' ? '正常' : '已删除' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="时间" width="180" />
|
||||
<el-table-column label="操作" fixed="right" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.status === 'active'"
|
||||
text
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="deleteComment(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else
|
||||
text
|
||||
type="success"
|
||||
size="small"
|
||||
@click="restoreComment(row)"
|
||||
>
|
||||
恢复
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useAdminStore, useSkillStore } from '@/stores'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const adminStore = useAdminStore()
|
||||
const skillStore = useSkillStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const statusFilter = ref('')
|
||||
|
||||
const comments = computed(() => adminStore.comments)
|
||||
|
||||
const filteredComments = computed(() => {
|
||||
if (!statusFilter.value) return comments.value
|
||||
return comments.value.filter(c => c.status === statusFilter.value)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
adminStore.loadComments()
|
||||
skillStore.loadSkills()
|
||||
})
|
||||
|
||||
const getSkillName = (skillId) => {
|
||||
const skill = skillStore.skills.find(s => s.id === skillId)
|
||||
return skill?.name || '未知'
|
||||
}
|
||||
|
||||
const deleteComment = (comment) => {
|
||||
ElMessageBox.confirm('确定要删除该评论吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
adminStore.deleteComment(comment.id)
|
||||
ElMessage.success('已删除')
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const restoreComment = (comment) => {
|
||||
adminStore.restoreComment(comment.id)
|
||||
ElMessage.success('已恢复')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.admin-comments-page {
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
|
||||
.user-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.content-cell {
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
329
frontend/src/views/admin/dashboard.vue
Normal file
329
frontend/src/views/admin/dashboard.vue
Normal file
@@ -0,0 +1,329 @@
|
||||
<template>
|
||||
<div class="dashboard-page">
|
||||
<h2 class="page-title">控制台</h2>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" style="background: #409eff">
|
||||
<el-icon :size="28"><User /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{{ stats.totalUsers }}</span>
|
||||
<span class="stat-label">用户总数</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" style="background: #67c23a">
|
||||
<el-icon :size="28"><Grid /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{{ stats.totalSkills }}</span>
|
||||
<span class="stat-label">Skill总数</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" style="background: #e6a23c">
|
||||
<el-icon :size="28"><Document /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{{ stats.totalOrders }}</span>
|
||||
<span class="stat-label">订单总数</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" style="background: #f56c6c">
|
||||
<el-icon :size="28"><Coin /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">¥{{ stats.totalRevenue }}</span>
|
||||
<span class="stat-label">总收入</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-row">
|
||||
<div class="dashboard-card">
|
||||
<div class="card-header">
|
||||
<h3>今日数据</h3>
|
||||
</div>
|
||||
<div class="today-stats">
|
||||
<div class="today-item">
|
||||
<span class="value">{{ stats.todayNewUsers }}</span>
|
||||
<span class="label">新增用户</span>
|
||||
</div>
|
||||
<div class="today-item">
|
||||
<span class="value">{{ stats.todayOrders }}</span>
|
||||
<span class="label">新增订单</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-card">
|
||||
<div class="card-header">
|
||||
<h3>积分统计</h3>
|
||||
</div>
|
||||
<div class="point-stats">
|
||||
<div class="point-item">
|
||||
<span class="label">累计发放</span>
|
||||
<span class="value">{{ stats.totalPointsIssued }}</span>
|
||||
</div>
|
||||
<div class="point-item">
|
||||
<span class="label">累计消耗</span>
|
||||
<span class="value">{{ stats.totalPointsConsumed }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-card">
|
||||
<div class="card-header">
|
||||
<h3>最新订单</h3>
|
||||
<el-button text type="primary" @click="$router.push('/admin/orders')">查看全部</el-button>
|
||||
</div>
|
||||
<el-table :data="recentOrders" style="width: 100%">
|
||||
<el-table-column prop="id" label="订单号" width="180" />
|
||||
<el-table-column prop="skillName" label="Skill名称" />
|
||||
<el-table-column prop="price" label="金额">
|
||||
<template #default="{ row }">
|
||||
¥{{ row.price }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.status)" size="small">
|
||||
{{ getStatusText(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" width="180" />
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-card">
|
||||
<div class="card-header">
|
||||
<h3>热门Skill</h3>
|
||||
<el-button text type="primary" @click="$router.push('/admin/skills')">查看全部</el-button>
|
||||
</div>
|
||||
<el-table :data="hotSkills" style="width: 100%">
|
||||
<el-table-column label="Skill" width="300">
|
||||
<template #default="{ row }">
|
||||
<div class="skill-cell">
|
||||
<img :src="row.cover" class="skill-cover" />
|
||||
<span>{{ row.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="downloadCount" label="下载量" />
|
||||
<el-table-column prop="rating" label="评分" />
|
||||
<el-table-column prop="price" label="价格">
|
||||
<template #default="{ row }">
|
||||
{{ row.price === 0 ? '免费' : '¥' + row.price }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useAdminStore, useSkillStore } from '@/stores'
|
||||
|
||||
const adminStore = useAdminStore()
|
||||
const skillStore = useSkillStore()
|
||||
|
||||
const stats = ref({
|
||||
totalUsers: 0,
|
||||
totalSkills: 0,
|
||||
totalOrders: 0,
|
||||
totalRevenue: 0,
|
||||
todayNewUsers: 0,
|
||||
todayOrders: 0,
|
||||
totalPointsIssued: 0,
|
||||
totalPointsConsumed: 0
|
||||
})
|
||||
|
||||
const recentOrders = ref([])
|
||||
const hotSkills = ref([])
|
||||
|
||||
onMounted(() => {
|
||||
stats.value = adminStore.loadDashboardStats()
|
||||
adminStore.loadOrders()
|
||||
recentOrders.value = adminStore.orders.slice(0, 5)
|
||||
|
||||
skillStore.loadSkills()
|
||||
hotSkills.value = [...skillStore.skills]
|
||||
.filter(s => s.status === 'active')
|
||||
.sort((a, b) => b.downloadCount - a.downloadCount)
|
||||
.slice(0, 5)
|
||||
})
|
||||
|
||||
const getStatusType = (status) => {
|
||||
const types = {
|
||||
pending: 'warning',
|
||||
completed: 'success',
|
||||
cancelled: 'info',
|
||||
refunded: 'danger'
|
||||
}
|
||||
return types[status] || 'info'
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const texts = {
|
||||
pending: '待支付',
|
||||
completed: '已完成',
|
||||
cancelled: '已取消',
|
||||
refunded: '已退款'
|
||||
}
|
||||
return texts[status] || status
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dashboard-page {
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
color: #303133;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.dashboard-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
|
||||
.today-stats {
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
|
||||
.today-item {
|
||||
.value {
|
||||
display: block;
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.point-stats {
|
||||
.point-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.skill-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.skill-cover {
|
||||
width: 40px;
|
||||
height: 30px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.dashboard-page {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-page {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
172
frontend/src/views/admin/login.vue
Normal file
172
frontend/src/views/admin/login.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<div class="admin-login-page">
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<el-icon :size="48" color="#409eff"><Setting /></el-icon>
|
||||
<h2>管理后台</h2>
|
||||
<p>OpenClaw Skills 管理系统</p>
|
||||
</div>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" @submit.prevent="handleLogin">
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
placeholder="请输入用户名"
|
||||
size="large"
|
||||
prefix-icon="User"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
prefix-icon="Lock"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
@click="handleLogin"
|
||||
style="width: 100%"
|
||||
>
|
||||
登录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="demo-accounts">
|
||||
<el-divider>演示账号</el-divider>
|
||||
<div class="account-list">
|
||||
<div class="account-item" @click="fillDemo('admin', 'admin123')">
|
||||
<span>超级管理员</span>
|
||||
<span>admin / admin123</span>
|
||||
</div>
|
||||
<div class="account-item" @click="fillDemo('operator', 'operator123')">
|
||||
<span>运营管理员</span>
|
||||
<span>operator / operator123</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAdminStore } from '@/stores'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const adminStore = useAdminStore()
|
||||
|
||||
const formRef = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const rules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
loading.value = true
|
||||
const result = adminStore.login(form.username, form.password)
|
||||
loading.value = false
|
||||
|
||||
if (result.success) {
|
||||
sessionStorage.setItem('admin_user', JSON.stringify(result.data))
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/admin')
|
||||
} else {
|
||||
ElMessage.error(result.message)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const fillDemo = (username, password) => {
|
||||
form.username = username
|
||||
form.password = password
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.admin-login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #001529 0%, #003a70 100%);
|
||||
|
||||
.login-container {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
color: #303133;
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
.demo-accounts {
|
||||
margin-top: 24px;
|
||||
|
||||
.account-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.account-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #e6e8eb;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
190
frontend/src/views/admin/orders.vue
Normal file
190
frontend/src/views/admin/orders.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<div class="admin-orders-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">订单管理</h2>
|
||||
<div class="header-actions">
|
||||
<el-select v-model="statusFilter" placeholder="状态筛选" clearable style="width: 120px">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="待支付" value="pending" />
|
||||
<el-option label="已完成" value="completed" />
|
||||
<el-option label="已取消" value="cancelled" />
|
||||
<el-option label="已退款" value="refunded" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="filteredOrders" style="width: 100%" v-loading="loading">
|
||||
<el-table-column prop="id" label="订单号" width="180" />
|
||||
<el-table-column label="Skill" width="250">
|
||||
<template #default="{ row }">
|
||||
<div class="skill-cell">
|
||||
<img :src="row.skillCover" class="skill-cover" />
|
||||
<span>{{ row.skillName }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="price" label="金额" width="100">
|
||||
<template #default="{ row }">
|
||||
¥{{ row.price }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="payType" label="支付方式" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ getPayTypeText(row.payType) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.status)" size="small">
|
||||
{{ getStatusText(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" width="180" />
|
||||
<el-table-column label="操作" fixed="right" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" size="small" @click="viewOrder(row)">详情</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 'completed'"
|
||||
text
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="refundOrder(row)"
|
||||
>
|
||||
退款
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="orderDialogVisible" title="订单详情" width="500px">
|
||||
<template v-if="currentOrder">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="订单号">{{ currentOrder.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Skill名称">{{ currentOrder.skillName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="订单金额">¥{{ currentOrder.price }}</el-descriptions-item>
|
||||
<el-descriptions-item label="支付方式">{{ getPayTypeText(currentOrder.payType) }}</el-descriptions-item>
|
||||
<el-descriptions-item v-if="currentOrder.paidPoints" label="支付积分">
|
||||
{{ currentOrder.paidPoints }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item v-if="currentOrder.paidAmount" label="支付金额">
|
||||
¥{{ currentOrder.paidAmount }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="订单状态">
|
||||
<el-tag :type="getStatusType(currentOrder.status)" size="small">
|
||||
{{ getStatusText(currentOrder.status) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ currentOrder.createdAt }}</el-descriptions-item>
|
||||
<el-descriptions-item v-if="currentOrder.paidAt" label="支付时间">
|
||||
{{ currentOrder.paidAt }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item v-if="currentOrder.completedAt" label="完成时间">
|
||||
{{ currentOrder.completedAt }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useAdminStore, useOrderStore } from '@/stores'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const adminStore = useAdminStore()
|
||||
const orderStore = useOrderStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const statusFilter = ref('')
|
||||
const orderDialogVisible = ref(false)
|
||||
const currentOrder = ref(null)
|
||||
|
||||
const orders = computed(() => adminStore.orders)
|
||||
|
||||
const filteredOrders = computed(() => {
|
||||
if (!statusFilter.value) return orders.value
|
||||
return orders.value.filter(o => o.status === statusFilter.value)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
adminStore.loadOrders()
|
||||
})
|
||||
|
||||
const getStatusType = (status) => {
|
||||
const types = {
|
||||
pending: 'warning',
|
||||
completed: 'success',
|
||||
cancelled: 'info',
|
||||
refunded: 'danger'
|
||||
}
|
||||
return types[status] || 'info'
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const texts = {
|
||||
pending: '待支付',
|
||||
completed: '已完成',
|
||||
cancelled: '已取消',
|
||||
refunded: '已退款'
|
||||
}
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
const getPayTypeText = (payType) => {
|
||||
const texts = {
|
||||
points: '积分',
|
||||
cash: '现金',
|
||||
mixed: '混合',
|
||||
free: '免费'
|
||||
}
|
||||
return texts[payType] || payType
|
||||
}
|
||||
|
||||
const viewOrder = (order) => {
|
||||
currentOrder.value = order
|
||||
orderDialogVisible.value = true
|
||||
}
|
||||
|
||||
const refundOrder = (order) => {
|
||||
ElMessageBox.confirm(`确定要退款订单 ${order.id} 吗?`, '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
orderStore.refundOrder(order.id, '管理员操作退款')
|
||||
adminStore.loadOrders()
|
||||
ElMessage.success('已退款')
|
||||
}).catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.admin-orders-page {
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
|
||||
.skill-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.skill-cover {
|
||||
width: 50px;
|
||||
height: 38px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
184
frontend/src/views/admin/points.vue
Normal file
184
frontend/src/views/admin/points.vue
Normal file
@@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<div class="admin-points-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">积分管理</h2>
|
||||
</div>
|
||||
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<span class="label">累计发放</span>
|
||||
<span class="value">{{ stats.totalIssued }}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="label">累计消耗</span>
|
||||
<span class="value">{{ stats.totalConsumed }}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="label">流通中</span>
|
||||
<span class="value">{{ stats.inCirculation }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<el-select v-model="typeFilter" placeholder="类型" clearable style="width: 120px">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="收入" value="income" />
|
||||
<el-option label="支出" value="expense" />
|
||||
</el-select>
|
||||
<el-select v-model="sourceFilter" placeholder="来源" clearable style="width: 150px">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="注册奖励" value="register" />
|
||||
<el-option label="签到" value="signin" />
|
||||
<el-option label="邀请" value="invite" />
|
||||
<el-option label="充值" value="recharge" />
|
||||
<el-option label="购买" value="purchase" />
|
||||
<el-option label="退款" value="refund" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<el-table :data="filteredRecords" style="width: 100%" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column label="用户" width="150">
|
||||
<template #default="{ row }">
|
||||
{{ getUserName(row.userId) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="type" label="类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.type === 'income' ? 'success' : 'danger'" size="small">
|
||||
{{ row.type === 'income' ? '收入' : '支出' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="amount" label="金额" width="100">
|
||||
<template #default="{ row }">
|
||||
<span :class="row.type">{{ row.type === 'income' ? '+' : '-' }}{{ row.amount }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="source" label="来源" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ getSourceText(row.source) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="描述" min-width="200" />
|
||||
<el-table-column prop="balance" label="余额" width="100" />
|
||||
<el-table-column prop="createdAt" label="时间" width="180" />
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useAdminStore, usePointStore } from '@/stores'
|
||||
|
||||
const adminStore = useAdminStore()
|
||||
const pointStore = usePointStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const typeFilter = ref('')
|
||||
const sourceFilter = ref('')
|
||||
|
||||
const records = computed(() => pointStore.records)
|
||||
|
||||
const stats = computed(() => {
|
||||
const totalIssued = records.value
|
||||
.filter(r => r.type === 'income')
|
||||
.reduce((sum, r) => sum + r.amount, 0)
|
||||
const totalConsumed = records.value
|
||||
.filter(r => r.type === 'expense')
|
||||
.reduce((sum, r) => sum + r.amount, 0)
|
||||
return {
|
||||
totalIssued,
|
||||
totalConsumed,
|
||||
inCirculation: totalIssued - totalConsumed
|
||||
}
|
||||
})
|
||||
|
||||
const filteredRecords = computed(() => {
|
||||
let result = records.value
|
||||
if (typeFilter.value) {
|
||||
result = result.filter(r => r.type === typeFilter.value)
|
||||
}
|
||||
if (sourceFilter.value) {
|
||||
result = result.filter(r => r.source === sourceFilter.value)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
pointStore.loadAllRecords()
|
||||
adminStore.loadUsers()
|
||||
})
|
||||
|
||||
const getUserName = (userId) => {
|
||||
const user = adminStore.users.find(u => u.id === userId)
|
||||
return user?.nickname || `用户${userId}`
|
||||
}
|
||||
|
||||
const getSourceText = (source) => {
|
||||
const texts = {
|
||||
register: '注册奖励',
|
||||
signin: '签到',
|
||||
invite: '邀请',
|
||||
group: '加群',
|
||||
recharge: '充值',
|
||||
purchase: '购买',
|
||||
refund: '退款',
|
||||
review: '评价'
|
||||
}
|
||||
return texts[source] || source
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.admin-points-page {
|
||||
.page-header {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.stat-card {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.label {
|
||||
color: #909399;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.income {
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.expense {
|
||||
color: #f56c6c;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
178
frontend/src/views/admin/settings.vue
Normal file
178
frontend/src/views/admin/settings.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<h2 class="page-title">系统设置</h2>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>基础设置</h3>
|
||||
<el-form :model="settings" label-width="120px">
|
||||
<el-form-item label="网站名称">
|
||||
<el-input v-model="settings.siteName" />
|
||||
</el-form-item>
|
||||
<el-form-item label="网站描述">
|
||||
<el-input v-model="settings.siteDescription" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>积分规则</h3>
|
||||
<el-form :model="settings.pointRules" label-width="120px">
|
||||
<el-form-item label="注册奖励">
|
||||
<el-input-number v-model="settings.pointRules.register" :min="0" />
|
||||
<span class="unit">积分</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="每日签到">
|
||||
<el-input-number v-model="settings.pointRules.dailySign" :min="0" />
|
||||
<span class="unit">积分</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="邀请奖励">
|
||||
<el-input-number v-model="settings.pointRules.invite" :min="0" />
|
||||
<span class="unit">积分</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="加群奖励">
|
||||
<el-input-number v-model="settings.pointRules.joinGroup" :min="0" />
|
||||
<span class="unit">积分</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="评价奖励">
|
||||
<el-input-number v-model="settings.pointRules.review" :min="0" />
|
||||
<span class="unit">积分</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>充值档位</h3>
|
||||
<div class="recharge-tiers">
|
||||
<div v-for="(tier, index) in settings.rechargeTiers" :key="index" class="tier-item">
|
||||
<span>充值 ¥{{ tier.amount }}</span>
|
||||
<span>赠送 {{ tier.bonus }} 积分</span>
|
||||
<el-button text type="danger" size="small" @click="removeTier(index)">删除</el-button>
|
||||
</div>
|
||||
<el-button type="primary" text @click="addTier">+ 添加档位</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>会员等级规则</h3>
|
||||
<el-table :data="settings.levelRules" style="width: 100%">
|
||||
<el-table-column prop="level" label="等级" width="80" />
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column prop="minGrowth" label="最低成长值" />
|
||||
<el-table-column prop="maxGrowth" label="最高成长值" />
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="settings-actions">
|
||||
<el-button type="primary" @click="saveSettings">保存设置</el-button>
|
||||
<el-button @click="resetSettings">重置默认</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useAdminStore } from '@/stores'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const adminStore = useAdminStore()
|
||||
|
||||
const settings = reactive({
|
||||
siteName: 'OpenClaw Skills',
|
||||
siteDescription: '数字员工交易平台',
|
||||
pointRules: {
|
||||
register: 300,
|
||||
dailySign: 10,
|
||||
invite: 100,
|
||||
joinGroup: 50,
|
||||
review: 10
|
||||
},
|
||||
rechargeTiers: [
|
||||
{ amount: 10, bonus: 10 },
|
||||
{ amount: 50, bonus: 60 },
|
||||
{ amount: 100, bonus: 150 },
|
||||
{ amount: 500, bonus: 800 },
|
||||
{ amount: 1000, bonus: 2000 }
|
||||
],
|
||||
levelRules: [
|
||||
{ level: 0, name: '普通会员', minGrowth: 0, maxGrowth: 499 },
|
||||
{ level: 1, name: '白银会员', minGrowth: 500, maxGrowth: 1999 },
|
||||
{ level: 2, name: '黄金会员', minGrowth: 2000, maxGrowth: 4999 },
|
||||
{ level: 3, name: '钻石会员', minGrowth: 5000, maxGrowth: 99999 }
|
||||
]
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const config = adminStore.loadSystemConfig()
|
||||
if (config) {
|
||||
Object.assign(settings, config)
|
||||
}
|
||||
})
|
||||
|
||||
const addTier = () => {
|
||||
settings.rechargeTiers.push({ amount: 100, bonus: 100 })
|
||||
}
|
||||
|
||||
const removeTier = (index) => {
|
||||
settings.rechargeTiers.splice(index, 1)
|
||||
}
|
||||
|
||||
const saveSettings = () => {
|
||||
adminStore.updateSystemConfig({ ...settings })
|
||||
ElMessage.success('设置已保存')
|
||||
}
|
||||
|
||||
const resetSettings = () => {
|
||||
ElMessage.info('已重置为默认设置')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.settings-page {
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
color: #303133;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
color: #303133;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.unit {
|
||||
margin-left: 8px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.recharge-tiers {
|
||||
.tier-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 12px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
span {
|
||||
color: #606266;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
210
frontend/src/views/admin/skills.vue
Normal file
210
frontend/src/views/admin/skills.vue
Normal file
@@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<div class="admin-skills-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">Skill管理</h2>
|
||||
<div class="header-actions">
|
||||
<el-select v-model="statusFilter" placeholder="状态筛选" clearable style="width: 120px">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="已上架" value="active" />
|
||||
<el-option label="待审核" value="pending" />
|
||||
<el-option label="已下架" value="inactive" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="filteredSkills" style="width: 100%" v-loading="loading">
|
||||
<el-table-column label="Skill" width="300">
|
||||
<template #default="{ row }">
|
||||
<div class="skill-cell">
|
||||
<img :src="row.cover" class="skill-cover" />
|
||||
<div class="skill-info">
|
||||
<span class="name">{{ row.name }}</span>
|
||||
<span class="author">{{ row.author }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="categoryName" label="分类" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ getCategoryName(row.categoryId) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="price" label="价格" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ row.price === 0 ? '免费' : '¥' + row.price }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="downloadCount" label="下载量" width="100" />
|
||||
<el-table-column prop="rating" label="评分" width="80" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.status)" size="small">
|
||||
{{ getStatusText(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" width="180" />
|
||||
<el-table-column label="操作" fixed="right" width="250">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" size="small" @click="viewSkill(row)">查看</el-button>
|
||||
<template v-if="row.status === 'pending'">
|
||||
<el-button text type="success" size="small" @click="approveSkill(row)">通过</el-button>
|
||||
<el-button text type="danger" size="small" @click="rejectSkill(row)">拒绝</el-button>
|
||||
</template>
|
||||
<template v-else-if="row.status === 'active'">
|
||||
<el-button text type="warning" size="small" @click="toggleFeatured(row)">
|
||||
{{ row.isFeatured ? '取消推荐' : '推荐' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="skillDialogVisible" title="Skill详情" width="600px">
|
||||
<template v-if="currentSkill">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="Skill名称" :span="2">{{ currentSkill.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="作者">{{ currentSkill.author }}</el-descriptions-item>
|
||||
<el-descriptions-item label="版本">{{ currentSkill.version }}</el-descriptions-item>
|
||||
<el-descriptions-item label="价格">
|
||||
{{ currentSkill.price === 0 ? '免费' : '¥' + currentSkill.price }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="积分价格">{{ currentSkill.pointPrice }}积分</el-descriptions-item>
|
||||
<el-descriptions-item label="下载量">{{ currentSkill.downloadCount }}</el-descriptions-item>
|
||||
<el-descriptions-item label="评分">{{ currentSkill.rating }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="getStatusType(currentSkill.status)" size="small">
|
||||
{{ getStatusText(currentSkill.status) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ currentSkill.createdAt }}</el-descriptions-item>
|
||||
<el-descriptions-item label="描述" :span="2">{{ currentSkill.description }}</el-descriptions-item>
|
||||
<el-descriptions-item label="标签" :span="2">
|
||||
<el-tag v-for="tag in currentSkill.tags" :key="tag" style="margin-right: 4px">{{ tag }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useAdminStore, useSkillStore } from '@/stores'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const adminStore = useAdminStore()
|
||||
const skillStore = useSkillStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const statusFilter = ref('')
|
||||
const skillDialogVisible = ref(false)
|
||||
const currentSkill = ref(null)
|
||||
|
||||
const skills = computed(() => adminStore.skills)
|
||||
|
||||
const filteredSkills = computed(() => {
|
||||
if (!statusFilter.value) return skills.value
|
||||
return skills.value.filter(s => s.status === statusFilter.value)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
adminStore.loadSkills()
|
||||
})
|
||||
|
||||
const getCategoryName = (categoryId) => {
|
||||
const categories = skillStore.categories
|
||||
const cat = categories.find(c => c.id === categoryId)
|
||||
return cat?.name || '未分类'
|
||||
}
|
||||
|
||||
const getStatusType = (status) => {
|
||||
const types = {
|
||||
active: 'success',
|
||||
pending: 'warning',
|
||||
inactive: 'info',
|
||||
rejected: 'danger'
|
||||
}
|
||||
return types[status] || 'info'
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const texts = {
|
||||
active: '已上架',
|
||||
pending: '待审核',
|
||||
inactive: '已下架',
|
||||
rejected: '已拒绝'
|
||||
}
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
const viewSkill = (skill) => {
|
||||
currentSkill.value = skill
|
||||
skillDialogVisible.value = true
|
||||
}
|
||||
|
||||
const approveSkill = (skill) => {
|
||||
adminStore.approveSkill(skill.id)
|
||||
ElMessage.success('已通过审核')
|
||||
}
|
||||
|
||||
const rejectSkill = (skill) => {
|
||||
ElMessageBox.prompt('请输入拒绝原因', '拒绝审核', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
inputPattern: /\S+/,
|
||||
inputErrorMessage: '请输入拒绝原因'
|
||||
}).then(({ value }) => {
|
||||
adminStore.rejectSkill(skill.id, value)
|
||||
ElMessage.success('已拒绝')
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const toggleFeatured = (skill) => {
|
||||
skillStore.setFeatured(skill.id, !skill.isFeatured)
|
||||
adminStore.loadSkills()
|
||||
ElMessage.success(skill.isFeatured ? '已取消推荐' : '已设为推荐')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.admin-skills-page {
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
|
||||
.skill-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.skill-cover {
|
||||
width: 60px;
|
||||
height: 45px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.skill-info {
|
||||
.name {
|
||||
display: block;
|
||||
color: #303133;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.author {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
191
frontend/src/views/admin/statistics.vue
Normal file
191
frontend/src/views/admin/statistics.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<div class="statistics-page">
|
||||
<h2 class="page-title">数据统计</h2>
|
||||
|
||||
<div class="chart-row">
|
||||
<div class="chart-card">
|
||||
<h3>用户增长趋势</h3>
|
||||
<div class="chart-placeholder">
|
||||
<el-icon :size="48" color="#409eff"><TrendCharts /></el-icon>
|
||||
<p>用户增长数据图表</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<h3>订单趋势</h3>
|
||||
<div class="chart-placeholder">
|
||||
<el-icon :size="48" color="#67c23a"><DataAnalysis /></el-icon>
|
||||
<p>订单数据图表</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-row">
|
||||
<div class="chart-card">
|
||||
<h3>收入统计</h3>
|
||||
<div class="chart-placeholder">
|
||||
<el-icon :size="48" color="#e6a23c"><Coin /></el-icon>
|
||||
<p>收入数据图表</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<h3>Skill下载排行</h3>
|
||||
<div class="ranking-list">
|
||||
<div v-for="(skill, index) in topSkills" :key="skill.id" class="ranking-item">
|
||||
<span class="rank" :class="'rank-' + (index + 1)">{{ index + 1 }}</span>
|
||||
<span class="name">{{ skill.name }}</span>
|
||||
<span class="count">{{ skill.downloadCount }}次</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-card full-width">
|
||||
<h3>数据概览</h3>
|
||||
<el-table :data="overviewData" style="width: 100%">
|
||||
<el-table-column prop="metric" label="指标" />
|
||||
<el-table-column prop="today" label="今日" />
|
||||
<el-table-column prop="week" label="本周" />
|
||||
<el-table-column prop="month" label="本月" />
|
||||
<el-table-column prop="total" label="累计" />
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useAdminStore, useSkillStore } from '@/stores'
|
||||
|
||||
const adminStore = useAdminStore()
|
||||
const skillStore = useSkillStore()
|
||||
|
||||
const topSkills = computed(() => {
|
||||
return [...skillStore.skills]
|
||||
.filter(s => s.status === 'active')
|
||||
.sort((a, b) => b.downloadCount - a.downloadCount)
|
||||
.slice(0, 10)
|
||||
})
|
||||
|
||||
const overviewData = computed(() => {
|
||||
const stats = adminStore.dashboardStats || {}
|
||||
return [
|
||||
{ metric: '新增用户', today: stats.todayNewUsers || 0, week: 45, month: 180, total: stats.totalUsers || 0 },
|
||||
{ metric: '新增订单', today: stats.todayOrders || 0, week: 89, month: 356, total: stats.totalOrders || 0 },
|
||||
{ metric: '交易金额', today: '¥1,280', week: '¥8,560', month: '¥32,400', total: '¥' + (stats.totalRevenue || 0) },
|
||||
{ metric: 'Skill下载', today: 156, week: 892, month: 3456, total: 12580 }
|
||||
]
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
adminStore.loadDashboardStats()
|
||||
skillStore.loadSkills()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.statistics-page {
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
color: #303133;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.chart-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
|
||||
&.full-width {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
color: #303133;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.chart-placeholder {
|
||||
height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.ranking-list {
|
||||
.ranking-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.rank {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-right: 12px;
|
||||
background: #f0f0f0;
|
||||
color: #909399;
|
||||
|
||||
&.rank-1 {
|
||||
background: #ffd700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.rank-2 {
|
||||
background: #c0c0c0;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.rank-3 {
|
||||
background: #cd7f32;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.count {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.statistics-page {
|
||||
.chart-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.chart-card.full-width {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
172
frontend/src/views/admin/users.vue
Normal file
172
frontend/src/views/admin/users.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<div class="admin-users-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">用户管理</h2>
|
||||
<div class="header-actions">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索用户..."
|
||||
clearable
|
||||
style="width: 200px"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="filteredUsers" style="width: 100%" v-loading="loading">
|
||||
<el-table-column label="用户" width="250">
|
||||
<template #default="{ row }">
|
||||
<div class="user-cell">
|
||||
<el-avatar :size="40" :src="row.avatar">{{ row.nickname?.charAt(0) }}</el-avatar>
|
||||
<div class="user-info">
|
||||
<span class="name">{{ row.nickname }}</span>
|
||||
<span class="phone">{{ row.phone }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="levelName" label="等级" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="warning" size="small">{{ row.levelName }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="points" label="积分" width="100" />
|
||||
<el-table-column prop="inviteCount" label="邀请人数" width="100" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 'active' ? '正常' : '封禁' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="注册时间" width="180" />
|
||||
<el-table-column label="操作" fixed="right" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" size="small" @click="viewUser(row)">查看</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 'active'"
|
||||
text
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="banUser(row)"
|
||||
>
|
||||
封禁
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else
|
||||
text
|
||||
type="success"
|
||||
size="small"
|
||||
@click="unbanUser(row)"
|
||||
>
|
||||
解封
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="userDialogVisible" title="用户详情" width="500px">
|
||||
<template v-if="currentUser">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="用户ID">{{ currentUser.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="昵称">{{ currentUser.nickname }}</el-descriptions-item>
|
||||
<el-descriptions-item label="手机号">{{ currentUser.phone }}</el-descriptions-item>
|
||||
<el-descriptions-item label="邮箱">{{ currentUser.email || '未设置' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="会员等级">{{ currentUser.levelName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="积分余额">{{ currentUser.points }}</el-descriptions-item>
|
||||
<el-descriptions-item label="累计积分">{{ currentUser.totalPoints }}</el-descriptions-item>
|
||||
<el-descriptions-item label="邀请人数">{{ currentUser.inviteCount }}</el-descriptions-item>
|
||||
<el-descriptions-item label="邀请码">{{ currentUser.inviteCode }}</el-descriptions-item>
|
||||
<el-descriptions-item label="注册时间">{{ currentUser.createdAt }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最后登录">{{ currentUser.lastLoginAt }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useAdminStore } from '@/stores'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const adminStore = useAdminStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const searchKeyword = ref('')
|
||||
const userDialogVisible = ref(false)
|
||||
const currentUser = ref(null)
|
||||
|
||||
const users = computed(() => adminStore.users)
|
||||
|
||||
const filteredUsers = computed(() => {
|
||||
if (!searchKeyword.value) return users.value
|
||||
const keyword = searchKeyword.value.toLowerCase()
|
||||
return users.value.filter(u =>
|
||||
u.nickname?.toLowerCase().includes(keyword) ||
|
||||
u.phone?.includes(keyword)
|
||||
)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
adminStore.loadUsers()
|
||||
})
|
||||
|
||||
const viewUser = (user) => {
|
||||
currentUser.value = user
|
||||
userDialogVisible.value = true
|
||||
}
|
||||
|
||||
const banUser = (user) => {
|
||||
ElMessageBox.confirm(`确定要封禁用户 ${user.nickname} 吗?`, '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
adminStore.banUser(user.id)
|
||||
ElMessage.success('已封禁')
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const unbanUser = (user) => {
|
||||
adminStore.unbanUser(user.id)
|
||||
ElMessage.success('已解封')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.admin-users-page {
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
|
||||
.user-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.user-info {
|
||||
.name {
|
||||
display: block;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.phone {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
356
frontend/src/views/customize/index.vue
Normal file
356
frontend/src/views/customize/index.vue
Normal file
@@ -0,0 +1,356 @@
|
||||
<template>
|
||||
<div class="customize-page">
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Skill定制服务</h1>
|
||||
<p class="page-desc">为您提供专属的数字员工定制解决方案</p>
|
||||
</div>
|
||||
|
||||
<div class="customize-content">
|
||||
<div class="intro-section">
|
||||
<div class="intro-card">
|
||||
<el-icon :size="48" color="#409eff"><Setting /></el-icon>
|
||||
<h3>专业定制</h3>
|
||||
<p>根据您的业务场景,量身定制专属Skill</p>
|
||||
</div>
|
||||
<div class="intro-card">
|
||||
<el-icon :size="48" color="#67c23a"><User /></el-icon>
|
||||
<h3>一对一服务</h3>
|
||||
<p>专属工程师全程跟进,确保需求落地</p>
|
||||
</div>
|
||||
<div class="intro-card">
|
||||
<el-icon :size="48" color="#e6a23c"><Timer /></el-icon>
|
||||
<h3>快速交付</h3>
|
||||
<p>高效开发流程,缩短交付周期</p>
|
||||
</div>
|
||||
<div class="intro-card">
|
||||
<el-icon :size="48" color="#f56c6c"><Service /></el-icon>
|
||||
<h3>持续支持</h3>
|
||||
<p>完善的售后服务,保障稳定运行</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h2 class="section-title">填写定制需求</h2>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-width="100px"
|
||||
class="customize-form"
|
||||
>
|
||||
<el-form-item label="姓名" prop="name">
|
||||
<el-input v-model="form.name" placeholder="请输入您的姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="联系方式" prop="contact">
|
||||
<el-input v-model="form.contact" placeholder="请输入手机号或邮箱" />
|
||||
</el-form-item>
|
||||
<el-form-item label="公司名称" prop="company">
|
||||
<el-input v-model="form.company" placeholder="请输入公司名称(选填)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="行业类型" prop="industry">
|
||||
<el-select v-model="form.industry" placeholder="请选择行业类型" style="width: 100%">
|
||||
<el-option label="互联网/IT" value="internet" />
|
||||
<el-option label="金融/银行" value="finance" />
|
||||
<el-option label="教育/培训" value="education" />
|
||||
<el-option label="医疗/健康" value="medical" />
|
||||
<el-option label="零售/电商" value="retail" />
|
||||
<el-option label="制造/工业" value="manufacturing" />
|
||||
<el-option label="政府/公共事业" value="government" />
|
||||
<el-option label="其他" value="other" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="业务场景" prop="scenario">
|
||||
<el-input
|
||||
v-model="form.scenario"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="请详细描述您的业务场景和需求,例如: 1. 当前面临的问题或痛点 2. 希望实现的功能目标 3. 预期的使用场景和频率 4. 其他特殊要求"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="预期预算">
|
||||
<el-radio-group v-model="form.budget">
|
||||
<el-radio label="5000以下">5,000元以下</el-radio>
|
||||
<el-radio label="5000-10000">5,000-10,000元</el-radio>
|
||||
<el-radio label="10000-50000">10,000-50,000元</el-radio>
|
||||
<el-radio label="50000以上">50,000元以上</el-radio>
|
||||
<el-radio label="待定">待定</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" size="large" :loading="submitting" @click="handleSubmit">
|
||||
提交定制需求
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="successDialogVisible"
|
||||
title="提交成功"
|
||||
width="500px"
|
||||
:close-on-click-modal="false"
|
||||
class="success-dialog"
|
||||
>
|
||||
<div class="success-content">
|
||||
<div class="success-icon">
|
||||
<el-icon :size="64" color="#67c23a"><CircleCheckFilled /></el-icon>
|
||||
</div>
|
||||
<h3>您的定制需求已提交成功!</h3>
|
||||
<p class="success-desc">我们的工程师将在1-2个工作日内与您联系</p>
|
||||
|
||||
<div class="vip-group-section">
|
||||
<div class="vip-header">
|
||||
<el-icon :size="24" color="#ffd700"><Medal /></el-icon>
|
||||
<span class="vip-title">加入VIP服务群,享受专属服务</span>
|
||||
</div>
|
||||
<div class="vip-benefits">
|
||||
<div class="benefit-item">
|
||||
<el-icon color="#ffd700"><Star /></el-icon>
|
||||
<span>工程师一对一专属服务</span>
|
||||
</div>
|
||||
<div class="benefit-item">
|
||||
<el-icon color="#ffd700"><Star /></el-icon>
|
||||
<span>优先响应,快速对接</span>
|
||||
</div>
|
||||
<div class="benefit-item">
|
||||
<el-icon color="#ffd700"><Star /></el-icon>
|
||||
<span>定制进度实时跟踪</span>
|
||||
</div>
|
||||
<div class="benefit-item">
|
||||
<el-icon color="#ffd700"><Star /></el-icon>
|
||||
<span>专属技术顾问支持</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vip-qrcode">
|
||||
<img src="https://picsum.photos/200/200?random=vip" alt="VIP服务群二维码" class="qrcode-img" />
|
||||
<p class="qrcode-tip">扫码加入VIP服务群</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="successDialogVisible = false">我知道了</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const formRef = ref(null)
|
||||
const submitting = ref(false)
|
||||
const successDialogVisible = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
contact: '',
|
||||
company: '',
|
||||
industry: '',
|
||||
scenario: '',
|
||||
budget: '待定'
|
||||
})
|
||||
|
||||
const rules = {
|
||||
name: [
|
||||
{ required: true, message: '请输入您的姓名', trigger: 'blur' }
|
||||
],
|
||||
contact: [
|
||||
{ required: true, message: '请输入联系方式', trigger: 'blur' }
|
||||
],
|
||||
scenario: [
|
||||
{ required: true, message: '请描述您的业务场景', trigger: 'blur' },
|
||||
{ min: 20, message: '请详细描述,至少20个字符', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate((valid) => {
|
||||
if (valid) {
|
||||
submitting.value = true
|
||||
setTimeout(() => {
|
||||
submitting.value = false
|
||||
successDialogVisible.value = true
|
||||
ElMessage.success('提交成功')
|
||||
}, 1500)
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.customize-page {
|
||||
padding: 20px 0 60px;
|
||||
|
||||
.page-header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
|
||||
.page-title {
|
||||
font-size: 32px;
|
||||
color: #303133;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
font-size: 16px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
.intro-section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
margin-bottom: 40px;
|
||||
|
||||
.intro-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 30px 20px;
|
||||
text-align: center;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
color: #303133;
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-section {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 40px;
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
|
||||
.section-title {
|
||||
font-size: 20px;
|
||||
color: #303133;
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.customize-form {
|
||||
:deep(.el-radio-group) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.success-dialog {
|
||||
.success-content {
|
||||
text-align: center;
|
||||
|
||||
.success-icon {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 20px;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.success-desc {
|
||||
color: #909399;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.vip-group-section {
|
||||
background: linear-gradient(135deg, #fff9e6 0%, #fffdf5 100%);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
text-align: left;
|
||||
|
||||
.vip-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.vip-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #d4a106;
|
||||
}
|
||||
}
|
||||
|
||||
.vip-benefits {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.benefit-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
}
|
||||
|
||||
.vip-qrcode {
|
||||
text-align: center;
|
||||
padding-top: 16px;
|
||||
border-top: 1px dashed #ebeef5;
|
||||
|
||||
.qrcode-img {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #ffd700;
|
||||
}
|
||||
|
||||
.qrcode-tip {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: #d4a106;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.customize-page {
|
||||
.intro-section {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.customize-page {
|
||||
.intro-section {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
44
frontend/src/views/error/404.vue
Normal file
44
frontend/src/views/error/404.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div class="not-found-page">
|
||||
<div class="content">
|
||||
<h1>404</h1>
|
||||
<h2>页面不存在</h2>
|
||||
<p>抱歉,您访问的页面不存在或已被移除</p>
|
||||
<el-button type="primary" @click="$router.push('/')">返回首页</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.not-found-page {
|
||||
min-height: calc(100vh - 64px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
|
||||
.content {
|
||||
h1 {
|
||||
font-size: 120px;
|
||||
font-weight: 700;
|
||||
color: #409eff;
|
||||
margin-bottom: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
color: #303133;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #909399;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
429
frontend/src/views/home/index.vue
Normal file
429
frontend/src/views/home/index.vue
Normal file
@@ -0,0 +1,429 @@
|
||||
<template>
|
||||
<div class="home-page">
|
||||
<section class="hero-section">
|
||||
<div class="hero-content">
|
||||
<h1 class="hero-title">发现优质数字员工</h1>
|
||||
<p class="hero-desc">探索数千款AI技能工具,提升工作效率,释放创造力</p>
|
||||
<div class="hero-search">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索你需要的Skill..."
|
||||
size="large"
|
||||
@keyup.enter="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
<template #append>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
<div class="hero-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">{{ stats.totalSkills }}+</span>
|
||||
<span class="stat-label">优质Skill</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">{{ stats.totalUsers }}+</span>
|
||||
<span class="stat-label">活跃用户</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">{{ stats.totalDownloads }}+</span>
|
||||
<span class="stat-label">累计下载</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="category-section page-container">
|
||||
<h2 class="section-title">热门分类</h2>
|
||||
<div class="category-grid">
|
||||
<div
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
class="category-card"
|
||||
@click="goToCategory(category.id)"
|
||||
>
|
||||
<el-icon :size="32" class="category-icon">
|
||||
<component :is="getCategoryIcon(category.icon)" />
|
||||
</el-icon>
|
||||
<span class="category-name">{{ category.name }}</span>
|
||||
<span class="category-count">{{ getCategoryCount(category.id) }}款</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="featured-section page-container">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">精选推荐</h2>
|
||||
<el-button text type="primary" @click="$router.push('/skills')">
|
||||
查看更多 <el-icon><ArrowRight /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="skill-grid">
|
||||
<SkillCard
|
||||
v-for="skill in featuredSkills"
|
||||
:key="skill.id"
|
||||
:skill="skill"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="hot-section page-container">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">热门下载</h2>
|
||||
<el-button text type="primary" @click="$router.push('/skills?sort=downloads')">
|
||||
查看更多 <el-icon><ArrowRight /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="skill-grid">
|
||||
<SkillCard
|
||||
v-for="skill in hotSkills"
|
||||
:key="skill.id"
|
||||
:skill="skill"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="new-section page-container">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">最新上架</h2>
|
||||
<el-button text type="primary" @click="$router.push('/skills?sort=newest')">
|
||||
查看更多 <el-icon><ArrowRight /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="skill-grid">
|
||||
<SkillCard
|
||||
v-for="skill in newSkills"
|
||||
:key="skill.id"
|
||||
:skill="skill"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="features-section page-container">
|
||||
<h2 class="section-title">平台特色</h2>
|
||||
<div class="features-grid">
|
||||
<div class="feature-item">
|
||||
<el-icon :size="48" class="feature-icon"><Checked /></el-icon>
|
||||
<h3>品质保障</h3>
|
||||
<p>所有Skill经过严格审核,确保安全可靠</p>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<el-icon :size="48" class="feature-icon"><Timer /></el-icon>
|
||||
<h3>即时使用</h3>
|
||||
<p>获取后立即使用,无需等待</p>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<el-icon :size="48" class="feature-icon"><Service /></el-icon>
|
||||
<h3>专业支持</h3>
|
||||
<p>7x24小时技术支持,问题快速响应</p>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<el-icon :size="48" class="feature-icon"><Coin /></el-icon>
|
||||
<h3>积分福利</h3>
|
||||
<p>多种方式获取积分,免费兑换优质Skill</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="cta-section">
|
||||
<div class="cta-content">
|
||||
<h2>开始你的数字员工之旅</h2>
|
||||
<p>注册即送300积分,探索更多可能</p>
|
||||
<el-button type="primary" size="large" @click="$router.push('/register')">
|
||||
立即注册
|
||||
</el-button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useSkillStore } from '@/stores'
|
||||
import SkillCard from '@/components/SkillCard.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const skillStore = useSkillStore()
|
||||
|
||||
const searchKeyword = ref('')
|
||||
|
||||
const stats = ref({
|
||||
totalSkills: 1000,
|
||||
totalUsers: 50000,
|
||||
totalDownloads: 200000
|
||||
})
|
||||
|
||||
const categories = computed(() => skillStore.categories)
|
||||
const featuredSkills = computed(() => skillStore.featuredSkills.slice(0, 4))
|
||||
const hotSkills = computed(() => skillStore.hotSkills.slice(0, 4))
|
||||
const newSkills = computed(() => skillStore.newSkills.slice(0, 4))
|
||||
|
||||
onMounted(() => {
|
||||
skillStore.loadSkills()
|
||||
})
|
||||
|
||||
const handleSearch = () => {
|
||||
if (searchKeyword.value.trim()) {
|
||||
router.push({ path: '/search', query: { keyword: searchKeyword.value } })
|
||||
}
|
||||
}
|
||||
|
||||
const getCategoryIcon = (iconName) => {
|
||||
const iconMap = {
|
||||
'Document': 'Document',
|
||||
'DataAnalysis': 'DataAnalysis',
|
||||
'Service': 'Service',
|
||||
'Edit': 'Edit',
|
||||
'TrendCharts': 'TrendCharts',
|
||||
'Tools': 'Tools'
|
||||
}
|
||||
return iconMap[iconName] || 'Document'
|
||||
}
|
||||
|
||||
const getCategoryCount = (categoryId) => {
|
||||
return skillStore.skills.filter(s => s.categoryId === categoryId && s.status === 'active').length
|
||||
}
|
||||
|
||||
const goToCategory = (categoryId) => {
|
||||
router.push({ path: '/skills', query: { category: categoryId } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.home-page {
|
||||
.hero-section {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 80px 20px;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
|
||||
.hero-content {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
font-size: 18px;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.hero-search {
|
||||
max-width: 600px;
|
||||
margin: 0 auto 40px;
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
border-radius: 25px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
:deep(.el-input-group__append) {
|
||||
border-radius: 0 25px 25px 0;
|
||||
background: #409eff;
|
||||
border-color: #409eff;
|
||||
|
||||
.el-button {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 60px;
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.stat-value {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.category-section {
|
||||
padding-top: 40px;
|
||||
|
||||
.category-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 20px;
|
||||
|
||||
.category-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
|
||||
.category-icon {
|
||||
color: #409eff;
|
||||
}
|
||||
}
|
||||
|
||||
.category-icon {
|
||||
color: #666;
|
||||
margin-bottom: 12px;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.category-name {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.category-count {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.featured-section,
|
||||
.hot-section,
|
||||
.new-section {
|
||||
padding-top: 40px;
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.skill-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.features-section {
|
||||
padding-top: 60px;
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 30px;
|
||||
|
||||
.feature-item {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
|
||||
.feature-icon {
|
||||
color: #409eff;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cta-section {
|
||||
background: linear-gradient(135deg, #409eff 0%, #67c23a 100%);
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
margin-top: 60px;
|
||||
|
||||
.cta-content {
|
||||
h2 {
|
||||
font-size: 32px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 16px;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.home-page {
|
||||
.category-grid {
|
||||
grid-template-columns: repeat(3, 1fr) !important;
|
||||
}
|
||||
|
||||
.skill-grid {
|
||||
grid-template-columns: repeat(2, 1fr) !important;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: repeat(2, 1fr) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.home-page {
|
||||
.hero-section {
|
||||
padding: 40px 20px;
|
||||
|
||||
.hero-title {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
gap: 30px;
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.category-grid {
|
||||
grid-template-columns: repeat(2, 1fr) !important;
|
||||
}
|
||||
|
||||
.skill-grid {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
653
frontend/src/views/join-us/index.vue
Normal file
653
frontend/src/views/join-us/index.vue
Normal file
@@ -0,0 +1,653 @@
|
||||
<template>
|
||||
<div class="join-us-page">
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">加入我们</h1>
|
||||
<p class="page-desc">成为Skill开发者,开启自由职业新篇章</p>
|
||||
</div>
|
||||
|
||||
<div class="hero-section">
|
||||
<div class="hero-content">
|
||||
<h2>招募Skill开发者</h2>
|
||||
<p>移动办公 · 时间自由 · 收入可观</p>
|
||||
<div class="hero-tags">
|
||||
<el-tag effect="dark" size="large">远程办公</el-tag>
|
||||
<el-tag effect="dark" size="large" type="success">时间自由</el-tag>
|
||||
<el-tag effect="dark" size="large" type="warning">收入可观</el-tag>
|
||||
<el-tag effect="dark" size="large" type="danger">技术成长</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="benefits-section">
|
||||
<h2 class="section-title">开发者权益</h2>
|
||||
<div class="benefits-grid">
|
||||
<div class="benefit-card">
|
||||
<div class="benefit-icon">
|
||||
<el-icon :size="36" color="#409eff"><Wallet /></el-icon>
|
||||
</div>
|
||||
<h3>丰厚收益</h3>
|
||||
<p>每次下载获得收益分成<br/>优秀开发者月入过万</p>
|
||||
</div>
|
||||
<div class="benefit-card">
|
||||
<div class="benefit-icon">
|
||||
<el-icon :size="36" color="#67c23a"><Location /></el-icon>
|
||||
</div>
|
||||
<h3>移动办公</h3>
|
||||
<p>无需坐班,随时随地<br/>在家也能轻松赚钱</p>
|
||||
</div>
|
||||
<div class="benefit-card">
|
||||
<div class="benefit-icon">
|
||||
<el-icon :size="36" color="#e6a23c"><Clock /></el-icon>
|
||||
</div>
|
||||
<h3>时间自由</h3>
|
||||
<p>自己安排开发时间<br/>工作生活完美平衡</p>
|
||||
</div>
|
||||
<div class="benefit-card">
|
||||
<div class="benefit-icon">
|
||||
<el-icon :size="36" color="#f56c6c"><TrendCharts /></el-icon>
|
||||
</div>
|
||||
<h3>技术成长</h3>
|
||||
<p>实战项目经验积累<br/>提升技术能力</p>
|
||||
</div>
|
||||
<div class="benefit-card">
|
||||
<div class="benefit-icon">
|
||||
<el-icon :size="36" color="#909399"><Medal /></el-icon>
|
||||
</div>
|
||||
<h3>官方认证</h3>
|
||||
<p>获得平台官方认证<br/>提升个人品牌影响力</p>
|
||||
</div>
|
||||
<div class="benefit-card">
|
||||
<div class="benefit-icon">
|
||||
<el-icon :size="36" color="#9b59b6"><Connection /></el-icon>
|
||||
</div>
|
||||
<h3>社区资源</h3>
|
||||
<p>加入开发者社区<br/>与同行交流学习</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="requirements-section">
|
||||
<h2 class="section-title">申请要求</h2>
|
||||
<div class="requirements-list">
|
||||
<div class="requirement-item">
|
||||
<el-icon :size="24" color="#67c23a"><Check /></el-icon>
|
||||
<div class="requirement-content">
|
||||
<h4>技术能力</h4>
|
||||
<p>熟练掌握至少一门编程语言,有实际项目开发经验</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="requirement-item">
|
||||
<el-icon :size="24" color="#67c23a"><Check /></el-icon>
|
||||
<div class="requirement-content">
|
||||
<h4>作品展示</h4>
|
||||
<p>需提供至少一个可演示的Skill作品或相关项目经验</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="requirement-item">
|
||||
<el-icon :size="24" color="#67c23a"><Check /></el-icon>
|
||||
<div class="requirement-content">
|
||||
<h4>责任心</h4>
|
||||
<p>对产品质量负责,能够及时响应和修复问题</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="requirement-item">
|
||||
<el-icon :size="24" color="#67c23a"><Check /></el-icon>
|
||||
<div class="requirement-content">
|
||||
<h4>持续维护</h4>
|
||||
<p>愿意持续维护和更新已发布的Skill产品</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h2 class="section-title">申请成为开发者</h2>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-width="120px"
|
||||
class="apply-form"
|
||||
>
|
||||
<el-divider content-position="left">基本信息</el-divider>
|
||||
|
||||
<el-form-item label="真实姓名" prop="realName">
|
||||
<el-input v-model="form.realName" placeholder="请输入真实姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号码" prop="phone">
|
||||
<el-input v-model="form.phone" placeholder="请输入手机号码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="电子邮箱" prop="email">
|
||||
<el-input v-model="form.email" placeholder="请输入电子邮箱" />
|
||||
</el-form-item>
|
||||
<el-form-item label="所在城市" prop="city">
|
||||
<el-input v-model="form.city" placeholder="请输入所在城市" />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">专业信息</el-divider>
|
||||
|
||||
<el-form-item label="技术栈" prop="techStack">
|
||||
<el-checkbox-group v-model="form.techStack">
|
||||
<el-checkbox label="Python">Python</el-checkbox>
|
||||
<el-checkbox label="JavaScript">JavaScript</el-checkbox>
|
||||
<el-checkbox label="Java">Java</el-checkbox>
|
||||
<el-checkbox label="Go">Go</el-checkbox>
|
||||
<el-checkbox label="C/C++">C/C++</el-checkbox>
|
||||
<el-checkbox label="Rust">Rust</el-checkbox>
|
||||
<el-checkbox label="TypeScript">TypeScript</el-checkbox>
|
||||
<el-checkbox label="其他">其他</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="工作年限" prop="experience">
|
||||
<el-select v-model="form.experience" placeholder="请选择工作年限" style="width: 100%">
|
||||
<el-option label="1年以下" value="0-1" />
|
||||
<el-option label="1-3年" value="1-3" />
|
||||
<el-option label="3-5年" value="3-5" />
|
||||
<el-option label="5-10年" value="5-10" />
|
||||
<el-option label="10年以上" value="10+" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="擅长领域" prop="expertise">
|
||||
<el-select v-model="form.expertise" multiple placeholder="请选择擅长领域" style="width: 100%">
|
||||
<el-option label="办公自动化" value="office" />
|
||||
<el-option label="数据处理" value="data" />
|
||||
<el-option label="AI/机器学习" value="ai" />
|
||||
<el-option label="Web开发" value="web" />
|
||||
<el-option label="移动端开发" value="mobile" />
|
||||
<el-option label="爬虫/采集" value="crawler" />
|
||||
<el-option label="自动化测试" value="testing" />
|
||||
<el-option label="其他" value="other" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">简历与作品</el-divider>
|
||||
|
||||
<el-form-item label="个人简介" prop="bio">
|
||||
<el-input
|
||||
v-model="form.bio"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="请简单介绍一下自己,包括教育背景、工作经历等"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="简历上传" prop="resumeUrl">
|
||||
<el-input v-model="form.resumeUrl" placeholder="请输入简历网盘链接(支持百度网盘、腾讯微云等)">
|
||||
<template #prepend>
|
||||
<el-icon><Link /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<div class="form-tip">请将简历上传至网盘后粘贴分享链接</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="Skill演示视频" prop="demoVideoUrl">
|
||||
<el-input v-model="form.demoVideoUrl" placeholder="请输入Skill演示视频网盘链接">
|
||||
<template #prepend>
|
||||
<el-icon><VideoCamera /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<div class="form-tip">请录制Skill演示视频并上传至网盘,展示您的作品功能</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="作品链接" prop="portfolioUrl">
|
||||
<el-input v-model="form.portfolioUrl" placeholder="请输入个人作品集或GitHub链接(选填)">
|
||||
<template #prepend>
|
||||
<el-icon><Monitor /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="期望收益" prop="expectedIncome">
|
||||
<el-radio-group v-model="form.expectedIncome">
|
||||
<el-radio label="1000-3000">1,000-3,000元/月</el-radio>
|
||||
<el-radio label="3000-5000">3,000-5,000元/月</el-radio>
|
||||
<el-radio label="5000-10000">5,000-10,000元/月</el-radio>
|
||||
<el-radio label="10000+">10,000元以上/月</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-checkbox v-model="form.agreement">
|
||||
我已阅读并同意
|
||||
<el-button type="primary" text>《开发者协议》</el-button>
|
||||
</el-checkbox>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" size="large" :loading="submitting" @click="handleSubmit">
|
||||
提交申请
|
||||
</el-button>
|
||||
<el-button size="large" @click="resetForm">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="process-section">
|
||||
<h2 class="section-title">申请流程</h2>
|
||||
<div class="process-steps">
|
||||
<div class="process-step">
|
||||
<div class="step-number">1</div>
|
||||
<div class="step-content">
|
||||
<h4>提交申请</h4>
|
||||
<p>填写申请表单<br/>上传简历和作品</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-arrow">
|
||||
<el-icon :size="24"><ArrowRight /></el-icon>
|
||||
</div>
|
||||
<div class="process-step">
|
||||
<div class="step-number">2</div>
|
||||
<div class="step-content">
|
||||
<h4>资质审核</h4>
|
||||
<p>平台审核团队<br/>评估技术能力</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-arrow">
|
||||
<el-icon :size="24"><ArrowRight /></el-icon>
|
||||
</div>
|
||||
<div class="process-step">
|
||||
<div class="step-number">3</div>
|
||||
<div class="step-content">
|
||||
<h4>技能测试</h4>
|
||||
<p>完成测试任务<br/>展示开发能力</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-arrow">
|
||||
<el-icon :size="24"><ArrowRight /></el-icon>
|
||||
</div>
|
||||
<div class="process-step">
|
||||
<div class="step-number">4</div>
|
||||
<div class="step-content">
|
||||
<h4>正式入驻</h4>
|
||||
<p>签署合作协议<br/>开始发布Skill</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="successDialogVisible"
|
||||
title="申请提交成功"
|
||||
width="500px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div class="success-content">
|
||||
<div class="success-icon">
|
||||
<el-icon :size="64" color="#67c23a"><CircleCheckFilled /></el-icon>
|
||||
</div>
|
||||
<h3>您的申请已提交成功!</h3>
|
||||
<p class="success-desc">我们将在3-5个工作日内完成审核,审核结果将通过邮件通知您</p>
|
||||
<div class="contact-info">
|
||||
<p>如有疑问,请联系:</p>
|
||||
<p>邮箱:developer@openclaw.com</p>
|
||||
<p>微信:OpenClawDev</p>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="successDialogVisible = false">我知道了</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const formRef = ref(null)
|
||||
const submitting = ref(false)
|
||||
const successDialogVisible = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
realName: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
city: '',
|
||||
techStack: [],
|
||||
experience: '',
|
||||
expertise: [],
|
||||
bio: '',
|
||||
resumeUrl: '',
|
||||
demoVideoUrl: '',
|
||||
portfolioUrl: '',
|
||||
expectedIncome: '',
|
||||
agreement: false
|
||||
})
|
||||
|
||||
const rules = {
|
||||
realName: [{ required: true, message: '请输入真实姓名', trigger: 'blur' }],
|
||||
phone: [
|
||||
{ required: true, message: '请输入手机号码', trigger: 'blur' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
|
||||
],
|
||||
email: [
|
||||
{ required: true, message: '请输入电子邮箱', trigger: 'blur' },
|
||||
{ type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
|
||||
],
|
||||
city: [{ required: true, message: '请输入所在城市', trigger: 'blur' }],
|
||||
techStack: [{ required: true, message: '请选择技术栈', trigger: 'change' }],
|
||||
experience: [{ required: true, message: '请选择工作年限', trigger: 'change' }],
|
||||
expertise: [{ required: true, message: '请选择擅长领域', trigger: 'change' }],
|
||||
bio: [
|
||||
{ required: true, message: '请输入个人简介', trigger: 'blur' },
|
||||
{ min: 50, message: '个人简介至少50个字符', trigger: 'blur' }
|
||||
],
|
||||
resumeUrl: [{ required: true, message: '请输入简历网盘链接', trigger: 'blur' }],
|
||||
demoVideoUrl: [{ required: true, message: '请输入Skill演示视频链接', trigger: 'blur' }],
|
||||
expectedIncome: [{ required: true, message: '请选择期望收益', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate((valid) => {
|
||||
if (valid) {
|
||||
if (!form.agreement) {
|
||||
ElMessage.warning('请阅读并同意开发者协议')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
setTimeout(() => {
|
||||
submitting.value = false
|
||||
successDialogVisible.value = true
|
||||
ElMessage.success('申请提交成功')
|
||||
}, 1500)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.join-us-page {
|
||||
padding: 20px 0 60px;
|
||||
|
||||
.page-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
|
||||
.page-title {
|
||||
font-size: 32px;
|
||||
color: #303133;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
font-size: 16px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-section {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 16px;
|
||||
padding: 50px 40px;
|
||||
margin-bottom: 40px;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
|
||||
h2 {
|
||||
font-size: 28px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 18px;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.hero-tags {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 24px;
|
||||
color: #303133;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.benefits-section {
|
||||
margin-bottom: 50px;
|
||||
|
||||
.benefits-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
|
||||
.benefit-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 30px 20px;
|
||||
text-align: center;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.benefit-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.requirements-section {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 40px;
|
||||
margin-bottom: 50px;
|
||||
|
||||
.requirements-list {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
|
||||
.requirement-item {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.requirement-content {
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-section {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 40px;
|
||||
margin-bottom: 50px;
|
||||
|
||||
.apply-form {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
|
||||
:deep(.el-checkbox-group) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
:deep(.el-radio-group) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.process-section {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 40px;
|
||||
|
||||
.process-steps {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
|
||||
.process-step {
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
max-width: 180px;
|
||||
|
||||
.step-number {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: linear-gradient(135deg, #409eff, #67c23a);
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.step-arrow {
|
||||
padding-top: 12px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.success-content {
|
||||
text-align: center;
|
||||
|
||||
.success-icon {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 20px;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.success-desc {
|
||||
color: #909399;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.contact-info {
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
text-align: left;
|
||||
|
||||
p {
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.join-us-page {
|
||||
.benefits-grid {
|
||||
grid-template-columns: repeat(2, 1fr) !important;
|
||||
}
|
||||
|
||||
.process-steps {
|
||||
flex-wrap: wrap;
|
||||
|
||||
.step-arrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.process-step {
|
||||
flex-basis: calc(50% - 12px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.join-us-page {
|
||||
.benefits-grid {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
|
||||
.process-steps {
|
||||
.process-step {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.form-section,
|
||||
.requirements-section,
|
||||
.process-section {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
247
frontend/src/views/order/detail.vue
Normal file
247
frontend/src/views/order/detail.vue
Normal file
@@ -0,0 +1,247 @@
|
||||
<template>
|
||||
<div class="order-detail-page">
|
||||
<div class="page-container">
|
||||
<div class="detail-card" v-loading="loading">
|
||||
<template v-if="order">
|
||||
<div class="order-header">
|
||||
<h2>订单详情</h2>
|
||||
<el-tag :type="getStatusType(order.status)" size="large">
|
||||
{{ getStatusText(order.status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="order-info">
|
||||
<div class="info-row">
|
||||
<span class="label">订单号</span>
|
||||
<span class="value">{{ order.id }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">创建时间</span>
|
||||
<span class="value">{{ order.createdAt }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="order.paidAt">
|
||||
<span class="label">支付时间</span>
|
||||
<span class="value">{{ order.paidAt }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="order.completedAt">
|
||||
<span class="label">完成时间</span>
|
||||
<span class="value">{{ order.completedAt }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<div class="skill-info">
|
||||
<img :src="order.skillCover" class="skill-cover" />
|
||||
<div class="skill-detail">
|
||||
<h3>{{ order.skillName }}</h3>
|
||||
<div class="price-info">
|
||||
<span class="label">订单金额:</span>
|
||||
<span class="price">¥{{ order.price }}</span>
|
||||
<span v-if="order.originalPrice > order.price" class="original">
|
||||
¥{{ order.originalPrice }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<div class="pay-info">
|
||||
<h3>支付信息</h3>
|
||||
<div class="info-row">
|
||||
<span class="label">支付方式</span>
|
||||
<span class="value">{{ getPayTypeText(order.payType) }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="order.paidPoints">
|
||||
<span class="label">支付积分</span>
|
||||
<span class="value">{{ order.paidPoints }}积分</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="order.paidAmount">
|
||||
<span class="label">支付金额</span>
|
||||
<span class="value">¥{{ order.paidAmount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="order-actions">
|
||||
<template v-if="order.status === 'pending'">
|
||||
<el-button type="primary" @click="goPay">去支付</el-button>
|
||||
<el-button @click="cancelOrder">取消订单</el-button>
|
||||
</template>
|
||||
<el-button @click="$router.push('/user/orders')">返回订单列表</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-empty description="订单不存在">
|
||||
<el-button type="primary" @click="$router.push('/user/orders')">返回订单列表</el-button>
|
||||
</el-empty>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useOrderStore, useUserStore } from '@/stores'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const orderStore = useOrderStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const order = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
const orderId = route.params.id
|
||||
order.value = orderStore.getOrderById(orderId)
|
||||
})
|
||||
|
||||
const getStatusType = (status) => {
|
||||
const types = {
|
||||
pending: 'warning',
|
||||
completed: 'success',
|
||||
cancelled: 'info',
|
||||
refunded: 'danger'
|
||||
}
|
||||
return types[status] || 'info'
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const texts = {
|
||||
pending: '待支付',
|
||||
completed: '已完成',
|
||||
cancelled: '已取消',
|
||||
refunded: '已退款'
|
||||
}
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
const getPayTypeText = (payType) => {
|
||||
const texts = {
|
||||
points: '积分支付',
|
||||
cash: '现金支付',
|
||||
mixed: '混合支付',
|
||||
free: '免费获取'
|
||||
}
|
||||
return texts[payType] || payType
|
||||
}
|
||||
|
||||
const goPay = () => {
|
||||
router.push(`/pay/${order.value.id}`)
|
||||
}
|
||||
|
||||
const cancelOrder = () => {
|
||||
ElMessageBox.confirm('确定要取消该订单吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
const result = orderStore.cancelOrder(order.value.id, userStore.user.id)
|
||||
if (result.success) {
|
||||
order.value.status = 'cancelled'
|
||||
ElMessage.success('订单已取消')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.order-detail-page {
|
||||
padding: 20px 0 40px;
|
||||
|
||||
.detail-card {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
|
||||
.order-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
|
||||
.order-info {
|
||||
.info-row {
|
||||
display: flex;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
|
||||
.label {
|
||||
width: 100px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.skill-info {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
padding: 20px 0;
|
||||
|
||||
.skill-cover {
|
||||
width: 120px;
|
||||
height: 90px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.skill-detail {
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
color: #303133;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.price-info {
|
||||
.label {
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.price {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.original {
|
||||
font-size: 14px;
|
||||
color: #c0c4cc;
|
||||
text-decoration: line-through;
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pay-info {
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
color: #303133;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.order-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
231
frontend/src/views/order/pay.vue
Normal file
231
frontend/src/views/order/pay.vue
Normal file
@@ -0,0 +1,231 @@
|
||||
<template>
|
||||
<div class="pay-page">
|
||||
<div class="pay-container">
|
||||
<div class="pay-header">
|
||||
<h2>订单支付</h2>
|
||||
</div>
|
||||
|
||||
<div class="pay-content" v-loading="loading">
|
||||
<template v-if="order">
|
||||
<div v-if="order.status === 'pending'" class="pay-form">
|
||||
<div class="order-summary">
|
||||
<img :src="order.skillCover" class="skill-cover" />
|
||||
<div class="skill-info">
|
||||
<h3>{{ order.skillName }}</h3>
|
||||
<div class="price">
|
||||
<span class="label">支付金额:</span>
|
||||
<span class="amount">¥{{ order.price }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pay-methods">
|
||||
<h3>选择支付方式</h3>
|
||||
<el-radio-group v-model="payMethod" class="method-list">
|
||||
<el-radio label="wechat" border>
|
||||
<div class="method-content">
|
||||
<el-icon :size="24" color="#07c160"><ChatDotRound /></el-icon>
|
||||
<span>微信支付</span>
|
||||
</div>
|
||||
</el-radio>
|
||||
<el-radio label="alipay" border>
|
||||
<div class="method-content">
|
||||
<el-icon :size="24" color="#1677ff"><Wallet /></el-icon>
|
||||
<span>支付宝</span>
|
||||
</div>
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<div class="pay-actions">
|
||||
<el-button type="primary" size="large" :loading="paying" @click="handlePay">
|
||||
确认支付 ¥{{ order.price }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="pay-success">
|
||||
<el-icon :size="48" color="#67c23a"><CircleCheck /></el-icon>
|
||||
<h3>支付成功</h3>
|
||||
<p>您的订单已支付成功,Skill已添加到您的账户</p>
|
||||
<div class="success-actions">
|
||||
<el-button type="primary" @click="$router.push('/user/skills')">查看我的Skill</el-button>
|
||||
<el-button @click="$router.push('/skills')">继续逛逛</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useOrderStore, useUserStore } from '@/stores'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const orderStore = useOrderStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const order = ref(null)
|
||||
const payMethod = ref('wechat')
|
||||
const paying = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
const orderId = route.params.orderId
|
||||
order.value = orderStore.getOrderById(orderId)
|
||||
|
||||
if (!order.value) {
|
||||
ElMessage.error('订单不存在')
|
||||
router.push('/user/orders')
|
||||
}
|
||||
})
|
||||
|
||||
const handlePay = () => {
|
||||
paying.value = true
|
||||
setTimeout(() => {
|
||||
const result = orderStore.payOrder(order.value.id, userStore.user.id)
|
||||
paying.value = false
|
||||
|
||||
if (result.success) {
|
||||
order.value = result.data
|
||||
userStore.refreshUser()
|
||||
ElMessage.success('支付成功')
|
||||
} else {
|
||||
ElMessage.error(result.message)
|
||||
}
|
||||
}, 1500)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.pay-page {
|
||||
min-height: calc(100vh - 64px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
|
||||
.pay-container {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.pay-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
|
||||
.order-summary {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.skill-cover {
|
||||
width: 80px;
|
||||
height: 60px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.skill-info {
|
||||
flex: 1;
|
||||
|
||||
h3 {
|
||||
font-size: 15px;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.price {
|
||||
.label {
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.amount {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #f56c6c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pay-methods {
|
||||
margin-bottom: 24px;
|
||||
|
||||
h3 {
|
||||
font-size: 15px;
|
||||
color: #303133;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.method-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
:deep(.el-radio) {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding: 16px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
margin-right: 0;
|
||||
|
||||
&.is-checked {
|
||||
background: #ecf5ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pay-actions {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.el-button {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.pay-success {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
|
||||
h3 {
|
||||
font-size: 20px;
|
||||
color: #303133;
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #909399;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.success-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
711
frontend/src/views/skill/detail.vue
Normal file
711
frontend/src/views/skill/detail.vue
Normal file
@@ -0,0 +1,711 @@
|
||||
<template>
|
||||
<div class="skill-detail-page" v-loading="loading">
|
||||
<template v-if="skill">
|
||||
<div class="detail-header">
|
||||
<div class="page-container">
|
||||
<div class="header-content">
|
||||
<div class="skill-cover">
|
||||
<img :src="skill.cover" :alt="skill.name" />
|
||||
</div>
|
||||
<div class="skill-info">
|
||||
<div class="skill-tags">
|
||||
<el-tag v-if="skill.isNew" type="success">新品</el-tag>
|
||||
<el-tag v-if="skill.price === 0" type="warning">免费</el-tag>
|
||||
<el-tag v-if="skill.isHot" type="danger">热门</el-tag>
|
||||
</div>
|
||||
<h1 class="skill-name">{{ skill.name }}</h1>
|
||||
<p class="skill-desc">{{ skill.description }}</p>
|
||||
<div class="skill-meta">
|
||||
<span class="meta-item">
|
||||
<el-icon><StarFilled /></el-icon>
|
||||
{{ skill.rating }}分
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<el-icon><Download /></el-icon>
|
||||
{{ formatNumber(skill.downloadCount) }}次下载
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<el-icon><User /></el-icon>
|
||||
{{ skill.author }}
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<el-icon><Clock /></el-icon>
|
||||
{{ skill.updatedAt }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="skill-price">
|
||||
<template v-if="skill.price === 0">
|
||||
<span class="price free">免费</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="price">
|
||||
<span class="label">价格:</span>
|
||||
<span class="current">¥{{ skill.price }}</span>
|
||||
<span v-if="skill.originalPrice > skill.price" class="original">¥{{ skill.originalPrice }}</span>
|
||||
</span>
|
||||
<span class="point-price">
|
||||
<span class="label">或</span>
|
||||
<span class="points">{{ skill.pointPrice }}积分</span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="skill-actions">
|
||||
<template v-if="hasPurchased">
|
||||
<el-button type="success" size="large" disabled>
|
||||
<el-icon><Check /></el-icon>已获取
|
||||
</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button
|
||||
v-if="skill.price === 0"
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="handleFreeGet"
|
||||
>
|
||||
<el-icon><Download /></el-icon>免费获取
|
||||
</el-button>
|
||||
<template v-else>
|
||||
<el-button type="primary" size="large" @click="handleBuy('cash')">
|
||||
<el-icon><ShoppingCart /></el-icon>现金购买
|
||||
</el-button>
|
||||
<el-button type="warning" size="large" @click="handleBuy('points')">
|
||||
<el-icon><Coin /></el-icon>积分兑换
|
||||
</el-button>
|
||||
</template>
|
||||
</template>
|
||||
<el-button size="large" @click="handleFavorite">
|
||||
<el-icon><Star /></el-icon>收藏
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-body page-container">
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="功能介绍" name="intro">
|
||||
<div class="tab-content">
|
||||
<div class="section-block">
|
||||
<h3>功能特点</h3>
|
||||
<ul class="feature-list">
|
||||
<li v-for="(feature, index) in skill.features" :key="index">
|
||||
<el-icon><Check /></el-icon>
|
||||
{{ feature }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section-block">
|
||||
<h3>详细介绍</h3>
|
||||
<div class="detail-images">
|
||||
<img v-for="(img, index) in skill.detailImages" :key="index" :src="img" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-block">
|
||||
<h3>系统要求</h3>
|
||||
<div class="requirements">
|
||||
<p><strong>支持系统:</strong>{{ skill.requirements?.system }}</p>
|
||||
<p><strong>版本要求:</strong>{{ skill.requirements?.version }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-block">
|
||||
<h3>标签</h3>
|
||||
<div class="tags">
|
||||
<el-tag v-for="tag in skill.tags" :key="tag" effect="plain">{{ tag }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="用户评价" name="comments">
|
||||
<div class="tab-content">
|
||||
<div class="comment-summary">
|
||||
<div class="rating-overview">
|
||||
<div class="rating-score">{{ skill.rating }}</div>
|
||||
<div class="rating-stars">
|
||||
<el-rate v-model="skill.rating" disabled show-score text-color="#ff9900" />
|
||||
</div>
|
||||
<div class="rating-count">{{ skill.ratingCount }}条评价</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="comment-list">
|
||||
<div v-if="canComment" class="comment-form">
|
||||
<h4>发表评价</h4>
|
||||
<el-form :model="commentForm" label-width="80px">
|
||||
<el-form-item label="评分">
|
||||
<el-rate v-model="commentForm.rating" show-text />
|
||||
</el-form-item>
|
||||
<el-form-item label="内容">
|
||||
<el-input
|
||||
v-model="commentForm.content"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="分享您的使用体验..."
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="submitComment" :loading="submitting">
|
||||
提交评价
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div v-if="comments.length > 0" class="comments">
|
||||
<div v-for="comment in comments" :key="comment.id" class="comment-item">
|
||||
<div class="comment-header">
|
||||
<el-avatar :size="40" :src="comment.userAvatar" />
|
||||
<div class="comment-user">
|
||||
<span class="user-name">{{ comment.userName }}</span>
|
||||
<el-rate v-model="comment.rating" disabled size="small" />
|
||||
</div>
|
||||
<span class="comment-time">{{ comment.createdAt }}</span>
|
||||
</div>
|
||||
<div class="comment-content">{{ comment.content }}</div>
|
||||
<div class="comment-footer">
|
||||
<el-button text size="small" @click="likeComment(comment.id)">
|
||||
<el-icon><Pointer /></el-icon>
|
||||
{{ comment.likes }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无评价" />
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="版本记录" name="versions">
|
||||
<div class="tab-content">
|
||||
<el-timeline>
|
||||
<el-timeline-item
|
||||
:timestamp="skill.updatedAt"
|
||||
placement="top"
|
||||
color="#409eff"
|
||||
>
|
||||
<el-card>
|
||||
<h4>v{{ skill.version }}</h4>
|
||||
<p>当前版本,功能优化与Bug修复</p>
|
||||
</el-card>
|
||||
</el-timeline-item>
|
||||
<el-timeline-item
|
||||
timestamp="2024-02-01"
|
||||
placement="top"
|
||||
>
|
||||
<el-card>
|
||||
<h4>v1.0.0</h4>
|
||||
<p>首次发布</p>
|
||||
</el-card>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
|
||||
<div class="related-section page-container">
|
||||
<h2 class="section-title">相关推荐</h2>
|
||||
<div class="related-grid">
|
||||
<SkillCard
|
||||
v-for="relatedSkill in relatedSkills"
|
||||
:key="relatedSkill.id"
|
||||
:skill="relatedSkill"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-empty v-else description="Skill不存在" />
|
||||
|
||||
<el-dialog v-model="payDialogVisible" title="选择支付方式" width="400px">
|
||||
<div class="pay-options">
|
||||
<div class="pay-info">
|
||||
<p>商品:{{ skill?.name }}</p>
|
||||
<p>价格:¥{{ skill?.price }} 或 {{ skill?.pointPrice }}积分</p>
|
||||
</div>
|
||||
<el-divider />
|
||||
<div class="pay-buttons">
|
||||
<el-button type="primary" size="large" @click="confirmPay('cash')">
|
||||
微信/支付宝支付 ¥{{ skill?.price }}
|
||||
</el-button>
|
||||
<el-button type="warning" size="large" @click="confirmPay('points')">
|
||||
积分兑换 {{ skill?.pointPrice }}积分
|
||||
</el-button>
|
||||
</div>
|
||||
<p class="point-balance">当前积分余额:{{ userStore.userPoints }}积分</p>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<DownloadSuccessDialog
|
||||
v-model="showDownloadSuccess"
|
||||
:skill-name="skill?.name"
|
||||
@joined="handleJoinedGroup"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useSkillStore, useUserStore, useOrderStore } from '@/stores'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import SkillCard from '@/components/SkillCard.vue'
|
||||
import DownloadSuccessDialog from '@/components/DownloadSuccessDialog.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const skillStore = useSkillStore()
|
||||
const userStore = useUserStore()
|
||||
const orderStore = useOrderStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const skill = ref(null)
|
||||
const comments = ref([])
|
||||
const relatedSkills = ref([])
|
||||
const activeTab = ref('intro')
|
||||
const payDialogVisible = ref(false)
|
||||
const payType = ref('cash')
|
||||
const submitting = ref(false)
|
||||
const showDownloadSuccess = ref(false)
|
||||
|
||||
const commentForm = ref({
|
||||
rating: 5,
|
||||
content: ''
|
||||
})
|
||||
|
||||
const hasPurchased = computed(() => {
|
||||
if (!userStore.isLoggedIn || !skill.value) return false
|
||||
return orderStore.getUserPurchasedSkills(userStore.user.id).some(s => s.id === skill.value.id)
|
||||
})
|
||||
|
||||
const canComment = computed(() => {
|
||||
return hasPurchased.value && userStore.isLoggedIn
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadSkill()
|
||||
})
|
||||
|
||||
const loadSkill = () => {
|
||||
loading.value = true
|
||||
const skillId = parseInt(route.params.id)
|
||||
skill.value = skillStore.loadSkillById(skillId)
|
||||
|
||||
if (skill.value) {
|
||||
comments.value = skillStore.getComments(skill.value.id)
|
||||
const allSkills = skillStore.skills.filter(s => s.status === 'active' && s.id !== skill.value.id)
|
||||
relatedSkills.value = allSkills.slice(0, 4)
|
||||
}
|
||||
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
const formatNumber = (num) => {
|
||||
if (num >= 10000) {
|
||||
return (num / 10000).toFixed(1) + '万'
|
||||
}
|
||||
return num
|
||||
}
|
||||
|
||||
const handleFreeGet = () => {
|
||||
if (!userStore.isLoggedIn) {
|
||||
ElMessage.warning('请先登录')
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
|
||||
const result = orderStore.createOrder(userStore.user.id, skill.value.id, 'free')
|
||||
if (result.success) {
|
||||
orderStore.payOrder(result.data.id, userStore.user.id)
|
||||
ElMessage.success('获取成功')
|
||||
loadSkill()
|
||||
showDownloadSuccess.value = true
|
||||
} else {
|
||||
ElMessage.error(result.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBuy = (type) => {
|
||||
if (!userStore.isLoggedIn) {
|
||||
ElMessage.warning('请先登录')
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
payType.value = type
|
||||
payDialogVisible.value = true
|
||||
}
|
||||
|
||||
const confirmPay = (type) => {
|
||||
const result = orderStore.createOrder(userStore.user.id, skill.value.id, type)
|
||||
if (result.success) {
|
||||
payDialogVisible.value = false
|
||||
if (type === 'points') {
|
||||
if (userStore.userPoints < skill.value.pointPrice) {
|
||||
ElMessage.error('积分不足')
|
||||
return
|
||||
}
|
||||
const payResult = orderStore.payOrder(result.data.id, userStore.user.id)
|
||||
if (payResult.success) {
|
||||
ElMessage.success('兑换成功')
|
||||
userStore.refreshUser()
|
||||
loadSkill()
|
||||
showDownloadSuccess.value = true
|
||||
} else {
|
||||
ElMessage.error(payResult.message)
|
||||
}
|
||||
} else {
|
||||
router.push(`/pay/${result.data.id}`)
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(result.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleJoinedGroup = () => {
|
||||
userStore.refreshUser()
|
||||
}
|
||||
|
||||
const handleFavorite = () => {
|
||||
ElMessage.success('已收藏')
|
||||
}
|
||||
|
||||
const submitComment = () => {
|
||||
if (!commentForm.value.content.trim()) {
|
||||
ElMessage.warning('请输入评价内容')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
const result = skillStore.addComment(
|
||||
userStore.user.id,
|
||||
skill.value.id,
|
||||
commentForm.value.rating,
|
||||
commentForm.value.content
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
ElMessage.success(result.message)
|
||||
commentForm.value = { rating: 5, content: '' }
|
||||
comments.value = skillStore.getComments(skill.value.id)
|
||||
userStore.refreshUser()
|
||||
} else {
|
||||
ElMessage.error(result.message)
|
||||
}
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
const likeComment = (commentId) => {
|
||||
skillStore.likeComment(commentId)
|
||||
comments.value = skillStore.getComments(skill.value.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.skill-detail-page {
|
||||
.detail-header {
|
||||
background: #fff;
|
||||
padding: 30px 0;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
|
||||
.skill-cover {
|
||||
width: 400px;
|
||||
height: 300px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.skill-info {
|
||||
flex: 1;
|
||||
|
||||
.skill-tags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.skill-name {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.skill-desc {
|
||||
font-size: 15px;
|
||||
color: #606266;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.skill-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
|
||||
.el-icon {
|
||||
color: #409eff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.skill-price {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.price {
|
||||
&.free {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.current {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.original {
|
||||
font-size: 14px;
|
||||
color: #c0c4cc;
|
||||
text-decoration: line-through;
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.point-price {
|
||||
margin-left: 20px;
|
||||
|
||||
.label {
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.points {
|
||||
color: #e6a23c;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.skill-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
|
||||
.tab-content {
|
||||
padding: 20px 0;
|
||||
|
||||
.section-block {
|
||||
margin-bottom: 30px;
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
color: #303133;
|
||||
margin-bottom: 16px;
|
||||
padding-left: 12px;
|
||||
border-left: 3px solid #409eff;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 0;
|
||||
color: #606266;
|
||||
|
||||
.el-icon {
|
||||
color: #67c23a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.detail-images {
|
||||
img {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.requirements {
|
||||
p {
|
||||
color: #606266;
|
||||
line-height: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.comment-summary {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.rating-overview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.rating-score {
|
||||
font-size: 48px;
|
||||
font-weight: 600;
|
||||
color: #ff9900;
|
||||
}
|
||||
|
||||
.rating-count {
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.comment-form {
|
||||
background: #f5f7fa;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.comment-item {
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.comment-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.comment-user {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.user-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.comment-time {
|
||||
margin-left: auto;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.comment-content {
|
||||
color: #606266;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.comment-footer {
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.related-section {
|
||||
padding-top: 40px;
|
||||
|
||||
.related-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.pay-options {
|
||||
.pay-info {
|
||||
p {
|
||||
margin-bottom: 8px;
|
||||
color: #606266;
|
||||
}
|
||||
}
|
||||
|
||||
.pay-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.el-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.point-balance {
|
||||
text-align: center;
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.skill-detail-page {
|
||||
.detail-header {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
|
||||
.skill-cover {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 4/3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.related-grid {
|
||||
grid-template-columns: repeat(2, 1fr) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.skill-detail-page {
|
||||
.related-grid {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
311
frontend/src/views/skill/list.vue
Normal file
311
frontend/src/views/skill/list.vue
Normal file
@@ -0,0 +1,311 @@
|
||||
<template>
|
||||
<div class="skill-list-page">
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Skill商城</h1>
|
||||
<p class="page-desc">发现优质数字员工,提升工作效率</p>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<div class="filter-left">
|
||||
<el-select v-model="filters.categoryId" placeholder="全部分类" clearable @change="handleFilter">
|
||||
<el-option
|
||||
v-for="cat in categories"
|
||||
:key="cat.id"
|
||||
:label="cat.name"
|
||||
:value="cat.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select v-model="filters.priceType" placeholder="价格筛选" clearable @change="handleFilter">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="免费" value="free" />
|
||||
<el-option label="付费" value="paid" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.sortBy" placeholder="排序方式" @change="handleFilter">
|
||||
<el-option label="综合排序" value="default" />
|
||||
<el-option label="最新发布" value="newest" />
|
||||
<el-option label="下载最多" value="downloads" />
|
||||
<el-option label="评分最高" value="rating" />
|
||||
<el-option label="价格从低到高" value="price_asc" />
|
||||
<el-option label="价格从高到低" value="price_desc" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="filter-right">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="搜索Skill..."
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
<template #append>
|
||||
<el-button @click="handleSearch">搜索</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="skill-list" v-loading="loading">
|
||||
<template v-if="displaySkills.length > 0">
|
||||
<div class="skill-grid">
|
||||
<SkillCard
|
||||
v-for="skill in displaySkills"
|
||||
:key="skill.id"
|
||||
:skill="skill"
|
||||
/>
|
||||
</div>
|
||||
<div class="pagination-wrapper">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[12, 24, 36, 48]"
|
||||
:total="totalSkills"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="empty-state">
|
||||
<el-empty description="暂无相关Skill">
|
||||
<el-button type="primary" @click="resetFilters">重置筛选</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useSkillStore } from '@/stores'
|
||||
import SkillCard from '@/components/SkillCard.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const skillStore = useSkillStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const keyword = ref('')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(12)
|
||||
|
||||
const filters = ref({
|
||||
categoryId: null,
|
||||
priceType: '',
|
||||
sortBy: 'default'
|
||||
})
|
||||
|
||||
const categories = computed(() => skillStore.categories)
|
||||
|
||||
const displaySkills = computed(() => {
|
||||
let skills = skillStore.skills.filter(s => s.status === 'active')
|
||||
|
||||
if (keyword.value) {
|
||||
const kw = keyword.value.toLowerCase()
|
||||
skills = skills.filter(s =>
|
||||
s.name.toLowerCase().includes(kw) ||
|
||||
s.description.toLowerCase().includes(kw) ||
|
||||
s.tags.some(t => t.toLowerCase().includes(kw))
|
||||
)
|
||||
}
|
||||
|
||||
if (filters.value.categoryId) {
|
||||
skills = skills.filter(s => s.categoryId === filters.value.categoryId)
|
||||
}
|
||||
|
||||
if (filters.value.priceType === 'free') {
|
||||
skills = skills.filter(s => s.price === 0)
|
||||
} else if (filters.value.priceType === 'paid') {
|
||||
skills = skills.filter(s => s.price > 0)
|
||||
}
|
||||
|
||||
switch (filters.value.sortBy) {
|
||||
case 'newest':
|
||||
skills.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||
break
|
||||
case 'downloads':
|
||||
skills.sort((a, b) => b.downloadCount - a.downloadCount)
|
||||
break
|
||||
case 'rating':
|
||||
skills.sort((a, b) => b.rating - a.rating)
|
||||
break
|
||||
case 'price_asc':
|
||||
skills.sort((a, b) => a.price - b.price)
|
||||
break
|
||||
case 'price_desc':
|
||||
skills.sort((a, b) => b.price - a.price)
|
||||
break
|
||||
}
|
||||
|
||||
return skills
|
||||
})
|
||||
|
||||
const totalSkills = computed(() => displaySkills.value.length)
|
||||
|
||||
const paginatedSkills = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
const end = start + pageSize.value
|
||||
return displaySkills.value.slice(start, end)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
skillStore.loadSkills()
|
||||
|
||||
if (route.query.category) {
|
||||
filters.value.categoryId = parseInt(route.query.category)
|
||||
}
|
||||
if (route.query.sort) {
|
||||
filters.value.sortBy = route.query.sort
|
||||
}
|
||||
if (route.query.keyword) {
|
||||
keyword.value = route.query.keyword
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => route.query, (query) => {
|
||||
if (query.category) {
|
||||
filters.value.categoryId = parseInt(query.category)
|
||||
}
|
||||
if (query.sort) {
|
||||
filters.value.sortBy = query.sort
|
||||
}
|
||||
if (query.keyword) {
|
||||
keyword.value = query.keyword
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const handleFilter = () => {
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
const handleSizeChange = (size) => {
|
||||
pageSize.value = size
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
currentPage.value = page
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
keyword.value = ''
|
||||
filters.value = {
|
||||
categoryId: null,
|
||||
priceType: '',
|
||||
sortBy: 'default'
|
||||
}
|
||||
currentPage.value = 1
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.skill-list-page {
|
||||
padding: 20px 0 40px;
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
background: #fff;
|
||||
padding: 16px 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.filter-left {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
:deep(.el-select) {
|
||||
width: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-right {
|
||||
:deep(.el-input) {
|
||||
width: 300px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.skill-list {
|
||||
min-height: 400px;
|
||||
|
||||
.skill-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 60px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.skill-list-page {
|
||||
.skill-grid {
|
||||
grid-template-columns: repeat(3, 1fr) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.skill-list-page {
|
||||
.skill-grid {
|
||||
grid-template-columns: repeat(2, 1fr) !important;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
.filter-left {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-right {
|
||||
:deep(.el-input) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.skill-list-page {
|
||||
.skill-grid {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
204
frontend/src/views/skill/search.vue
Normal file
204
frontend/src/views/skill/search.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div class="search-page">
|
||||
<div class="page-container">
|
||||
<div class="search-header">
|
||||
<h1>搜索结果</h1>
|
||||
<p v-if="keyword">关键词:"{{ keyword }}",共找到 {{ results.length }} 个结果</p>
|
||||
</div>
|
||||
|
||||
<div class="search-box">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索Skill..."
|
||||
size="large"
|
||||
@keyup.enter="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
<template #append>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<el-select v-model="filters.categoryId" placeholder="分类" clearable @change="doSearch">
|
||||
<el-option
|
||||
v-for="cat in categories"
|
||||
:key="cat.id"
|
||||
:label="cat.name"
|
||||
:value="cat.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select v-model="filters.priceType" placeholder="价格" clearable @change="doSearch">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="免费" value="free" />
|
||||
<el-option label="付费" value="paid" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.sortBy" placeholder="排序" @change="doSearch">
|
||||
<el-option label="综合" value="default" />
|
||||
<el-option label="最新" value="newest" />
|
||||
<el-option label="下载量" value="downloads" />
|
||||
<el-option label="评分" value="rating" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<div class="search-results" v-loading="loading">
|
||||
<template v-if="results.length > 0">
|
||||
<div class="result-grid">
|
||||
<SkillCard
|
||||
v-for="skill in results"
|
||||
:key="skill.id"
|
||||
:skill="skill"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="empty-state">
|
||||
<el-empty description="没有找到相关Skill">
|
||||
<el-button type="primary" @click="clearSearch">清空搜索</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useSkillStore } from '@/stores'
|
||||
import SkillCard from '@/components/SkillCard.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const skillStore = useSkillStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const keyword = ref('')
|
||||
const searchKeyword = ref('')
|
||||
const results = ref([])
|
||||
|
||||
const filters = ref({
|
||||
categoryId: null,
|
||||
priceType: '',
|
||||
sortBy: 'default'
|
||||
})
|
||||
|
||||
const categories = computed(() => skillStore.categories)
|
||||
|
||||
onMounted(() => {
|
||||
skillStore.loadSkills()
|
||||
if (route.query.keyword) {
|
||||
keyword.value = route.query.keyword
|
||||
searchKeyword.value = route.query.keyword
|
||||
doSearch()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => route.query.keyword, (newKeyword) => {
|
||||
if (newKeyword) {
|
||||
keyword.value = newKeyword
|
||||
searchKeyword.value = newKeyword
|
||||
doSearch()
|
||||
}
|
||||
})
|
||||
|
||||
const handleSearch = () => {
|
||||
if (searchKeyword.value.trim()) {
|
||||
router.push({ path: '/search', query: { keyword: searchKeyword.value } })
|
||||
}
|
||||
}
|
||||
|
||||
const doSearch = () => {
|
||||
loading.value = true
|
||||
results.value = skillStore.searchSkills(searchKeyword.value, filters.value)
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
const clearSearch = () => {
|
||||
searchKeyword.value = ''
|
||||
keyword.value = ''
|
||||
filters.value = {
|
||||
categoryId: null,
|
||||
priceType: '',
|
||||
sortBy: 'default'
|
||||
}
|
||||
results.value = []
|
||||
router.push('/skills')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.search-page {
|
||||
padding: 20px 0 40px;
|
||||
|
||||
.search-header {
|
||||
margin-bottom: 20px;
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
.search-box {
|
||||
margin-bottom: 20px;
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
:deep(.el-select) {
|
||||
width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
.result-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 60px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.search-page {
|
||||
.result-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.search-page {
|
||||
.result-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.search-page {
|
||||
.result-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
187
frontend/src/views/user/center.vue
Normal file
187
frontend/src/views/user/center.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<div class="user-center-page">
|
||||
<div class="page-container">
|
||||
<div class="user-layout">
|
||||
<aside class="user-sidebar">
|
||||
<div class="user-card">
|
||||
<el-avatar :size="80" :src="userStore.user?.avatar">
|
||||
{{ userStore.user?.nickname?.charAt(0) }}
|
||||
</el-avatar>
|
||||
<h3 class="user-name">{{ userStore.user?.nickname }}</h3>
|
||||
<p class="user-level">
|
||||
<el-tag type="warning" effect="plain">{{ userStore.user?.levelName }}</el-tag>
|
||||
</p>
|
||||
<div class="user-stats">
|
||||
<div class="stat-item">
|
||||
<span class="value">{{ userStore.user?.points || 0 }}</span>
|
||||
<span class="label">积分</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="value">{{ userStore.user?.inviteCount || 0 }}</span>
|
||||
<span class="label">邀请</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
router
|
||||
>
|
||||
<el-menu-item index="/user">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>个人资料</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/user/orders">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>我的订单</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/user/skills">
|
||||
<el-icon><Grid /></el-icon>
|
||||
<span>我的Skill</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/user/points">
|
||||
<el-icon><Coin /></el-icon>
|
||||
<span>积分中心</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/user/recharge">
|
||||
<el-icon><Wallet /></el-icon>
|
||||
<span>积分充值</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/user/invite">
|
||||
<el-icon><Share /></el-icon>
|
||||
<span>邀请好友</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/user/notifications">
|
||||
<el-icon><Bell /></el-icon>
|
||||
<span>消息通知</span>
|
||||
<el-badge v-if="userStore.unreadCount > 0" :value="userStore.unreadCount" class="menu-badge" />
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/user/settings">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>账号设置</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</aside>
|
||||
<main class="user-content">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useUserStore } from '@/stores'
|
||||
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const activeMenu = computed(() => route.path)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.user-center-page {
|
||||
padding: 20px 0 40px;
|
||||
|
||||
.user-layout {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
|
||||
.user-sidebar {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.user-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.user-name {
|
||||
font-size: 18px;
|
||||
color: #303133;
|
||||
margin: 12px 0 8px;
|
||||
}
|
||||
|
||||
.user-level {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.user-stats {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 40px;
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.value {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-menu) {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
|
||||
.el-menu-item {
|
||||
height: 48px;
|
||||
line-height: 48px;
|
||||
|
||||
&.is-active {
|
||||
background: #ecf5ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu-badge {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.user-content {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
min-height: 600px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.user-center-page {
|
||||
.user-layout {
|
||||
flex-direction: column;
|
||||
|
||||
.user-sidebar {
|
||||
width: 100%;
|
||||
|
||||
:deep(.el-menu) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px;
|
||||
|
||||
.el-menu-item {
|
||||
width: auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
232
frontend/src/views/user/invite.vue
Normal file
232
frontend/src/views/user/invite.vue
Normal file
@@ -0,0 +1,232 @@
|
||||
<template>
|
||||
<div class="invite-page">
|
||||
<h2 class="page-title">邀请好友</h2>
|
||||
|
||||
<div class="invite-banner">
|
||||
<div class="banner-content">
|
||||
<h3>邀请好友,双方得积分</h3>
|
||||
<p>每成功邀请一位好友,您可获得 <strong>100积分</strong> 奖励</p>
|
||||
<p>好友首次消费,您还可额外获得 <strong>50积分</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="invite-section">
|
||||
<h3>我的邀请码</h3>
|
||||
<div class="invite-code-box">
|
||||
<span class="code">{{ userStore.user?.inviteCode }}</span>
|
||||
<el-button type="primary" @click="copyInviteCode">复制邀请码</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="invite-section">
|
||||
<h3>邀请链接</h3>
|
||||
<div class="invite-link-box">
|
||||
<el-input :value="inviteLink" readonly>
|
||||
<template #append>
|
||||
<el-button @click="copyInviteLink">复制链接</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="invite-section">
|
||||
<h3>邀请记录</h3>
|
||||
<div class="invite-stats">
|
||||
<div class="stat-item">
|
||||
<span class="value">{{ inviteRecords.length }}</span>
|
||||
<span class="label">已邀请</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="value">{{ purchasedCount }}</span>
|
||||
<span class="label">已消费</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="invite-list">
|
||||
<template v-if="inviteRecords.length > 0">
|
||||
<div v-for="record in inviteRecords" :key="record.id" class="invite-item">
|
||||
<el-avatar :size="40" :src="record.avatar">{{ record.nickname?.charAt(0) }}</el-avatar>
|
||||
<div class="user-info">
|
||||
<span class="name">{{ record.nickname }}</span>
|
||||
<span class="time">{{ record.createdAt }}</span>
|
||||
</div>
|
||||
<el-tag :type="record.hasPurchased ? 'success' : 'info'" size="small">
|
||||
{{ record.hasPurchased ? '已消费' : '已注册' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-empty description="暂无邀请记录" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="invite-rules">
|
||||
<h3>邀请规则</h3>
|
||||
<ul>
|
||||
<li>好友通过您的邀请码注册成功,您即可获得100积分奖励</li>
|
||||
<li>好友完成首次消费后,您可额外获得50积分奖励</li>
|
||||
<li>邀请人数无上限,邀请越多,奖励越多</li>
|
||||
<li>禁止恶意刷邀请,一经发现将取消奖励并封号处理</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useUserStore, usePointStore } from '@/stores'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const pointStore = usePointStore()
|
||||
|
||||
const inviteRecords = ref([])
|
||||
|
||||
const inviteLink = computed(() => {
|
||||
return `${window.location.origin}/register?code=${userStore.user?.inviteCode}`
|
||||
})
|
||||
|
||||
const purchasedCount = computed(() => {
|
||||
return inviteRecords.value.filter(r => r.hasPurchased).length
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (userStore.user) {
|
||||
inviteRecords.value = pointStore.getInviteRecords(userStore.user.id)
|
||||
}
|
||||
})
|
||||
|
||||
const copyInviteCode = () => {
|
||||
navigator.clipboard.writeText(userStore.user?.inviteCode || '')
|
||||
ElMessage.success('邀请码已复制')
|
||||
}
|
||||
|
||||
const copyInviteLink = () => {
|
||||
navigator.clipboard.writeText(inviteLink.value)
|
||||
ElMessage.success('邀请链接已复制')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.invite-page {
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
color: #303133;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.invite-banner {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
color: #fff;
|
||||
margin-bottom: 24px;
|
||||
|
||||
h3 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
p {
|
||||
opacity: 0.9;
|
||||
margin-bottom: 8px;
|
||||
|
||||
strong {
|
||||
color: #ffd700;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.invite-section {
|
||||
margin-bottom: 24px;
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
color: #303133;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.invite-code-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
|
||||
.code {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #409eff;
|
||||
letter-spacing: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.invite-stats {
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.stat-item {
|
||||
.value {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.invite-list {
|
||||
.invite-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
|
||||
.user-info {
|
||||
flex: 1;
|
||||
|
||||
.name {
|
||||
display: block;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.invite-rules {
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
|
||||
h3 {
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-left: 20px;
|
||||
|
||||
li {
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
line-height: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
193
frontend/src/views/user/login.vue
Normal file
193
frontend/src/views/user/login.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<h2>登录</h2>
|
||||
<p>欢迎回到 OpenClaw Skills</p>
|
||||
</div>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" @submit.prevent="handleLogin">
|
||||
<el-form-item prop="phone">
|
||||
<el-input
|
||||
v-model="form.phone"
|
||||
placeholder="请输入手机号"
|
||||
size="large"
|
||||
prefix-icon="Phone"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
prefix-icon="Lock"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<div class="form-actions">
|
||||
<el-checkbox v-model="rememberMe">记住登录</el-checkbox>
|
||||
<el-button type="primary" text>忘记密码?</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
@click="handleLogin"
|
||||
style="width: 100%"
|
||||
>
|
||||
登录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="login-footer">
|
||||
<span>还没有账号?</span>
|
||||
<el-button type="primary" text @click="$router.push('/register')">立即注册</el-button>
|
||||
</div>
|
||||
<div class="demo-accounts">
|
||||
<el-divider>演示账号</el-divider>
|
||||
<div class="account-list">
|
||||
<div class="account-item" @click="fillDemo('13800138000')">
|
||||
<span>手机号:13800138000</span>
|
||||
<span>密码:123456</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const formRef = ref(null)
|
||||
const loading = ref(false)
|
||||
const rememberMe = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
phone: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const rules = {
|
||||
phone: [
|
||||
{ required: true, message: '请输入手机号', trigger: 'blur' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码至少6位', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
loading.value = true
|
||||
const result = await userStore.login(form.phone, form.password)
|
||||
loading.value = false
|
||||
|
||||
if (result.success) {
|
||||
ElMessage.success('登录成功')
|
||||
const redirect = route.query.redirect || '/'
|
||||
router.push(redirect)
|
||||
} else {
|
||||
ElMessage.error(result.message)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const fillDemo = (phone) => {
|
||||
form.phone = phone
|
||||
form.password = '123456'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login-page {
|
||||
min-height: calc(100vh - 64px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 40px 20px;
|
||||
|
||||
.login-container {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
|
||||
h2 {
|
||||
font-size: 28px;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.demo-accounts {
|
||||
margin-top: 24px;
|
||||
|
||||
.account-list {
|
||||
.account-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #e6e8eb;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
148
frontend/src/views/user/notifications.vue
Normal file
148
frontend/src/views/user/notifications.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div class="notifications-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">消息通知</h2>
|
||||
<el-button v-if="userStore.unreadCount > 0" type="primary" text @click="markAllRead">全部已读</el-button>
|
||||
</div>
|
||||
|
||||
<div class="notification-list" v-loading="loading">
|
||||
<template v-if="notifications.length > 0">
|
||||
<div
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
class="notification-item"
|
||||
:class="{ unread: !notification.isRead }"
|
||||
@click="handleClick(notification)"
|
||||
>
|
||||
<el-icon :size="24" class="notification-icon" :class="notification.type">
|
||||
<component :is="getIcon(notification.type)" />
|
||||
</el-icon>
|
||||
<div class="notification-content">
|
||||
<div class="notification-header">
|
||||
<span class="notification-title">{{ notification.title }}</span>
|
||||
<span class="notification-time">{{ notification.createdAt }}</span>
|
||||
</div>
|
||||
<p class="notification-text">{{ notification.content }}</p>
|
||||
</div>
|
||||
<span v-if="!notification.isRead" class="unread-dot"></span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-empty description="暂无消息" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useUserStore } from '@/stores'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loading = false
|
||||
const notifications = computed(() => userStore.notifications)
|
||||
|
||||
onMounted(() => {
|
||||
userStore.loadNotifications()
|
||||
})
|
||||
|
||||
const getIcon = (type) => {
|
||||
const icons = {
|
||||
system: 'Bell',
|
||||
order: 'Document',
|
||||
point: 'Coin',
|
||||
interaction: 'ChatDotRound'
|
||||
}
|
||||
return icons[type] || 'Bell'
|
||||
}
|
||||
|
||||
const handleClick = (notification) => {
|
||||
if (!notification.isRead) {
|
||||
userStore.markNotificationRead(notification.id)
|
||||
}
|
||||
}
|
||||
|
||||
const markAllRead = () => {
|
||||
userStore.markAllNotificationsRead()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.notifications-page {
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-list {
|
||||
.notification-item {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
&.unread {
|
||||
background: #ecf5ff;
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
&.system { color: #409eff; }
|
||||
&.order { color: #67c23a; }
|
||||
&.point { color: #e6a23c; }
|
||||
&.interaction { color: #f56c6c; }
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
flex: 1;
|
||||
|
||||
.notification-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.notification-title {
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.notification-time {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-text {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.unread-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #f56c6c;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
218
frontend/src/views/user/orders.vue
Normal file
218
frontend/src/views/user/orders.vue
Normal file
@@ -0,0 +1,218 @@
|
||||
<template>
|
||||
<div class="orders-page">
|
||||
<h2 class="page-title">我的订单</h2>
|
||||
<el-tabs v-model="activeTab" @tab-change="handleTabChange">
|
||||
<el-tab-pane label="全部" name="all" />
|
||||
<el-tab-pane label="待支付" name="pending" />
|
||||
<el-tab-pane label="已完成" name="completed" />
|
||||
<el-tab-pane label="已退款" name="refunded" />
|
||||
</el-tabs>
|
||||
|
||||
<div class="order-list" v-loading="loading">
|
||||
<template v-if="filteredOrders.length > 0">
|
||||
<div v-for="order in filteredOrders" :key="order.id" class="order-item">
|
||||
<div class="order-header">
|
||||
<span class="order-id">订单号:{{ order.id }}</span>
|
||||
<span class="order-time">{{ order.createdAt }}</span>
|
||||
<el-tag :type="getStatusType(order.status)" size="small">
|
||||
{{ getStatusText(order.status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="order-content">
|
||||
<img :src="order.skillCover" class="skill-cover" />
|
||||
<div class="skill-info">
|
||||
<h4 class="skill-name">{{ order.skillName }}</h4>
|
||||
<div class="price-info">
|
||||
<span v-if="order.payType === 'points'" class="price">
|
||||
{{ order.paidPoints }}积分
|
||||
</span>
|
||||
<span v-else-if="order.payType === 'cash'" class="price">
|
||||
¥{{ order.paidAmount || order.price }}
|
||||
</span>
|
||||
<span v-else class="price">
|
||||
¥{{ order.paidAmount }} + {{ order.paidPoints }}积分
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="order-actions">
|
||||
<template v-if="order.status === 'pending'">
|
||||
<el-button type="primary" size="small" @click="goPay(order.id)">
|
||||
去支付
|
||||
</el-button>
|
||||
<el-button size="small" @click="cancelOrder(order.id)">
|
||||
取消
|
||||
</el-button>
|
||||
</template>
|
||||
<template v-else-if="order.status === 'completed'">
|
||||
<el-button type="primary" size="small" @click="viewDetail(order.id)">
|
||||
查看详情
|
||||
</el-button>
|
||||
</template>
|
||||
<el-button size="small" text @click="viewDetail(order.id)">
|
||||
订单详情
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-empty description="暂无订单">
|
||||
<el-button type="primary" @click="$router.push('/skills')">去逛逛</el-button>
|
||||
</el-empty>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore, useOrderStore } from '@/stores'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const orderStore = useOrderStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const activeTab = ref('all')
|
||||
|
||||
const orders = computed(() => orderStore.orders)
|
||||
|
||||
const filteredOrders = computed(() => {
|
||||
if (activeTab.value === 'all') {
|
||||
return orders.value
|
||||
}
|
||||
return orders.value.filter(o => o.status === activeTab.value)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (userStore.user) {
|
||||
orderStore.loadUserOrders(userStore.user.id)
|
||||
}
|
||||
})
|
||||
|
||||
const getStatusType = (status) => {
|
||||
const types = {
|
||||
pending: 'warning',
|
||||
completed: 'success',
|
||||
cancelled: 'info',
|
||||
refunded: 'danger'
|
||||
}
|
||||
return types[status] || 'info'
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const texts = {
|
||||
pending: '待支付',
|
||||
completed: '已完成',
|
||||
cancelled: '已取消',
|
||||
refunded: '已退款'
|
||||
}
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
const handleTabChange = () => {
|
||||
// Tab change logic
|
||||
}
|
||||
|
||||
const goPay = (orderId) => {
|
||||
router.push(`/pay/${orderId}`)
|
||||
}
|
||||
|
||||
const cancelOrder = (orderId) => {
|
||||
ElMessageBox.confirm('确定要取消该订单吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
const result = orderStore.cancelOrder(orderId, userStore.user.id)
|
||||
if (result.success) {
|
||||
ElMessage.success('订单已取消')
|
||||
} else {
|
||||
ElMessage.error(result.message)
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const viewDetail = (orderId) => {
|
||||
router.push(`/order/${orderId}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.orders-page {
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
color: #303133;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.order-list {
|
||||
margin-top: 16px;
|
||||
|
||||
.order-item {
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
|
||||
.order-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
|
||||
.order-id {
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.order-time {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.order-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
gap: 16px;
|
||||
|
||||
.skill-cover {
|
||||
width: 80px;
|
||||
height: 60px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.skill-info {
|
||||
flex: 1;
|
||||
|
||||
.skill-name {
|
||||
font-size: 15px;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.price-info {
|
||||
.price {
|
||||
color: #f56c6c;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.order-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
322
frontend/src/views/user/points.vue
Normal file
322
frontend/src/views/user/points.vue
Normal file
@@ -0,0 +1,322 @@
|
||||
<template>
|
||||
<div class="points-page">
|
||||
<h2 class="page-title">积分中心</h2>
|
||||
|
||||
<div class="points-overview">
|
||||
<div class="points-card">
|
||||
<div class="points-main">
|
||||
<span class="points-value">{{ userStore.user?.points || 0 }}</span>
|
||||
<span class="points-label">可用积分</span>
|
||||
</div>
|
||||
<div class="points-actions">
|
||||
<el-button type="primary" @click="$router.push('/user/recharge')">充值积分</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="points-stats">
|
||||
<div class="stat-item">
|
||||
<span class="value">{{ userStore.user?.totalPoints || 0 }}</span>
|
||||
<span class="label">累计获得</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="value">{{ userStore.user?.continuousSignDays || 0 }}</span>
|
||||
<span class="label">连续签到</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="value">{{ userStore.user?.totalSignDays || 0 }}</span>
|
||||
<span class="label">累计签到</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="quick-actions">
|
||||
<div class="action-item" @click="handleSign" :class="{ disabled: userStore.user?.signedToday }">
|
||||
<el-icon :size="24"><Calendar /></el-icon>
|
||||
<span class="action-label">每日签到</span>
|
||||
<span class="action-desc">{{ userStore.user?.signedToday ? '今日已签' : '+10积分起' }}</span>
|
||||
</div>
|
||||
<div class="action-item" @click="$router.push('/user/invite')">
|
||||
<el-icon :size="24"><Share /></el-icon>
|
||||
<span class="action-label">邀请好友</span>
|
||||
<span class="action-desc">+100积分/人</span>
|
||||
</div>
|
||||
<div class="action-item" @click="handleJoinGroup" :class="{ disabled: userStore.user?.joinedGroup }">
|
||||
<el-icon :size="24"><ChatDotRound /></el-icon>
|
||||
<span class="action-label">加入社群</span>
|
||||
<span class="action-desc">{{ userStore.user?.joinedGroup ? '已加入' : '+50积分' }}</span>
|
||||
</div>
|
||||
<div class="action-item" @click="$router.push('/user/recharge')">
|
||||
<el-icon :size="24"><Wallet /></el-icon>
|
||||
<span class="action-label">充值赠送</span>
|
||||
<span class="action-desc">多充多送</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="records-section">
|
||||
<div class="section-header">
|
||||
<h3>积分明细</h3>
|
||||
<el-radio-group v-model="recordType" size="small">
|
||||
<el-radio-button label="all">全部</el-radio-button>
|
||||
<el-radio-button label="income">收入</el-radio-button>
|
||||
<el-radio-button label="expense">支出</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<div class="records-list" v-loading="loading">
|
||||
<template v-if="filteredRecords.length > 0">
|
||||
<div v-for="record in filteredRecords" :key="record.id" class="record-item">
|
||||
<div class="record-info">
|
||||
<span class="record-desc">{{ record.description }}</span>
|
||||
<span class="record-time">{{ record.createdAt }}</span>
|
||||
</div>
|
||||
<span class="record-amount" :class="record.type">
|
||||
{{ record.type === 'income' ? '+' : '-' }}{{ record.amount }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-empty description="暂无记录" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useUserStore, usePointStore } from '@/stores'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const pointStore = usePointStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const recordType = ref('all')
|
||||
|
||||
const filteredRecords = computed(() => {
|
||||
if (recordType.value === 'all') {
|
||||
return pointStore.records
|
||||
}
|
||||
return pointStore.records.filter(r => r.type === recordType.value)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (userStore.user) {
|
||||
pointStore.loadUserRecords(userStore.user.id)
|
||||
pointStore.loadRechargeTiers()
|
||||
}
|
||||
})
|
||||
|
||||
const handleSign = () => {
|
||||
if (userStore.user?.signedToday) {
|
||||
ElMessage.info('今日已签到')
|
||||
return
|
||||
}
|
||||
const result = userStore.dailySign()
|
||||
if (result.success) {
|
||||
ElMessage.success(result.message)
|
||||
pointStore.loadUserRecords(userStore.user.id)
|
||||
} else {
|
||||
ElMessage.error(result.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleJoinGroup = () => {
|
||||
if (userStore.user?.joinedGroup) {
|
||||
ElMessage.info('您已加入过社群')
|
||||
return
|
||||
}
|
||||
ElMessageBox.alert(
|
||||
'请扫描下方二维码加入技术交流群,加入后联系客服验证即可获得积分奖励',
|
||||
'加入社群',
|
||||
{
|
||||
confirmButtonText: '我知道了',
|
||||
type: 'info'
|
||||
}
|
||||
).then(() => {
|
||||
const result = userStore.joinGroup()
|
||||
if (result.success) {
|
||||
ElMessage.success(result.message)
|
||||
pointStore.loadUserRecords(userStore.user.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.points-page {
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
color: #303133;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.points-overview {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.points-card {
|
||||
flex: 1;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
.points-main {
|
||||
.points-value {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.points-label {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.points-stats {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
||||
.stat-item {
|
||||
flex: 1;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
|
||||
.value {
|
||||
display: block;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.action-item {
|
||||
background: #fff;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
border-color: #409eff;
|
||||
background: #ecf5ff;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
color: #409eff;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.action-label {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
color: #303133;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.action-desc {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.records-section {
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
|
||||
.records-list {
|
||||
.record-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.record-info {
|
||||
.record-desc {
|
||||
display: block;
|
||||
color: #303133;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.record-time {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
.record-amount {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
|
||||
&.income {
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
&.expense {
|
||||
color: #f56c6c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.points-page {
|
||||
.points-overview {
|
||||
flex-direction: column;
|
||||
|
||||
.points-stats {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
158
frontend/src/views/user/profile.vue
Normal file
158
frontend/src/views/user/profile.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<div class="profile-page">
|
||||
<h2 class="page-title">个人资料</h2>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||
<el-form-item label="头像">
|
||||
<div class="avatar-upload">
|
||||
<el-avatar :size="80" :src="form.avatar">
|
||||
{{ form.nickname?.charAt(0) }}
|
||||
</el-avatar>
|
||||
<el-button size="small" class="upload-btn">更换头像</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="昵称" prop="nickname">
|
||||
<el-input v-model="form.nickname" placeholder="请输入昵称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号">
|
||||
<el-input :value="userStore.user?.phone" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input v-model="form.email" placeholder="请输入邮箱" />
|
||||
</el-form-item>
|
||||
<el-form-item label="个人简介">
|
||||
<el-input v-model="form.bio" type="textarea" :rows="3" placeholder="介绍一下自己吧" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="handleSave">保存修改</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<h3 class="section-title">账号信息</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-item">
|
||||
<span class="label">会员等级</span>
|
||||
<span class="value">
|
||||
<el-tag type="warning">{{ userStore.user?.levelName }}</el-tag>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">成长值</span>
|
||||
<span class="value">{{ userStore.user?.growthValue || 0 }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">注册时间</span>
|
||||
<span class="value">{{ userStore.user?.createdAt }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">VIP状态</span>
|
||||
<span class="value">
|
||||
<el-tag v-if="userStore.user?.isVip" type="success">VIP会员</el-tag>
|
||||
<el-tag v-else type="info">普通用户</el-tag>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const formRef = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
avatar: '',
|
||||
nickname: '',
|
||||
email: '',
|
||||
bio: ''
|
||||
})
|
||||
|
||||
const rules = {
|
||||
nickname: [
|
||||
{ required: true, message: '请输入昵称', trigger: 'blur' }
|
||||
],
|
||||
email: [
|
||||
{ type: 'email', message: '请输入正确的邮箱', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (userStore.user) {
|
||||
form.avatar = userStore.user.avatar
|
||||
form.nickname = userStore.user.nickname
|
||||
form.email = userStore.user.email || ''
|
||||
form.bio = userStore.user.bio || ''
|
||||
}
|
||||
})
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
loading.value = true
|
||||
const result = userStore.updateUserInfo({
|
||||
avatar: form.avatar,
|
||||
nickname: form.nickname,
|
||||
email: form.email,
|
||||
bio: form.bio
|
||||
})
|
||||
loading.value = false
|
||||
|
||||
if (result.success) {
|
||||
ElMessage.success('保存成功')
|
||||
} else {
|
||||
ElMessage.error(result.message)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.profile-page {
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
color: #303133;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
color: #303133;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.avatar-upload {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.upload-btn {
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.info-list {
|
||||
.info-item {
|
||||
display: flex;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
|
||||
.label {
|
||||
width: 100px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
323
frontend/src/views/user/recharge.vue
Normal file
323
frontend/src/views/user/recharge.vue
Normal file
@@ -0,0 +1,323 @@
|
||||
<template>
|
||||
<div class="recharge-page">
|
||||
<h2 class="page-title">积分充值</h2>
|
||||
|
||||
<div class="current-balance">
|
||||
<span class="label">当前积分</span>
|
||||
<span class="value">{{ userStore.user?.points || 0 }}</span>
|
||||
</div>
|
||||
|
||||
<div class="recharge-tiers">
|
||||
<h3>选择充值金额</h3>
|
||||
<div class="tier-grid">
|
||||
<div
|
||||
v-for="tier in rechargeTiers"
|
||||
:key="tier.amount"
|
||||
class="tier-item"
|
||||
:class="{ active: selectedTier?.amount === tier.amount }"
|
||||
@click="selectTier(tier)"
|
||||
>
|
||||
<div class="tier-amount">¥{{ tier.amount }}</div>
|
||||
<div class="tier-bonus">
|
||||
<span class="bonus-label">赠送</span>
|
||||
<span class="bonus-value">+{{ tier.bonus }}</span>
|
||||
</div>
|
||||
<div class="tier-total">共得 {{ tier.amount * 10 + tier.bonus }}积分</div>
|
||||
<el-icon v-if="selectedTier?.amount === tier.amount" class="check-icon"><Check /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="custom-recharge">
|
||||
<h3>自定义充值</h3>
|
||||
<div class="custom-input">
|
||||
<el-input-number
|
||||
v-model="customAmount"
|
||||
:min="1"
|
||||
:max="10000"
|
||||
:step="10"
|
||||
size="large"
|
||||
@change="selectedTier = null"
|
||||
/>
|
||||
<span class="unit">元</span>
|
||||
<span class="custom-info">= {{ customAmount * 10 }}积分</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="recharge-summary">
|
||||
<div class="summary-item">
|
||||
<span class="label">充值金额</span>
|
||||
<span class="value">¥{{ selectedTier?.amount || customAmount }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="label">赠送积分</span>
|
||||
<span class="value bonus">+{{ selectedTier?.bonus || 0 }}</span>
|
||||
</div>
|
||||
<div class="summary-item total">
|
||||
<span class="label">共获得</span>
|
||||
<span class="value">{{ (selectedTier?.amount || customAmount) * 10 + (selectedTier?.bonus || 0) }}积分</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="recharge-actions">
|
||||
<el-button type="primary" size="large" :loading="loading" @click="handleRecharge">立即充值</el-button>
|
||||
</div>
|
||||
|
||||
<div class="recharge-tips">
|
||||
<h4>充值说明</h4>
|
||||
<ul>
|
||||
<li>充值金额将转换为积分,1元=10积分</li>
|
||||
<li>充值金额越高,赠送积分越多</li>
|
||||
<li>积分可用于兑换平台内所有付费Skill</li>
|
||||
<li>充值后积分即时到账,不支持退款</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useUserStore, usePointStore } from '@/stores'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const pointStore = usePointStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const rechargeTiers = ref([])
|
||||
const selectedTier = ref(null)
|
||||
const customAmount = ref(10)
|
||||
|
||||
onMounted(() => {
|
||||
pointStore.loadRechargeTiers()
|
||||
rechargeTiers.value = pointStore.rechargeTiers
|
||||
})
|
||||
|
||||
const selectTier = (tier) => {
|
||||
selectedTier.value = tier
|
||||
}
|
||||
|
||||
const handleRecharge = () => {
|
||||
const amount = selectedTier.value?.amount || customAmount.value
|
||||
if (!amount || amount < 1) {
|
||||
ElMessage.warning('请选择或输入充值金额')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
setTimeout(() => {
|
||||
const result = pointStore.recharge(userStore.user.id, amount)
|
||||
loading.value = false
|
||||
|
||||
if (result.success) {
|
||||
ElMessage.success(result.message)
|
||||
userStore.refreshUser()
|
||||
pointStore.loadUserRecords(userStore.user.id)
|
||||
} else {
|
||||
ElMessage.error(result.message)
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.recharge-page {
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
color: #303133;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.current-balance {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.label {
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.recharge-tiers {
|
||||
margin-bottom: 24px;
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
color: #303133;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tier-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 12px;
|
||||
|
||||
.tier-item {
|
||||
background: #fff;
|
||||
border: 2px solid #ebeef5;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: #409eff;
|
||||
background: #ecf5ff;
|
||||
}
|
||||
|
||||
.tier-amount {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tier-bonus {
|
||||
margin-bottom: 8px;
|
||||
|
||||
.bonus-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.bonus-value {
|
||||
color: #e6a23c;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.tier-total {
|
||||
font-size: 12px;
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
color: #409eff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-recharge {
|
||||
margin-bottom: 24px;
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
color: #303133;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.custom-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.unit {
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.custom-info {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.recharge-summary {
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.summary-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
|
||||
.label {
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
|
||||
&.bonus {
|
||||
color: #e6a23c;
|
||||
}
|
||||
}
|
||||
|
||||
&.total {
|
||||
border-top: 1px solid #ebeef5;
|
||||
margin-top: 8px;
|
||||
padding-top: 16px;
|
||||
|
||||
.value {
|
||||
font-size: 20px;
|
||||
color: #409eff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.recharge-actions {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.el-button {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.recharge-tips {
|
||||
h4 {
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-left: 20px;
|
||||
|
||||
li {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
line-height: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.recharge-page {
|
||||
.tier-grid {
|
||||
grid-template-columns: repeat(3, 1fr) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.recharge-page {
|
||||
.tier-grid {
|
||||
grid-template-columns: repeat(2, 1fr) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
227
frontend/src/views/user/register.vue
Normal file
227
frontend/src/views/user/register.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<div class="register-page">
|
||||
<div class="register-container">
|
||||
<div class="register-card">
|
||||
<div class="register-header">
|
||||
<h2>注册</h2>
|
||||
<p>创建您的 OpenClaw Skills 账号</p>
|
||||
</div>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" @submit.prevent="handleRegister">
|
||||
<el-form-item prop="phone">
|
||||
<el-input
|
||||
v-model="form.phone"
|
||||
placeholder="请输入手机号"
|
||||
size="large"
|
||||
prefix-icon="Phone"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="请设置密码(至少6位)"
|
||||
size="large"
|
||||
prefix-icon="Lock"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="confirmPassword">
|
||||
<el-input
|
||||
v-model="form.confirmPassword"
|
||||
type="password"
|
||||
placeholder="请确认密码"
|
||||
size="large"
|
||||
prefix-icon="Lock"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="nickname">
|
||||
<el-input
|
||||
v-model="form.nickname"
|
||||
placeholder="请输入昵称(选填)"
|
||||
size="large"
|
||||
prefix-icon="User"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="inviteCode">
|
||||
<el-input
|
||||
v-model="form.inviteCode"
|
||||
placeholder="邀请码(选填)"
|
||||
size="large"
|
||||
prefix-icon="Ticket"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="agreement">
|
||||
<el-checkbox v-model="form.agreement">
|
||||
我已阅读并同意
|
||||
<el-button type="primary" text>《用户协议》</el-button>
|
||||
和
|
||||
<el-button type="primary" text>《隐私政策》</el-button>
|
||||
</el-checkbox>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
@click="handleRegister"
|
||||
style="width: 100%"
|
||||
>
|
||||
注册
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="register-footer">
|
||||
<span>已有账号?</span>
|
||||
<el-button type="primary" text @click="$router.push('/login')">立即登录</el-button>
|
||||
</div>
|
||||
<div class="register-bonus">
|
||||
<el-icon :size="20" color="#e6a23c"><Present /></el-icon>
|
||||
<span>注册即送 <strong>300积分</strong>,可免费兑换优质Skill</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const formRef = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
phone: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
nickname: '',
|
||||
inviteCode: '',
|
||||
agreement: false
|
||||
})
|
||||
|
||||
const validatePass = (rule, value, callback) => {
|
||||
if (value === '') {
|
||||
callback(new Error('请再次输入密码'))
|
||||
} else if (value !== form.password) {
|
||||
callback(new Error('两次输入密码不一致'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
const validateAgreement = (rule, value, callback) => {
|
||||
if (!value) {
|
||||
callback(new Error('请阅读并同意用户协议'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
const rules = {
|
||||
phone: [
|
||||
{ required: true, message: '请输入手机号', trigger: 'blur' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请设置密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码至少6位', trigger: 'blur' }
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, validator: validatePass, trigger: 'blur' }
|
||||
],
|
||||
agreement: [
|
||||
{ validator: validateAgreement, trigger: 'change' }
|
||||
]
|
||||
}
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
loading.value = true
|
||||
const result = await userStore.register({
|
||||
phone: form.phone,
|
||||
password: form.password,
|
||||
nickname: form.nickname,
|
||||
inviteCode: form.inviteCode
|
||||
})
|
||||
loading.value = false
|
||||
|
||||
if (result.success) {
|
||||
ElMessage.success('注册成功,已获得300积分')
|
||||
router.push('/')
|
||||
} else {
|
||||
ElMessage.error(result.message)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.register-page {
|
||||
min-height: calc(100vh - 64px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 40px 20px;
|
||||
|
||||
.register-container {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.register-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||
|
||||
.register-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
|
||||
h2 {
|
||||
font-size: 28px;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
.register-footer {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.register-bonus {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
padding: 12px;
|
||||
background: #fdf6ec;
|
||||
border-radius: 8px;
|
||||
color: #e6a23c;
|
||||
font-size: 14px;
|
||||
|
||||
strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
214
frontend/src/views/user/settings.vue
Normal file
214
frontend/src/views/user/settings.vue
Normal file
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<h2 class="page-title">账号设置</h2>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>修改密码</h3>
|
||||
<el-form ref="passwordFormRef" :model="passwordForm" :rules="passwordRules" label-width="100px">
|
||||
<el-form-item label="原密码" prop="oldPassword">
|
||||
<el-input v-model="passwordForm.oldPassword" type="password" show-password placeholder="请输入原密码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码" prop="newPassword">
|
||||
<el-input v-model="passwordForm.newPassword" type="password" show-password placeholder="请输入新密码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码" prop="confirmPassword">
|
||||
<el-input v-model="passwordForm.confirmPassword" type="password" show-password placeholder="请确认新密码" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="changePassword">修改密码</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>通知设置</h3>
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">站内通知</span>
|
||||
<span class="setting-desc">接收系统消息、订单通知等</span>
|
||||
</div>
|
||||
<el-switch v-model="settings.notification" />
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">邮件通知</span>
|
||||
<span class="setting-desc">重要消息通过邮件提醒</span>
|
||||
</div>
|
||||
<el-switch v-model="settings.emailNotify" />
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">短信通知</span>
|
||||
<span class="setting-desc">订单状态变更短信提醒</span>
|
||||
</div>
|
||||
<el-switch v-model="settings.smsNotify" />
|
||||
</div>
|
||||
<el-button type="primary" style="margin-top: 16px" @click="saveSettings">保存设置</el-button>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<div class="settings-section danger-zone">
|
||||
<h3>危险操作</h3>
|
||||
<div class="danger-item">
|
||||
<div class="danger-info">
|
||||
<span class="danger-label">退出登录</span>
|
||||
<span class="danger-desc">退出当前账号</span>
|
||||
</div>
|
||||
<el-button type="danger" plain @click="handleLogout">退出登录</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const passwordFormRef = ref(null)
|
||||
|
||||
const passwordForm = reactive({
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
const validateConfirm = (rule, value, callback) => {
|
||||
if (value === '') {
|
||||
callback(new Error('请确认新密码'))
|
||||
} else if (value !== passwordForm.newPassword) {
|
||||
callback(new Error('两次输入密码不一致'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
const passwordRules = {
|
||||
oldPassword: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
|
||||
newPassword: [
|
||||
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码至少6位', trigger: 'blur' }
|
||||
],
|
||||
confirmPassword: [{ required: true, validator: validateConfirm, trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const settings = reactive({
|
||||
notification: true,
|
||||
emailNotify: true,
|
||||
smsNotify: false
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (userStore.user?.settings) {
|
||||
settings.notification = userStore.user.settings.notification
|
||||
settings.emailNotify = userStore.user.settings.emailNotify
|
||||
settings.smsNotify = userStore.user.settings.smsNotify
|
||||
}
|
||||
})
|
||||
|
||||
const changePassword = async () => {
|
||||
if (!passwordFormRef.value) return
|
||||
await passwordFormRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
if (passwordForm.oldPassword !== userStore.user?.password) {
|
||||
ElMessage.error('原密码错误')
|
||||
return
|
||||
}
|
||||
const result = userStore.updateUserInfo({ password: passwordForm.newPassword })
|
||||
if (result.success) {
|
||||
ElMessage.success('密码修改成功')
|
||||
passwordForm.oldPassword = ''
|
||||
passwordForm.newPassword = ''
|
||||
passwordForm.confirmPassword = ''
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const saveSettings = () => {
|
||||
const result = userStore.updateUserInfo({ settings: { ...settings } })
|
||||
if (result.success) {
|
||||
ElMessage.success('设置已保存')
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
userStore.logout()
|
||||
ElMessage.success('已退出登录')
|
||||
router.push('/')
|
||||
}).catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.settings-page {
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
color: #303133;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
color: #303133;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
|
||||
.setting-info {
|
||||
.setting-label {
|
||||
display: block;
|
||||
color: #303133;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.setting-desc {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.danger-zone {
|
||||
.danger-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
background: #fef0f0;
|
||||
border-radius: 8px;
|
||||
|
||||
.danger-info {
|
||||
.danger-label {
|
||||
display: block;
|
||||
color: #f56c6c;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.danger-desc {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
146
frontend/src/views/user/skills.vue
Normal file
146
frontend/src/views/user/skills.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div class="my-skills-page">
|
||||
<h2 class="page-title">我的Skill</h2>
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="已获取" name="purchased" />
|
||||
<el-tab-pane label="收藏" name="favorites" />
|
||||
</el-tabs>
|
||||
|
||||
<div class="skill-list" v-loading="loading">
|
||||
<template v-if="displaySkills.length > 0">
|
||||
<div class="skill-grid">
|
||||
<div v-for="skill in displaySkills" :key="skill.id" class="skill-item">
|
||||
<img :src="skill.cover" class="skill-cover" />
|
||||
<div class="skill-info">
|
||||
<h4 class="skill-name text-ellipsis">{{ skill.name }}</h4>
|
||||
<p class="skill-desc text-ellipsis-2">{{ skill.description }}</p>
|
||||
<div class="skill-meta">
|
||||
<span v-if="skill.purchasedAt" class="meta-item">
|
||||
获取时间:{{ skill.purchasedAt }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="skill-actions">
|
||||
<el-button type="primary" size="small" @click="goToDetail(skill.id)">
|
||||
查看详情
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-empty :description="activeTab === 'purchased' ? '暂无已获取的Skill' : '暂无收藏'">
|
||||
<el-button type="primary" @click="$router.push('/skills')">去逛逛</el-button>
|
||||
</el-empty>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore, useOrderStore, useSkillStore } from '@/stores'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const orderStore = useOrderStore()
|
||||
const skillStore = useSkillStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const activeTab = ref('purchased')
|
||||
|
||||
const purchasedSkills = computed(() => {
|
||||
if (!userStore.user) return []
|
||||
return orderStore.getUserPurchasedSkills(userStore.user.id)
|
||||
})
|
||||
|
||||
const favoriteSkills = computed(() => {
|
||||
return []
|
||||
})
|
||||
|
||||
const displaySkills = computed(() => {
|
||||
return activeTab.value === 'purchased' ? purchasedSkills.value : favoriteSkills.value
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (userStore.user) {
|
||||
orderStore.loadUserOrders(userStore.user.id)
|
||||
}
|
||||
})
|
||||
|
||||
const goToDetail = (skillId) => {
|
||||
router.push(`/skill/${skillId}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.my-skills-page {
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
color: #303133;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.skill-list {
|
||||
margin-top: 16px;
|
||||
|
||||
.skill-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
|
||||
.skill-item {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
|
||||
.skill-cover {
|
||||
width: 100px;
|
||||
height: 75px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skill-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.skill-name {
|
||||
font-size: 15px;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.skill-desc {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.skill-meta {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
.skill-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.my-skills-page {
|
||||
.skill-grid {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
16
frontend/vite.config.js
Normal file
16
frontend/vite.config.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
host: true
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user