Initial commit

This commit is contained in:
Developer
2026-03-17 12:09:43 +08:00
commit 70bedcf241
211 changed files with 31464 additions and 0 deletions

13
frontend/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

23
frontend/package.json Normal file
View 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
View File

@@ -0,0 +1,12 @@
<template>
<router-view />
</template>
<script setup>
</script>
<style>
#app {
min-height: 100vh;
}
</style>

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

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

View 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
}

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

View 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
View 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')

View 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

View 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
}

View 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
}
}
})

View 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
}
}
})

View 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'

View 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)
}
}
})

View 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)
}
}
})

View 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
View 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: '未登录' }
}
}
})

View 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;
}
}

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

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

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

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

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

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

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

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

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

View 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="请详细描述您的业务场景和需求,例如:&#10;1. 当前面临的问题或痛点&#10;2. 希望实现的功能目标&#10;3. 预期的使用场景和频率&#10;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>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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
View 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
}
})