This commit is contained in:
13121765685 2025-11-24 09:42:23 +08:00
parent 4926e47152
commit f9022e31c8
63 changed files with 9764 additions and 1084 deletions

45
.github/prompts/webtsc.prompt.md vendored Normal file
View File

@ -0,0 +1,45 @@
---
agent: agent
---
如果前端项目,需要考虑以下几点:
1.你是一名精通 Vue3纯 JavaScript不使用 TypeScript的前端开发专家专注于构建可适配移动端、桌面端、平板端的响应式页面并且开发的页面都会基于当前项目搭建的中英文框架支持中英文切换(页面中英文切换内容都是本地的,所以需要在项目中配置好对应的英文内容),和主题色切换请基于以下要求完成开发任务:
2.技术栈规范
核心框架:使用 Vue3Options API 或 Composition API 均可,优先推荐 Composition API 以提升代码复用性),禁止使用 TypeScript所有逻辑用原生 JavaScript 实现。
3.构建工具:基于 Vite 搭建项目,确保热更新效率和打包性能。
4.样式解决方案:
优先使用 Scoped CSS + CSS 变量实现组件样式隔离与主题定制,避免全局样式污染。
响应式布局必须结合 Flexbox/Grid并通过媒体查询media queries适配不同设备尺寸参考断点移动端 <768px平板 768px-1024px桌面端> 1024px
可选集成 Tailwind CSS若使用需说明配置方案或原生 CSS 实现
5.状态管理:简单场景用 Vue3 内置的reactive/ref+provide/inject复杂场景使用 Pinia需说明 Store 设计逻辑,禁止使用 Vuex
6.路由管理:使用 Vue Router 4配置动态路由和嵌套路由确保多端路由跳转体验一致移动端可优化为底部导航桌面端为顶部 / 侧边导航)。
7.UI 组件:
如需使用 UI 库,优先选择轻量型适配多端的框架(如 Vant 4 适配移动端Element Plus 适配桌面端,需说明多端组件切换逻辑),如果使用到Element Plus的图标需要查一下图标库有没有该图标。
自定义组件需实现自适应尺寸(字体、间距、宽高随屏幕尺寸动态调整),避免固定像素值导致的适配问题。
8.交互优化:
移动端需添加触摸反馈(如点击态、滑动动效),支持手势操作(如左右滑动切换页面)。
桌面端优化鼠标悬停效果、键盘导航支持。
平板端兼顾触摸与鼠标操作,优化横 / 竖屏切换体验。
多端适配核心要求
9.布局适配:
采用 “移动优先” 原则设计布局,通过媒体查询逐步扩展至平板和桌面端。
关键区域(如头部、内容区、底部)需在不同设备上重排:移动端单列布局,平板双列 / 混合布局,桌面端多列布局。
图片 / 视频使用object-fit和响应式 srcset确保在不同分辨率下清晰显示且不拉伸。
10.性能优化:
实现组件懒加载基于defineAsyncComponent和路由懒加载减少首屏加载时间。
移动端禁止不必要的动画和重绘,确保 60fps 流畅度;桌面端可适当增加过渡效果提升体验。
监听窗口尺寸变化resize事件动态调整组件状态避免频繁触发需添加防抖处理
11.兼容性:
移动端兼容 iOS 13+、Android 8+;桌面端兼容 Chrome 80+、Firefox 75+、Edge 80+平板端覆盖主流设备iPadOS、Android 平板)。
避免使用 ES6 + 以上高级语法(或通过 Babel 转译),确保低版本浏览器兼容性。
12.设计风格
- 主色调:深紫色(#6B46C1用于主要操作浅紫色#A78BFA用于强调
- 辅助色:深灰色(#1F2937用于文本浅灰色#F3F4F6用于背景
- 按钮样式圆角设计8px半径微妙阴影悬停效果
- 字体排版Inter字体系列16px基础大小响应式缩放
- 布局风格基于卡片的设计统一间距8px网格系统
- 图标风格Feather图标库UI元素统一24px大小
- 动画效果平滑过渡200ms缓入缓出加载骨架屏
13.交付标准
代码结构清晰,遵循 Vue3 最佳实践(如组件拆分粒度合理、逻辑与 UI 分离)。
提供完整的多端测试报告(说明在不同设备 / 尺寸下的测试结果及适配方案)。
附带 README说明项目启动、打包命令以及响应式布局的核心实现逻辑。

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -62,31 +62,48 @@
<el-container class="admin-container">
<!-- 侧边栏 -->
<el-aside :class="['admin-sidebar', { 'collapsed': sidebarCollapsed }]">
<el-menu
:default-active="activeMenu"
class="admin-menu"
:collapse="sidebarCollapsed"
router
>
<el-menu
:default-active="activeMenu"
class="admin-menu"
:collapse="sidebarCollapsed"
router
:default-openeds="openKeys"
:unique-opened="true"
:key="menuKey"
>
<el-menu-item index="/admin">
<el-icon><DataAnalysis /></el-icon>
<template #title>{{ t('admin.layout.dashboard') }}</template>
</el-menu-item>
<el-menu-item index="/admin/content-review">
<el-icon><Document /></el-icon>
<template #title>{{ t('admin.layout.contentReview') }}</template>
</el-menu-item>
<el-menu-item index="/admin/orders">
<el-icon><ShoppingCart /></el-icon>
<template #title>{{ t('admin.layout.orders') }}</template>
</el-menu-item>
<el-sub-menu index="/admin/orders">
<template #title>
<el-icon><ShoppingCart /></el-icon>
<span>{{ t('admin.layout.orders') }}</span>
</template>
<el-menu-item index="/admin/content-review">
<el-icon><Document /></el-icon>
<template #title>{{ t('admin.layout.contentReview') }}</template>
</el-menu-item>
<el-menu-item index="/admin/disassembly-orders">
<el-icon><EditPen /></el-icon>
<template #title>{{ t('admin.layout.disassemblyOrders') }}</template>
</el-menu-item>
<el-menu-item index="/admin/orders">
<el-icon><ShoppingCart /></el-icon>
<template #title>{{ t('admin.layout.orders') }}</template>
</el-menu-item>
</el-sub-menu>
<el-menu-item index="/admin/users">
<el-icon><UserFilled /></el-icon>
<template #title>{{ t('admin.layout.users') }}</template>
</el-menu-item>
</el-menu>
</el-aside>
@ -110,7 +127,7 @@
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useI18n } from 'vue-i18n'
@ -127,7 +144,8 @@ import {
DataAnalysis,
Document,
ShoppingCart,
UserFilled
UserFilled,
EditPen
} from '@element-plus/icons-vue'
const { t, locale } = useI18n()
@ -145,6 +163,30 @@ const currentLocale = computed(() => locale.value)
const username = computed(() => authStore.username)
const activeMenu = computed(() => route.path)
// index -> indexes
const submenuMap = {
'/admin/orders': ['/admin/content-review', '/admin/disassembly-orders', '/admin/orders']
}
// keys
const openKeys = ref([])
const menuKey = ref('root')
const updateOpenKeys = () => {
const parents = Object.keys(submenuMap).filter(parent => submenuMap[parent].includes(route.path))
openKeys.value = parents
menuKey.value = parents.join('|') || 'root'
}
//
watch(() => route.path, () => {
updateOpenKeys()
})
onMounted(() => {
updateOpenKeys()
})
//
const toggleSidebar = () => {
if (window.innerWidth < 768) {

View File

@ -133,7 +133,7 @@ export default {
endDate: 'End Date',
loading: 'Loading...',
refreshSuccess: 'Refresh successful',
confirm: 'Confirm',
confirm: 'Review',
cancel: 'Cancel',
save: 'Save',
delete: 'Delete',
@ -151,6 +151,7 @@ export default {
contentReview: 'Content Review',
orders: 'Order Management',
users: 'User Management',
disassemblyOrders: 'Order Processing',
logout: 'Logout',
profile: 'Profile',
settings: 'Settings'
@ -217,6 +218,11 @@ export default {
date: 'Order Date',
actions: 'Actions',
view: 'View',
confirm: 'Confirm',
process: 'Process',
ship: 'Ship',
viewLogistics: 'View Logistics',
refundNotice: 'Refund initiated automatically',
updateStatus: 'Update Status',
detail: 'Order Detail',
basicInfo: 'Basic Information',
@ -227,6 +233,13 @@ export default {
currentStatus: 'Current Status',
newStatus: 'New Status',
selectStatus: 'Select Status',
trackingNumber: 'Tracking Number',
carrier: 'Carrier',
shippingNote: 'Shipping Note',
logisticsTimeline: 'Logistics Timeline',
selectAction: 'Select Action',
availableActions: 'Available Actions',
customerNote: 'Customer Note',
stats: {
total: 'Total Orders',
pending: 'Pending',
@ -234,12 +247,11 @@ export default {
revenue: 'Total Revenue'
},
statusOptions: {
pending: 'Pending',
pendingConfirmation: 'Pending Review',
rejected: 'Rejected',
processing: 'Processing',
completed: 'Completed',
shipped: 'Shipped',
delivered: 'Delivered',
cancelled: 'Cancelled'
readyToShip: 'Ready to Ship',
shipped: 'Shipped'
},
paymentOptions: {
alipay: 'Alipay',
@ -267,12 +279,13 @@ export default {
approved: 'Approved',
rejected: 'Rejected',
pending: 'Pending Review',
rejectReason: 'Reject Reason',
rejectionReason: 'Reject Reason',
enterRejectReason: 'Please enter reject reason',
pleaseInputReason: 'Please enter reject reason',
modelPreview: 'Model Preview',
proceedToParts: 'Parts',
noRejectReason: 'Please enter reject reason',
noThumbnail: 'No thumbnail available for preview',
confirmApprove: 'Confirm approve this IP?',
confirmReject: 'Confirm reject this IP?',
enterFeedback: 'Please enter feedback',
@ -290,6 +303,10 @@ export default {
createTime: 'Create Time',
actions: 'Actions',
goToDisassembly: 'Disassembly',
zoomIn: 'Zoom In',
zoomOut: 'Zoom Out',
resetZoom: 'Reset',
openInNewTab: 'Open in New Tab',
stats: {
total: 'Total Reviews',
pending: 'Pending Review',
@ -312,6 +329,11 @@ export default {
username: 'Username',
email: 'Email',
phone: 'Phone',
avatar: 'Avatar',
realName: 'Real Name',
creatorLevel: 'Creator Level',
worksCount: 'Works Count',
bio: 'Bio',
lastLogin: 'Last Login',
loginCount: 'Login Count',
actions: 'Actions',
@ -339,6 +361,111 @@ export default {
admin: 'Administrator',
user: 'Regular User',
vip: 'VIP User'
},
creatorLevels: {
beginner: 'Beginner Creator',
intermediate: 'Intermediate Creator',
advanced: 'Advanced Creator',
master: 'Master Creator'
},
selectCreatorLevel: 'Select Creator Level'
},
disassemblyOrders: {
title: 'Disassembly Orders Management',
subtitle: 'Manage approved IP disassembly orders',
stats: {
total: 'Total Orders',
pending: 'Pending',
processing: 'Processing'
},
filters: {
status: 'Order Status',
dateRange: 'Date Range',
search: 'Search Orders',
searchPlaceholder: 'Enter order number or creator name',
allStatus: 'All Status',
reset: 'Reset',
refresh: 'Refresh',
filter: 'Filter',
statusOptions: {
pending: 'Pending',
processing: 'Processing'
}
},
list: {
title: 'Disassembly Orders List',
id: 'ID',
orderNumber: 'Order Number',
creatorName: 'Creator Name',
status: 'Status',
createTime: 'Create Time',
actions: 'Actions',
disassembly: 'Disassembly',
disassemblyType: 'Disassembly Type',
refresh: 'Refresh',
noData: 'No disassembly orders',
detail: 'Detail',
view: 'View',
delete: 'Delete'
},
detail: {
title: 'Disassembly Detail',
back: 'Back to List',
orderInfo: 'Order Information',
orderNumber: 'Order Number',
customerName: 'Customer Name',
modelName: 'Model Name',
createTime: 'Create Time',
status: 'Status',
process: 'Disassembly Process',
step1: 'Preview Image/Model Display',
step2: 'Disassembly Parts Display',
step3: 'Generated Model Display',
step4: 'Logistics Information',
preview: 'Preview',
disassembly: 'Disassembly',
generateModel: 'Generate Model',
export: 'Export',
ship: 'Ship',
previewDialog: 'Model Preview',
close: 'Close',
loading: 'Loading...',
previewDescription: 'This is a preview of the original 3D model. You can drag to rotate and scroll to zoom.',
disassemblyDescription: 'Model disassembly in progress, please wait...',
generateDescription: 'Generating disassembled model files...',
exportDescription: 'Exporting disassembled model...',
shipDescription: 'Please fill in shipping information',
shippingForm: {
trackingNumber: 'Tracking Number',
shippingCompany: 'Shipping Company',
shippingAddress: 'Shipping Address',
notes: 'Notes',
submit: 'Submit Shipping Info',
success: 'Shipping information submitted successfully',
error: 'Submission failed, please try again'
},
statusOptions: {
pending: 'Pending Disassembly',
processing: 'In Disassembly'
},
typeOptions: {
full: 'Full Disassembly',
partial: 'Partial Disassembly',
custom: 'Custom Disassembly'
}
},
messages: {
refreshSuccess: 'Refresh successful',
deleteConfirm: 'Are you sure to delete this order?',
deleteTitle: 'Delete Confirmation',
deleteSuccess: 'Delete successful',
disassemblySuccess: 'Disassembly successful',
generateSuccess: 'Model generation successful',
exportSuccess: 'Export successful',
shipSuccess: 'Shipping successful',
disassembleConfirm: 'Are you sure to start disassembling this order?',
disassembleTitle: 'Disassembly Confirmation',
alreadyProcessing: 'This order is already being processed, please do not repeat the operation'
}
}
}

View File

@ -156,9 +156,10 @@ export default {
layout: {
dashboard: '仪表板',
content: '内容审核',
contentReview: '内容审核',
contentReview: '订单审核',
orders: '订单管理',
users: '用户管理',
disassemblyOrders: '订单处理',
logout: '退出登录',
profile: '个人资料',
settings: '设置',
@ -202,6 +203,11 @@ export default {
date: '下单日期',
actions: '操作',
view: '查看',
confirm: '去审核',
process: '去处理',
ship: '发货',
viewLogistics: '查看物流',
refundNotice: '已自动发起退款',
updateStatus: '更新状态',
detail: '订单详情',
basicInfo: '基本信息',
@ -212,6 +218,13 @@ export default {
currentStatus: '当前状态',
newStatus: '新状态',
selectStatus: '选择状态',
trackingNumber: '物流单号',
carrier: '物流公司',
shippingNote: '发货备注',
logisticsTimeline: '物流时间线',
selectAction: '选择操作',
availableActions: '可用操作',
customerNote: '客户备注',
stats: {
total: '总订单',
pending: '待处理',
@ -219,12 +232,11 @@ export default {
revenue: '总收入'
},
statusOptions: {
pending: '待处理',
processing: '处理中',
completed: '已完成',
shipped: '已发货',
delivered: '已送达',
cancelled: '已取消'
pendingConfirmation: '待审核',
rejected: '已拒绝',
processing: '待处理',
readyToShip: '待发货',
shipped: '已发货'
},
paymentOptions: {
alipay: '支付宝',
@ -252,12 +264,13 @@ export default {
approved: '已通过',
rejected: '已拒绝',
pending: '待审核',
rejectReason: '拒绝原因',
rejectionReason: '拒绝原因',
enterRejectReason: '请输入拒绝原因',
pleaseInputReason: '请输入拒绝原因',
modelPreview: '模型预览',
proceedToParts: '拆件',
noRejectReason: '请输入拒绝原因',
noThumbnail: '没有可预览的缩略图',
confirmApprove: '确认通过此IP',
confirmReject: '确认拒绝此IP',
enterFeedback: '请输入反馈',
@ -275,6 +288,10 @@ export default {
createTime: '创建时间',
actions: '操作',
goToDisassembly: '拆件',
zoomIn: '放大',
zoomOut: '缩小',
resetZoom: '重置',
openInNewTab: '新窗口打开',
stats: {
total: '总审核数',
pending: '待审核',
@ -287,6 +304,104 @@ export default {
rejected: '已拒绝'
}
},
disassemblyOrders: {
title: '拆件订单管理',
subtitle: '管理已通过审核的IP拆件订单',
stats: {
total: '总订单数',
pending: '待拆件',
processing: '拆件中'
},
filters: {
status: '订单状态',
dateRange: '日期范围',
search: '搜索订单',
searchPlaceholder: '请输入订单号、客户或模型名称',
allStatus: '全部状态',
reset: '重置',
refresh: '刷新',
filter: '筛选',
statusOptions: {
pending: '待拆件',
processing: '拆件中'
}
},
list: {
title: '拆件订单列表',
id: 'ID',
orderNumber: '订单号',
creatorName: '创作者名称',
status: '状态',
createTime: '创建时间',
actions: '操作',
disassembly: '拆件',
disassemblyType: '拆件类型',
refresh: '刷新',
noData: '暂无拆件订单',
detail: '详情',
view: '查看',
delete: '删除'
},
detail: {
title: '拆件详情',
back: '返回列表',
orderInfo: '订单信息',
orderNumber: '订单号',
customerName: '客户名称',
modelName: '模型名称',
createTime: '创建时间',
status: '状态',
process: '拆件流程',
step1: '预览图/模型展示',
step2: '拆件图片展示',
step3: '生成模型展示',
step4: '物流信息填写',
preview: '预览',
disassembly: '拆件',
generateModel: '生成模型',
export: '导出',
ship: '发货',
previewDialog: '模型预览',
close: '关闭',
loading: '加载中...',
previewDescription: '这是原始3D模型的预览您可以拖动鼠标旋转、滚轮缩放查看模型细节。',
disassemblyDescription: '模型拆件处理中,请稍候...',
generateDescription: '正在生成拆件后的模型文件...',
exportDescription: '正在导出拆件模型...',
shipDescription: '请填写物流信息',
shippingForm: {
trackingNumber: '物流单号',
shippingCompany: '物流公司',
shippingAddress: '收货地址',
notes: '备注',
submit: '提交发货信息',
success: '发货信息提交成功',
error: '提交失败,请重试'
},
statusOptions: {
pending: '待拆件',
processing: '拆件中'
},
typeOptions: {
full: '完全拆件',
partial: '部分拆件',
custom: '自定义拆件'
}
},
messages: {
refreshSuccess: '刷新成功',
deleteConfirm: '确定要删除此订单吗?',
deleteTitle: '删除确认',
deleteSuccess: '删除成功',
disassemblySuccess: '拆件成功',
generateSuccess: '模型生成成功',
exportSuccess: '导出成功',
shipSuccess: '发货成功',
disassembleConfirm: '确定要开始拆件此订单吗?',
disassembleTitle: '拆件确认',
alreadyProcessing: '该订单正在拆件中,请勿重复操作'
}
},
users: {
title: '用户管理',
add: '添加用户',
@ -309,6 +424,11 @@ export default {
selectRole: '选择角色',
selectStatus: '选择状态',
save: '保存',
avatar: '头像',
realName: '真实姓名',
creatorLevel: '创作者等级',
worksCount: '作品数量',
bio: '个人简介',
stats: {
total: '总用户',
active: '活跃用户',
@ -324,7 +444,14 @@ export default {
admin: '管理员',
user: '普通用户',
vip: 'VIP用户'
}
},
creatorLevels: {
beginner: '普通创作者',
intermediate: '进阶创作者',
advanced: '专业创作者',
master: '大师级创作者'
},
selectCreatorLevel: '选择创作者等级'
}
},

View File

@ -10,6 +10,8 @@ const AdminContent = () => import('@/views/admin/AdminContent.vue')
const AdminOrders = () => import('@/views/admin/AdminOrders.vue')
const AdminUsers = () => import('@/views/admin/AdminUsers.vue')
const AdminContentReview = () => import('@/views/admin/AdminContentReview.vue')
const AdminDisassemblyOrders = () => import('@/views/admin/AdminDisassemblyOrders.vue')
const AdminDisassemblyDetail = () => import('@/views/admin/AdminDisassemblyDetail.vue')
const routes = [
{
@ -88,6 +90,22 @@ const routes = [
meta: {
title: '用户管理'
}
},
{
path: 'disassembly-orders',
name: 'AdminDisassemblyOrders',
component: AdminDisassemblyOrders,
meta: {
title: '拆件订单'
}
},
{
path: 'disassembly-orders/:id',
name: 'AdminDisassemblyDetail',
component: AdminDisassemblyDetail,
meta: {
title: '拆件详情'
}
}
]
},

View File

@ -51,15 +51,6 @@
<el-option :label="t('admin.review.statusOptions.approved')" value="approved" />
<el-option :label="t('admin.review.statusOptions.rejected')" value="rejected" />
</el-select>
<el-date-picker
v-model="dateRange"
type="daterange"
:placeholder="t('admin.review.dateRange')"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
clearable
/>
</div>
<div class="search-group">
@ -86,7 +77,7 @@
style="width: 100%"
v-loading="loading"
>
<el-table-column prop="id" label="ID" min-width="60" />
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="creatorName" :label="t('admin.review.creator')" min-width="140" />
<el-table-column :label="t('admin.review.thumbnail')" min-width="80">
<template #default="{ row }">
@ -134,7 +125,7 @@
{{ formatDate(row.createTime) }}
</template>
</el-table-column>
<el-table-column :label="t('admin.review.actions')" min-width="280" fixed="right">
<el-table-column :label="$t('admin.review.actions')" min-width="300" align="center" fixed="right">
<template #default="{ row }">
<div class="actions-container">
<el-button size="small" @click="previewModel(row)">
@ -156,14 +147,6 @@
>
{{ t('admin.review.reject') }}
</el-button>
<el-button
size="small"
type="success"
@click="goToDisassembly(row)"
v-if="row.status === 'approved'"
>
{{ t('admin.review.goToDisassembly') }}
</el-button>
</div>
</template>
</el-table-column>
@ -207,21 +190,21 @@
@click="zoomIn"
>
<el-icon><ZoomIn /></el-icon>
放大
{{ t('admin.review.zoomIn') }}
</el-button>
<el-button
size="small"
@click="zoomOut"
>
<el-icon><ZoomOut /></el-icon>
缩小
{{ t('admin.review.zoomOut') }}
</el-button>
<el-button
size="small"
@click="resetZoom"
>
<el-icon><Refresh /></el-icon>
重置
{{ t('admin.review.resetZoom') }}
</el-button>
<el-button
size="small"
@ -229,7 +212,7 @@
@click="openInNewTab"
>
<el-icon><Link /></el-icon>
新窗口打开
{{ t('admin.review.openInNewTab') }}
</el-button>
</div>
</div>
@ -298,14 +281,17 @@ import {
Check,
Close,
Picture,
Box,
ZoomIn,
ZoomOut,
Link
} from '@element-plus/icons-vue'
// useRouter
import { useRouter } from 'vue-router'
import ModelViewer from '@/components/common/ModelViewer.vue'
const { t } = useI18n()
const router = useRouter()
//
const loading = ref(false)
@ -314,7 +300,6 @@ const pageSize = ref(20)
const totalReviews = ref(0)
const selectedStatus = ref('')
const searchQuery = ref('')
const dateRange = ref([])
//
const previewImageVisible = ref(false)
@ -354,7 +339,7 @@ const reviewList = ref([
{
id: 1,
creatorName: t('admin.review.creatorStudioA'),
thumbnail: 'https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=150',
thumbnail: '',
orderPrice: 299.99,
status: 'pending',
rejectionReason: '',
@ -363,7 +348,7 @@ const reviewList = ref([
{
id: 2,
creatorName: t('admin.review.creatorStudioB'),
thumbnail: 'https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=150',
thumbnail: '',
orderPrice: 459.99,
status: 'approved',
rejectionReason: '',
@ -372,7 +357,7 @@ const reviewList = ref([
{
id: 3,
creatorName: t('admin.review.creatorStudioC'),
thumbnail: 'https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=150',
thumbnail: '',
orderPrice: 189.99,
status: 'rejected',
rejectionReason: t('admin.review.nonComplianceReason'),
@ -395,14 +380,6 @@ const filteredReviewList = computed(() => {
)
}
if (dateRange.value && dateRange.value.length === 2) {
const [start, end] = dateRange.value
list = list.filter(item => {
const itemDate = item.createTime.split(' ')[0]
return itemDate >= start && itemDate <= end
})
}
totalReviews.value = list.length
return list.slice(
(currentPage.value - 1) * pageSize.value,
@ -452,6 +429,10 @@ const getStatusTagType = (status) => {
}
const previewThumbnail = (imageUrl) => {
if (!imageUrl) {
ElMessage.warning(t('admin.review.noThumbnail'))
return
}
currentImage.value = imageUrl
imageScale.value = 1
previewImageVisible.value = true
@ -510,6 +491,12 @@ const approveReview = async (review) => {
reviewStats.value.approved++
ElMessage.success(t('admin.review.approveSuccess'))
//
setTimeout(() => {
router.push('/admin/disassembly-orders')
}, 1500)
} catch {
//
}
@ -540,11 +527,7 @@ const confirmRejectReview = async () => {
}
}
const goToDisassembly = (review) => {
//
ElMessage.info(t('admin.review.disassemblyComingSoon'))
console.log(t('admin.review.redirectToDisassembly'), review)
}
onMounted(() => {
//
@ -688,6 +671,11 @@ onMounted(() => {
.review-table :deep(.el-table__cell) {
padding: 8px 12px;
white-space: nowrap;
vertical-align: middle;
}
.review-table :deep(.el-table__row) {
height: 66px;
}
.review-table :deep(.el-table__header-wrapper) {
@ -699,6 +687,17 @@ onMounted(() => {
color: #374151;
font-weight: 600;
border-bottom: 2px solid #e5e7eb;
white-space: nowrap !important;
text-align: center;
text-overflow: ellipsis;
overflow: hidden;
}
.review-table :deep(.el-table__header th .cell) {
white-space: nowrap !important;
word-break: keep-all;
overflow: hidden;
text-overflow: ellipsis;
}
/* 操作按钮容器 */
@ -708,7 +707,9 @@ onMounted(() => {
flex-wrap: nowrap;
align-items: center;
justify-content: flex-start;
overflow: hidden;
width: 100%;
overflow-x: auto;
padding-bottom: 4px;
}
.actions-container .el-button {
@ -716,6 +717,33 @@ onMounted(() => {
white-space: nowrap;
}
/* 缩略图容器样式 */
.thumbnail-container {
display: flex;
align-items: center;
justify-content: center;
padding: 8px 0;
}
.thumbnail-container .el-image {
border-radius: 6px;
width: 50px !important;
height: 50px !important;
min-width: 50px !important;
max-width: 50px !important;
min-height: 50px !important;
max-height: 50px !important;
overflow: hidden !important;
object-fit: cover;
}
.thumbnail-container .el-image img {
width: 100% !important;
height: 100% !important;
object-fit: cover !important;
border-radius: 6px !important;
}
/* 图片错误样式优化 */
.image-error {
width: 50px;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,614 @@
<template>
<div class="admin-disassembly-orders">
<!-- 统计卡片 -->
<div class="disassembly-stats">
<div class="stat-card">
<div class="stat-icon total">
<el-icon><Document /></el-icon>
</div>
<div class="stat-info">
<div class="stat-number">{{ disassemblyStats.total }}</div>
<div class="stat-label">{{ t('admin.disassemblyOrders.stats.total') }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon pending">
<el-icon><Clock /></el-icon>
</div>
<div class="stat-info">
<div class="stat-number">{{ disassemblyStats.pending }}</div>
<div class="stat-label">{{ t('admin.disassemblyOrders.stats.pending') }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon processing">
<el-icon><Loading /></el-icon>
</div>
<div class="stat-info">
<div class="stat-number">{{ disassemblyStats.processing }}</div>
<div class="stat-label">{{ t('admin.disassemblyOrders.stats.processing') }}</div>
</div>
</div>
</div>
<!-- 筛选搜索区域 -->
<div class="disassembly-filters">
<div class="filter-group">
<el-select
v-model="selectedStatus"
:placeholder="$t('admin.disassemblyOrders.filters.status')"
clearable
>
<el-option
:label="$t('admin.disassemblyOrders.filters.allStatus')"
value=""
/>
<el-option
:label="$t('admin.disassemblyOrders.detail.statusOptions.pending')"
value="pending"
/>
<el-option
:label="$t('admin.disassemblyOrders.detail.statusOptions.processing')"
value="processing"
/>
</el-select>
</div>
<div class="search-group">
<el-input
v-model="searchQuery"
:placeholder="$t('admin.disassemblyOrders.filters.searchPlaceholder')"
prefix-icon="Search"
clearable
/>
</div>
<div class="filter-actions">
<el-button
type="primary"
@click="fetchOrders"
>
<el-icon><Search /></el-icon>
{{ $t('admin.disassemblyOrders.filters.filter') }}
</el-button>
<el-button
@click="resetFilters"
>
<el-icon><Refresh /></el-icon>
{{ $t('admin.disassemblyOrders.filters.refresh') }}
</el-button>
</div>
</div>
<!-- 订单列表区域 -->
<div class="disassembly-table">
<el-table
:data="paginatedOrders"
stripe
style="width: 100%"
v-loading="loading"
>
<el-table-column
prop="id"
:label="$t('admin.disassemblyOrders.list.id')"
width="80"
/>
<el-table-column
prop="orderNumber"
:label="$t('admin.disassemblyOrders.list.orderNumber')"
width="150"
/>
<el-table-column
prop="creatorName"
:label="$t('admin.disassemblyOrders.list.creatorName')"
width="120"
/>
<el-table-column
prop="status"
:label="$t('admin.disassemblyOrders.list.status')"
width="100"
>
<template #default="scope">
<el-tag :type="getStatusType(scope.row.status)">
{{ getStatusText(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="createTime"
:label="$t('admin.disassemblyOrders.list.createTime')"
width="160"
>
<template #default="scope">
{{ formatDate(scope.row.createTime) }}
</template>
</el-table-column>
<el-table-column
:label="$t('admin.disassemblyOrders.list.actions')"
width="200"
fixed="right"
>
<template #default="scope">
<div class="actions-container">
<el-button
type="primary"
size="small"
:icon="Tools"
@click="handleDisassemble(scope.row)"
>
{{ $t('admin.disassemblyOrders.list.disassembly') }}
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="filteredOrdersList.length"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh, Search, Document, Clock, Loading, Check, Van, Tools } from '@element-plus/icons-vue'
const { t } = useI18n()
const router = useRouter()
//
const loading = ref(false)
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
const ordersList = ref([])
const selectedStatus = ref('')
const searchQuery = ref('')
//
const disassemblyStats = ref({
total: 0,
pending: 0,
processing: 0
})
//
const mockOrders = [
{
id: 1,
orderNumber: 'ORD202311001',
creatorName: '张三',
status: 'pending',
createTime: '2023-11-20 10:30:00'
},
{
id: 2,
orderNumber: 'ORD202311002',
creatorName: '李四',
status: 'processing',
createTime: '2023-11-20 09:15:00'
},
{
id: 3,
orderNumber: 'ORD202311003',
creatorName: '王五',
status: 'pending',
createTime: '2023-11-19 16:45:00'
},
{
id: 4,
orderNumber: 'ORD202311004',
creatorName: '赵六',
status: 'processing',
createTime: '2023-11-19 14:20:00'
},
{
id: 5,
orderNumber: 'ORD202311005',
creatorName: '钱七',
status: 'pending',
createTime: '2023-11-18 11:10:00'
}
]
// -
const filteredOrdersList = computed(() => {
let filtered = [...ordersList.value]
//
if (selectedStatus.value) {
filtered = filtered.filter(order => order.status === selectedStatus.value)
}
//
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
filtered = filtered.filter(order =>
order.orderNumber.toLowerCase().includes(query) ||
order.creatorName.toLowerCase().includes(query)
)
}
return filtered
})
//
const formatDate = (dateString) => {
const date = new Date(dateString)
return date.toLocaleString()
}
//
const fetchOrders = () => {
loading.value = true
// API
setTimeout(() => {
// API使
ordersList.value = mockOrders
total.value = mockOrders.length
//
const stats = {
total: mockOrders.length,
pending: mockOrders.filter(order => order.status === 'pending').length,
processing: mockOrders.filter(order => order.status === 'processing').length
}
disassemblyStats.value = stats
loading.value = false
}, 500)
}
//
const refreshOrders = () => {
fetchOrders()
ElMessage.success(t('admin.disassemblyOrders.messages.refreshSuccess'))
}
//
const resetFilters = () => {
selectedStatus.value = ''
searchQuery.value = ''
fetchOrders()
}
// -
const paginatedOrders = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return filteredOrdersList.value.slice(start, end)
})
//
const getStatusText = (status) => {
const statusMap = {
'pending': t('admin.disassemblyOrders.detail.statusOptions.pending'),
'processing': t('admin.disassemblyOrders.detail.statusOptions.processing')
}
return statusMap[status] || status
}
//
const getDisassemblyTypeText = (type) => {
const typeMap = {
'full': t('admin.disassemblyOrders.detail.typeOptions.full'),
'partial': t('admin.disassemblyOrders.detail.typeOptions.partial'),
'custom': t('admin.disassemblyOrders.detail.typeOptions.custom')
}
return typeMap[type] || type
}
//
const getDisassemblyTypeTag = (type) => {
const typeMap = {
'full': 'success',
'partial': 'warning',
'custom': 'info'
}
return typeMap[type] || 'info'
}
//
const handleDisassemble = (order) => {
//
router.push({
name: 'AdminDisassemblyDetail',
params: { id: order.id }
})
}
//
const getStatusType = (status) => {
const statusMap = {
pending: 'warning',
processing: 'primary'
}
return statusMap[status] || 'info'
}
//
const handleSizeChange = (val) => {
pageSize.value = val
fetchOrders()
}
const handleCurrentChange = (val) => {
currentPage.value = val
fetchOrders()
}
//
onMounted(() => {
fetchOrders()
})
</script>
<style scoped>
/* 拆件订单管理页面样式 */
.admin-disassembly-orders {
padding: 24px;
max-height: 100vh;
}
/* 统计卡片样式 */
.disassembly-stats {
display: flex;
gap: 20px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.stat-card {
flex: 1;
min-width: 200px;
padding: 20px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
transition: all 0.3s ease;
}
.stat-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 15px;
font-size: 24px;
color: white;
}
.stat-icon.total {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.stat-icon.pending {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.stat-icon.processing {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.stat-info {
flex: 1;
}
.stat-number {
font-size: 32px;
font-weight: 700;
color: #1f2937;
line-height: 1;
}
.stat-label {
color: #6b7280;
font-size: 14px;
margin-top: 4px;
}
/* 筛选搜索区域 */
.disassembly-filters {
padding: 20px;
border-radius: 12px;
margin-bottom: 24px;
display: flex;
gap: 12px;
align-items: flex-end;
justify-content: flex-end;
overflow-x: auto;
white-space: nowrap;
}
.filter-group {
display: flex;
gap: 8px;
align-items: flex-end;
flex-shrink: 0;
}
.search-group {
flex: 1;
min-width: 200px;
max-width: 300px;
flex-shrink: 0;
}
.filter-actions {
display: flex;
align-items: flex-end;
flex-shrink: 0;
}
/* 表格样式 */
.disassembly-table {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border: 1px solid #e5e7eb;
margin-bottom: 24px;
}
/* 表格样式优化 */
.disassembly-table :deep(.el-table) {
width: 100% !important;
table-layout: auto;
}
.disassembly-table :deep(.el-table__header) {
width: 100% !important;
}
.disassembly-table :deep(.el-table__body) {
width: 100% !important;
}
.disassembly-table :deep(.el-table__cell) {
padding: 8px 12px;
white-space: nowrap;
vertical-align: middle;
}
.disassembly-table :deep(.el-table__row) {
height: 66px;
}
.disassembly-table :deep(.el-table__header-wrapper) {
background-color: #f8fafc;
}
.disassembly-table :deep(.el-table__header th) {
background-color: #f8fafc;
color: #374151;
font-weight: 600;
border-bottom: 2px solid #e5e7eb;
white-space: nowrap !important;
text-align: center;
text-overflow: ellipsis;
overflow: hidden;
}
.disassembly-table :deep(.el-table__header th .cell) {
white-space: nowrap !important;
word-break: keep-all;
overflow: hidden;
text-overflow: ellipsis;
}
/* 操作按钮容器 */
.actions-container {
display: flex;
gap: 6px;
flex-wrap: nowrap;
align-items: center;
justify-content: flex-start;
overflow: hidden;
}
.actions-container .el-button {
flex-shrink: 0;
white-space: nowrap;
}
/* 分页样式 */
.pagination-container {
display: flex;
justify-content: flex-end;
padding: 16px 0;
}
/* 响应式设计 */
@media (max-width: 768px) {
.admin-disassembly-orders {
padding: 16px;
}
.disassembly-stats {
flex-direction: column;
gap: 16px;
}
.stat-card {
min-width: auto;
}
.disassembly-filters {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.filter-group {
flex-direction: column;
align-items: stretch;
}
.search-group {
min-width: auto;
max-width: none;
}
.filter-actions {
justify-content: center;
}
.actions-container {
flex-direction: column;
gap: 6px;
}
/* 移动端表格优化 */
.disassembly-table {
overflow-x: auto;
}
.disassembly-table :deep(.el-table) {
min-width: 800px;
}
}
@media (min-width: 769px) and (max-width: 1024px) {
.disassembly-stats {
flex-wrap: wrap;
}
.stat-card {
min-width: calc(50% - 10px);
}
.disassembly-filters {
flex-wrap: wrap;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -1,21 +1,5 @@
<template>
<div class="admin-orders">
<!-- 页面头部 -->
<div class="dashboard-header">
<div class="header-left">
<h2 class="title">{{ $t('admin.orders.title') }}</h2>
<p class="subtitle">Manage orders and track delivery status</p>
</div>
<div class="header-actions">
<el-button type="primary" :icon="Download" @click="exportOrders">
{{ $t('admin.orders.export') }}
</el-button>
<el-button :icon="Refresh" @click="refresh">
{{ $t('admin.common.refresh') }}
</el-button>
</div>
</div>
<!-- 统计卡片 -->
<div class="order-stats">
<div class="stat-card">
@ -62,18 +46,43 @@
<!-- 筛选和搜索 -->
<div class="orders-filters">
<div class="filter-group">
<el-select v-model="selectedStatus" :placeholder="t('admin.orders.status')" clearable>
<el-option :label="t('admin.orders.status.pending')" value="pending" />
<el-option :label="t('admin.orders.status.processing')" value="processing" />
<el-option :label="t('admin.orders.status.shipped')" value="shipped" />
<el-option :label="t('admin.orders.status.delivered')" value="delivered" />
<el-option :label="t('admin.orders.status.cancelled')" value="cancelled" />
<el-select
v-model="selectedStatus"
:placeholder="t('admin.orders.status')"
clearable
>
<el-option
:label="t('admin.orders.statusOptions.pendingConfirmation')"
value="pendingConfirmation"
/>
<el-option
:label="t('admin.orders.statusOptions.confirmed')"
value="confirmed"
/>
<el-option
:label="t('admin.orders.statusOptions.rejected')"
value="rejected"
/>
<el-option
:label="t('admin.orders.statusOptions.processing')"
value="processing"
/>
<el-option
:label="t('admin.orders.statusOptions.readyToShip')"
value="readyToShip"
/>
<el-option
:label="t('admin.orders.statusOptions.shipped')"
value="shipped"
/>
</el-select>
<el-date-picker
v-model="dateRange"
type="daterange"
:placeholder="t('admin.orders.dateRange')"
range-separator="-"
:start-placeholder="t('admin.orders.dateRange')"
:end-placeholder="t('admin.orders.dateRange')"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
clearable
@ -81,13 +90,29 @@
</div>
<div class="search-group">
<el-input
<el-input
v-model="searchQuery"
:placeholder="t('admin.orders.search')"
:prefix-icon="Search"
prefix-icon="Search"
clearable
/>
</div>
<div class="filter-actions">
<el-button
type="primary"
@click="handleExportOrders"
>
<el-icon><Download /></el-icon>
{{ t('admin.orders.export') }}
</el-button>
<el-button
@click="refresh"
>
<el-icon><Refresh /></el-icon>
{{ t('admin.common.refresh') }}
</el-button>
</div>
</div>
<!-- 订单列表 -->
@ -98,7 +123,9 @@
style="width: 100%"
v-loading="loading"
@row-click="handleOrderDetail"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="id" label="ID" width="100" />
<el-table-column prop="orderNumber" :label="t('admin.orders.orderNumber')" width="150" />
<el-table-column prop="customerName" :label="t('admin.orders.customer')" width="120" />
@ -110,13 +137,13 @@
<el-table-column prop="status" :label="t('admin.orders.status')" width="120">
<template #default="{ row }">
<el-tag :type="getStatusTagType(row.status)">
{{ t(`admin.orders.status.${row.status}`) }}
{{ t(`admin.orders.statusOptions.${row.status}`) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="paymentMethod" :label="t('admin.orders.payment')" width="100">
<template #default="{ row }">
{{ t(`admin.orders.payment.${row.paymentMethod}`) }}
{{ t(`admin.orders.paymentOptions.${row.paymentMethod}`) }}
</template>
</el-table-column>
<el-table-column prop="orderDate" :label="t('admin.orders.date')" width="180">
@ -124,18 +151,14 @@
{{ formatDate(row.orderDate) }}
</template>
</el-table-column>
<el-table-column :label="t('admin.orders.actions')" width="200" fixed="right">
<el-table-column :label="t('admin.orders.actions')" width="120" fixed="right">
<template #default="{ row }">
<el-button size="small" @click.stop="handleViewOrder(row)">
{{ t('admin.orders.view') }}
</el-button>
<el-button
size="small"
type="primary"
@click.stop="handleUpdateStatus(row)"
v-if="row.status !== 'delivered' && row.status !== 'cancelled'"
@click.stop="handleActionSelect(row)"
>
{{ t('admin.orders.updateStatus') }}
{{ t('admin.orders.actions') }}
</el-button>
</template>
</el-table-column>
@ -176,7 +199,7 @@
</el-descriptions-item>
<el-descriptions-item :label="t('admin.orders.status')">
<el-tag :type="getStatusTagType(selectedOrder.status)">
{{ t(`admin.orders.status.${selectedOrder.status}`) }}
{{ t(`admin.orders.statusOptions.${selectedOrder.status}`) }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
@ -206,7 +229,7 @@
<el-form v-if="selectedOrderForStatus" :model="statusForm" label-width="100px">
<el-form-item :label="t('admin.orders.currentStatus')">
<el-tag :type="getStatusTagType(selectedOrderForStatus.status)">
{{ t(`admin.orders.status.${selectedOrderForStatus.status}`) }}
{{ t(`admin.orders.statusOptions.${selectedOrderForStatus.status}`) }}
</el-tag>
</el-form-item>
<el-form-item :label="t('admin.orders.newStatus')" prop="status">
@ -225,12 +248,168 @@
<el-button type="primary" @click="handleStatusUpdate">{{ t('common.confirm') }}</el-button>
</template>
</el-dialog>
<!-- 发货对话框 -->
<el-dialog
v-model="shippingDialogVisible"
:title="t('admin.orders.ship')"
width="40%"
>
<div v-if="selectedOrder">
<el-form :model="shippingForm" label-width="120px">
<el-form-item :label="t('admin.orders.trackingNumber')" required>
<el-input v-model="shippingForm.trackingNumber" placeholder="请输入物流单号"></el-input>
</el-form-item>
<el-form-item :label="t('admin.orders.carrier')" required>
<el-select v-model="shippingForm.carrier" placeholder="请选择物流公司">
<el-option label="顺丰速运" value="sf"></el-option>
<el-option label="圆通速递" value="yto"></el-option>
<el-option label="中通快递" value="zto"></el-option>
<el-option label="申通快递" value="sto"></el-option>
<el-option label="韵达速递" value="yd"></el-option>
<el-option label="邮政EMS" value="ems"></el-option>
</el-select>
</el-form-item>
<el-form-item :label="t('admin.orders.shippingNote')">
<el-input
v-model="shippingForm.note"
type="textarea"
rows="3"
placeholder="发货备注(可选)">
</el-input>
</el-form-item>
</el-form>
</div>
<template #footer>
<el-button @click="shippingDialogVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="confirmShipOrder">{{ t('common.confirm') }}</el-button>
</template>
</el-dialog>
<!-- 物流对话框 -->
<el-dialog
v-model="logisticsDialogVisible"
:title="t('admin.orders.viewLogistics')"
width="50%"
>
<div v-if="selectedOrder">
<el-descriptions :column="2" border>
<el-descriptions-item :label="t('admin.orders.trackingNumber')">
{{ selectedOrder.trackingNumber || 'SF1234567890' }}
</el-descriptions-item>
<el-descriptions-item :label="t('admin.orders.carrier')">
{{ selectedOrder.carrier || '顺丰速运' }}
</el-descriptions-item>
</el-descriptions>
<el-divider>{{ t('admin.orders.logisticsTimeline') }}</el-divider>
<el-timeline>
<el-timeline-item
v-for="(activity, index) in logisticsActivities"
:key="index"
:timestamp="activity.timestamp"
:type="activity.type"
>
{{ activity.content }}
</el-timeline-item>
</el-timeline>
</div>
<template #footer>
<el-button @click="logisticsDialogVisible = false">{{ t('common.close') }}</el-button>
</template>
</el-dialog>
<!-- 操作选择对话框 -->
<el-dialog
v-model="actionDialogVisible"
:title="t('admin.orders.selectAction')"
width="40%"
>
<div v-if="selectedOrderForAction">
<el-descriptions :column="1" border class="order-info">
<el-descriptions-item :label="t('admin.orders.orderNumber')">
{{ selectedOrderForAction.orderNumber }}
</el-descriptions-item>
<el-descriptions-item :label="t('admin.orders.customer')">
{{ selectedOrderForAction.customerName }}
</el-descriptions-item>
<el-descriptions-item :label="t('admin.orders.status')">
<el-tag :type="getStatusTagType(selectedOrderForAction.status)">
{{ t(`admin.orders.statusOptions.${selectedOrderForAction.status}`) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item :label="t('admin.orders.customerNote')" v-if="selectedOrderForAction.customerNote">
<div class="customer-note">{{ selectedOrderForAction.customerNote }}</div>
</el-descriptions-item>
</el-descriptions>
<el-divider>{{ t('admin.orders.availableActions') }}</el-divider>
<div class="action-options">
<!-- 查看详情 - 所有状态都可用 -->
<el-button
type="primary"
size="large"
class="action-btn"
@click="executeAction('view')"
>
{{ t('admin.orders.view') }}
</el-button>
<!-- 待确认状态确认操作 -->
<el-button
v-if="selectedOrderForAction.status === 'pendingConfirmation'"
type="success"
size="large"
class="action-btn"
@click="executeAction('confirm')"
>
{{ t('admin.orders.confirm') }}
</el-button>
<!-- 待处理状态处理操作 -->
<el-button
v-if="selectedOrderForAction.status === 'processing'"
type="warning"
size="large"
class="action-btn"
@click="executeAction('process')"
>
{{ t('admin.orders.process') }}
</el-button>
<!-- 待发货状态发货操作 -->
<el-button
v-if="selectedOrderForAction.status === 'readyToShip'"
type="primary"
size="large"
class="action-btn"
@click="executeAction('ship')"
>
{{ t('admin.orders.ship') }}
</el-button>
<!-- 已发货状态查看物流 -->
<el-button
v-if="selectedOrderForAction.status === 'shipped'"
type="success"
size="large"
class="action-btn"
@click="executeAction('logistics')"
>
{{ t('admin.orders.viewLogistics') }}
</el-button>
</div>
</div>
</el-dialog>
</div>
</template>
<script>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import {
Download,
ShoppingCart,
@ -238,7 +417,11 @@ import {
Check,
Money,
Search,
Refresh
Refresh,
View,
Tools,
Van,
Box
} from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
@ -250,10 +433,16 @@ export default {
Clock,
Check,
Money,
Search
Search,
Refresh,
View,
Tools,
Van,
Box
},
setup() {
const { t } = useI18n()
const router = useRouter()
//
const loading = ref(false)
@ -265,12 +454,48 @@ export default {
const totalOrders = ref(0)
const detailDialogVisible = ref(false)
const statusDialogVisible = ref(false)
const shippingDialogVisible = ref(false)
const logisticsDialogVisible = ref(false)
const actionDialogVisible = ref(false)
const selectedOrder = ref(null)
const selectedOrderForStatus = ref(null)
const selectedOrderForAction = ref(null)
const selectedOrders = ref([]) //
const statusForm = ref({
const statusForm = reactive({
status: ''
})
//
const shippingForm = reactive({
trackingNumber: '',
carrier: '',
note: ''
})
// 线
const logisticsActivities = ref([
{
content: '您的订单已发货',
timestamp: '2023-12-01 10:00:00',
type: 'primary'
},
{
content: '您的订单已到达【北京转运中心】',
timestamp: '2023-12-01 14:30:00',
type: 'success'
},
{
content: '您的订单正在派送中',
timestamp: '2023-12-02 09:00:00',
type: 'warning'
},
{
content: '您的订单已签收',
timestamp: '2023-12-02 16:30:00',
type: 'success'
}
])
//
const ordersList = ref([
@ -279,9 +504,10 @@ export default {
orderNumber: 'ORD-2024-001',
customerName: '张三',
totalAmount: 1299.00,
status: 'delivered',
status: 'shipped',
paymentMethod: 'alipay',
orderDate: '2024-01-15 10:30:00',
customerNote: '请在工作日送货,周末不在家',
items: [
{ name: 'AI智能音箱', quantity: 1, price: 799.00 },
{ name: '智能插座', quantity: 2, price: 250.00 }
@ -292,9 +518,10 @@ export default {
orderNumber: 'ORD-2024-002',
customerName: '李四',
totalAmount: 2599.00,
status: 'shipped',
status: 'readyToShip',
paymentMethod: 'wechat',
orderDate: '2024-01-14 16:20:00',
customerNote: '需要发票抬头XX科技有限公司',
items: [
{ name: '智能门锁', quantity: 1, price: 1999.00 },
{ name: '智能摄像头', quantity: 1, price: 600.00 }
@ -308,6 +535,7 @@ export default {
status: 'processing',
paymentMethod: 'credit',
orderDate: '2024-01-13 09:15:00',
customerNote: '请提前一天电话联系',
items: [
{ name: '智能灯泡', quantity: 3, price: 299.00 }
]
@ -317,12 +545,39 @@ export default {
orderNumber: 'ORD-2024-004',
customerName: '赵六',
totalAmount: 1599.00,
status: 'pending',
status: 'pendingConfirmation',
paymentMethod: 'alipay',
orderDate: '2024-01-12 14:45:00',
customerNote: '收货地址XX市XX区XX街道XX小区X号楼X单元XXX室',
items: [
{ name: '智能扫地机器人', quantity: 1, price: 1599.00 }
]
},
{
id: 5,
orderNumber: 'ORD-2024-005',
customerName: '孙七',
totalAmount: 799.00,
status: 'processing',
paymentMethod: 'wechat',
orderDate: '2024-01-11 11:30:00',
customerNote: '商品是礼物,请用精美包装',
items: [
{ name: '智能体重秤', quantity: 1, price: 799.00 }
]
},
{
id: 6,
orderNumber: 'ORD-2024-006',
customerName: '周八',
totalAmount: 1299.00,
status: 'rejected',
paymentMethod: 'credit',
orderDate: '2024-01-10 15:20:00',
customerNote: '需要开具增值税专用发票',
items: [
{ name: '智能手环', quantity: 1, price: 1299.00 }
]
}
])
@ -363,11 +618,11 @@ export default {
const availableStatuses = computed(() => {
const allStatuses = [
{ value: 'pending', label: t('admin.orders.status.pending') },
{ value: 'processing', label: t('admin.orders.status.processing') },
{ value: 'shipped', label: t('admin.orders.status.shipped') },
{ value: 'delivered', label: t('admin.orders.status.delivered') },
{ value: 'cancelled', label: t('admin.orders.status.cancelled') }
{ value: 'pendingConfirmation', label: t('admin.orders.statusOptions.pendingConfirmation') },
{ value: 'rejected', label: t('admin.orders.statusOptions.rejected') },
{ value: 'processing', label: t('admin.orders.statusOptions.processing') },
{ value: 'readyToShip', label: t('admin.orders.statusOptions.readyToShip') },
{ value: 'shipped', label: t('admin.orders.statusOptions.shipped') }
]
return allStatuses
})
@ -375,13 +630,13 @@ export default {
//
const getStatusTagType = (status) => {
const statusMap = {
pending: 'warning',
processing: 'info',
shipped: 'primary',
delivered: 'success',
cancelled: 'danger'
pendingConfirmation: 'info', // -
rejected: 'danger', // -
processing: 'warning', // -
readyToShip: 'primary', // -
shipped: 'success' // - 绿
}
return statusMap[status] || ''
return statusMap[status] || 'info'
}
const formatDate = (dateString) => {
@ -389,7 +644,7 @@ export default {
return date.toLocaleString('zh-CN')
}
const exportOrders = () => {
const handleExportOrders = () => {
ElMessage.success('Orders exported successfully')
}
@ -432,6 +687,54 @@ const refresh = () => {
}
}
//
const handleConfirmOrder = (row) => {
//
router.push('/admin/content-review')
}
//
const handleProcessOrder = (row) => {
//
router.push('/admin/disassembly-orders')
}
//
const handleShipOrder = (row) => {
selectedOrder.value = row
shippingDialogVisible.value = true
}
//
const handleViewLogistics = (row) => {
selectedOrder.value = row
logisticsDialogVisible.value = true
}
//
const confirmShipOrder = () => {
if (!shippingForm.trackingNumber || !shippingForm.carrier) {
ElMessage.warning('请填写必要的发货信息')
return
}
//
const orderIndex = orders.value.findIndex(order => order.id === selectedOrder.value.id)
if (orderIndex !== -1) {
orders.value[orderIndex].status = 'shipped'
orders.value[orderIndex].trackingNumber = shippingForm.trackingNumber
orders.value[orderIndex].carrier = shippingForm.carrier
}
ElMessage.success('发货成功')
shippingDialogVisible.value = false
//
shippingForm.trackingNumber = ''
shippingForm.carrier = ''
shippingForm.note = ''
}
const handleSizeChange = (val) => {
pageSize.value = val
currentPage.value = 1
@ -441,6 +744,42 @@ const refresh = () => {
currentPage.value = val
}
//
const handleSelectionChange = (selection) => {
selectedOrders.value = selection
}
//
const handleActionSelect = (row) => {
selectedOrderForAction.value = row
actionDialogVisible.value = true
}
//
const executeAction = (action) => {
actionDialogVisible.value = false
switch (action) {
case 'view':
handleViewOrder(selectedOrderForAction.value)
break
case 'confirm':
handleConfirmOrder(selectedOrderForAction.value)
break
case 'process':
handleProcessOrder(selectedOrderForAction.value)
break
case 'ship':
handleShipOrder(selectedOrderForAction.value)
break
case 'logistics':
handleViewLogistics(selectedOrderForAction.value)
break
}
}
//
onMounted(() => {
totalOrders.value = ordersList.value.length
@ -457,9 +796,16 @@ const refresh = () => {
totalOrders,
detailDialogVisible,
statusDialogVisible,
shippingDialogVisible,
logisticsDialogVisible,
actionDialogVisible,
selectedOrder,
selectedOrderForStatus,
selectedOrderForAction,
selectedOrders,
statusForm,
shippingForm,
logisticsActivities,
ordersList,
orderStats,
filteredOrdersList,
@ -467,12 +813,21 @@ const refresh = () => {
getStatusTagType,
formatDate,
handleExportOrders,
refresh,
handleOrderDetail,
handleViewOrder,
handleUpdateStatus,
handleStatusUpdate,
handleConfirmOrder,
handleProcessOrder,
handleShipOrder,
handleViewLogistics,
confirmShipOrder,
handleSizeChange,
handleCurrentChange
handleCurrentChange,
handleSelectionChange,
handleActionSelect,
executeAction
}
}
}
@ -480,44 +835,10 @@ const refresh = () => {
<style scoped>
.admin-orders {
padding: 20px;
background-color: #f5f5f5;
min-height: calc(100vh - 60px);
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
background: white;
padding: 24px;
border-radius: 8px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.header-left {
flex: 1;
}
.header-left .title {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #1f2937;
}
.header-left .subtitle {
margin: 8px 0 0 0;
font-size: 14px;
color: #6b7280;
}
.header-actions {
display: flex;
gap: 12px;
align-items: center;
}
padding: 20px;
background-color: #f8fafc;
min-height: calc(100vh - 60px);
}
/* 统计卡片样式 */
.order-stats {
@ -530,21 +851,28 @@ const refresh = () => {
.stat-card {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border: 1px solid #e5e7eb;
display: flex;
align-items: center;
gap: 16px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: 8px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-size: 24px;
color: white;
}
@ -565,44 +893,112 @@ const refresh = () => {
}
.stat-number {
font-size: 24px;
font-weight: bold;
font-size: 32px;
font-weight: 700;
color: #1f2937;
margin-bottom: 4px;
line-height: 1;
}
.stat-label {
color: #6b7280;
font-size: 14px;
margin-top: 4px;
}
/* 筛选器样式 */
.orders-filters {
display: flex;
gap: 16px;
padding: 20px;
border-radius: 12px;
margin-bottom: 24px;
align-items: center;
flex-wrap: wrap;
display: flex;
gap: 12px;
align-items: flex-end;
justify-content: flex-end;
overflow-x: auto;
white-space: nowrap;
}
.filter-group {
display: flex;
gap: 12px;
flex-wrap: wrap;
gap: 8px;
align-items: flex-end;
flex-shrink: 0;
}
.search-group {
flex: 1;
min-width: 300px;
min-width: 200px;
max-width: 300px;
flex-shrink: 0;
}
.filter-actions {
display: flex;
align-items: flex-end;
flex-shrink: 0;
gap: 8px;
}
/* 订单列表样式 */
.orders-list {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border: 1px solid #e5e7eb;
margin-bottom: 24px;
}
/* 表格样式优化 */
.orders-list :deep(.el-table) {
width: 100% !important;
table-layout: auto;
}
.orders-list :deep(.el-table__header) {
width: 100% !important;
}
.orders-list :deep(.el-table__body) {
width: 100% !important;
}
.orders-list :deep(.el-table__cell) {
padding: 8px 12px;
white-space: nowrap;
}
.orders-list :deep(.el-table__header-wrapper) {
background-color: #f8fafc;
}
.orders-list :deep(.el-table__header th) {
background-color: #f8fafc;
color: #374151;
font-weight: 600;
border-bottom: 2px solid #e5e7eb;
}
/* 操作按钮容器 */
.actions-container {
display: flex;
gap: 6px;
flex-wrap: nowrap;
align-items: center;
justify-content: flex-start;
overflow: hidden;
}
.actions-container .el-button {
flex-shrink: 0;
white-space: nowrap;
}
/* 分页样式 */
.pagination {
display: flex;
justify-content: flex-end;
padding: 16px 0;
}
/* 订单详情样式 */
@ -622,6 +1018,21 @@ const refresh = () => {
color: #1f2937;
}
.customer-note {
background-color: #f5f7fa;
padding: 8px 12px;
border-radius: 4px;
font-size: 14px;
color: #606266;
line-height: 1.5;
word-break: break-word;
max-width: 100%;
}
.action-options{
display: flex;
justify-content: flex-end;
}
/* 响应式设计 */
@media (max-width: 1024px) {
.order-stats {
@ -629,16 +1040,7 @@ const refresh = () => {
}
.orders-filters {
flex-direction: column;
align-items: stretch;
}
.filter-group {
justify-content: flex-start;
}
.search-group {
min-width: unset;
flex-wrap: wrap;
}
}
@ -647,18 +1049,56 @@ const refresh = () => {
padding: 16px;
}
.orders-header {
flex-direction: column;
gap: 16px;
align-items: flex-start;
}
.order-stats {
grid-template-columns: 1fr;
}
.orders-filters {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.filter-group {
flex-direction: column;
align-items: stretch;
}
.search-group {
min-width: auto;
max-width: none;
}
.filter-actions {
justify-content: center;
}
/* 移动端表格优化 */
.orders-list {
overflow-x: auto;
}
.orders-list :deep(.el-table) {
min-width: 800px;
}
.actions-container {
flex-direction: column;
gap: 6px;
}
}
@media (min-width: 769px) and (max-width: 1024px) {
.order-stats {
flex-wrap: wrap;
}
.stat-card {
min-width: calc(50% - 8px);
}
.orders-filters {
flex-wrap: wrap;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,15 @@
node_modules
npm-debug.log
dist
.git
.gitignore
README.md
.env
.nyc_output
coverage
.nyc_output
.vscode
.idea
*.log
.DS_Store
Thumbs.db

View File

@ -0,0 +1,349 @@
# 🚀 前端 Docker 镜像部署到后端服务器完整指南
## 📋 部署流程概览
```
本地构建 → 导出镜像 → 上传服务器 → 导入镜像 → 启动容器
↓ ↓ ↓ ↓ ↓
前端负责 前端负责 前端/后端 后端负责 后端负责
```
---
## 👥 角色分工
### 🔧 前端开发者职责
- ✅ 构建 Docker 镜像
- ✅ 测试镜像本地运行
- ✅ 导出镜像文件
- ✅ 提供部署文档和配置文件
### 🖥️ 后端运维职责
- ✅ 提供服务器访问权限
- ✅ 接收镜像文件
- ✅ 导入并运行容器
- ✅ 配置域名和 SSL如需要
---
## 📝 步骤详解
### 🎯 第一步:后端提供的信息
**后端需要向前端提供:**
```bash
# 1. 服务器连接信息
服务器IP: 192.168.1.100
用户名: deploy
端口: 22
# 2. 部署目录
部署路径: /app/frontend
# 3. 端口信息
应用端口: 3000
域名: your-domain.com
```
### 🏗️ 第二步:前端本地构建镜像
#### 1. 确保代码可正常构建
```bash
# 本地测试构建
npm run build
# 检查构建结果
ls -la dist/
```
#### 2. 构建 Docker 镜像
```bash
# 在项目根目录执行
cd d:\work\Aiproject\DeotalandAi\apps\frontend
# 构建镜像(使用版本号标记)
docker build -t deotaland-frontend:v1.0.0 .
# 验证镜像构建成功
docker images | grep deotaland-frontend
```
#### 3. 本地测试镜像
```bash
# 运行容器测试
docker run -d -p 3001:3000 --name test-frontend deotaland-frontend:v1.0.0
# 检查容器状态
docker ps
# 测试访问(本地浏览器)
# 访问: http://localhost:3001
# 停止测试容器
docker stop test-frontend
docker rm test-frontend
```
### 📦 第三步:导出镜像
#### 1. 保存镜像为文件
```bash
# 导出镜像(文件会很大,这是正常的)
docker save -o deotaland-frontend-v1.0.0.tar deotaland-frontend:v1.0.0
# 检查文件大小
ls -lh deotaland-frontend-v1.0.0.tar
```
#### 2. 压缩镜像文件(推荐)
```bash
# Windows 上压缩
powershell Compress-Archive deotaland-frontend-v1.0.0.tar deotaland-frontend-v1.0.0.tar.zip
# 或者使用 7zip
7z a deotaland-frontend-v1.0.0.tar.gz deotaland-frontend-v1.0.0.tar
```
### 🚀 第四步:上传到服务器
#### 方法 A使用 SCP推荐
```bash
# 直接上传需要服务器SSH权限
scp deotaland-frontend-v1.0.0.tar.gz deploy@192.168.1.100:/app/frontend/
# 如果端口不是22
scp -P 2222 deotaland-frontend-v1.0.0.tar.gz deploy@192.168.1.100:/app/frontend/
```
#### 方法 B使用 FTP/SFTP 工具
- FileZilla
- WinSCP
- MobaXterm
#### 方法 C通过中间存储
```bash
# 上传到云存储如阿里云OSS、AWS S3
# 后端再从云存储下载
```
### 🔧 第五步:后端导入和运行
#### 1. 登录服务器
```bash
# 后端操作
ssh deploy@192.168.1.100
# 进入部署目录
cd /app/frontend
```
#### 2. 解压文件(如果需要)
```bash
# 解压 tar.gz
gunzip deotaland-frontend-v1.0.0.tar.gz
# 或者解压 zip
unzip deotaland-frontend-v1.0.0.tar.zip
```
#### 3. 导入镜像
```bash
# 导入 Docker 镜像
docker load -i deotaland-frontend-v1.0.0.tar
# 验证导入成功
docker images | grep deotaland-frontend
```
#### 4. 准备部署文件
```bash
# 创建部署目录
mkdir -p /app/frontend/configs
# 上传 docker-compose 文件(前端提供)
# 保存为 docker-compose.yml
```
### 🎯 第六步:启动应用
#### 1. 使用 docker-compose 启动
```bash
# 在包含 docker-compose.yml 的目录下执行
cd /app/frontend
# 启动服务
docker-compose -f docker-compose.prod.yml up -d
# 检查状态
docker-compose ps
# 查看日志
docker-compose logs -f
```
#### 2. 验证部署成功
```bash
# 检查容器运行状态
docker ps
# 测试本地访问
curl http://localhost:3000
# 检查端口监听
netstat -tlnp | grep 3000
```
---
## 📁 需要提供的文件清单
### 📋 前端需要提供给后端的文件:
1. **镜像文件**(必需)
```
deotaland-frontend-v1.0.0.tar
```
2. **docker-compose 配置文件**(必需)
```
docker-compose.prod.yml
```
3. **部署文档**(推荐)
```
DEPLOYMENT_GUIDE.md (本文档)
```
4. **版本信息**(推荐)
```
version.txt - 包含版本号和构建时间
```
---
## 🔧 常用命令速查
### 前端构建命令
```bash
# 构建镜像
docker build -t deotaland-frontend:v1.0.0 .
# 导出镜像
docker save -o deotaland-frontend-v1.0.0.tar deotaland-frontend:v1.0.0
# 压缩文件
gzip deotaland-frontend-v1.0.0.tar
```
### 后端部署命令
```bash
# 导入镜像
docker load -i deotaland-frontend-v1.0.0.tar
# 启动服务
docker-compose -f docker-compose.prod.yml up -d
# 查看状态
docker-compose ps
# 查看日志
docker-compose logs -f
# 停止服务
docker-compose down
# 重启服务
docker-compose restart
```
---
## 🚨 常见问题解决
### 问题 1镜像文件太大
**解决方案:**
- 使用 `.dockerignore` 文件排除不需要的文件
- 使用多阶段构建优化镜像大小
- 考虑使用镜像仓库代替文件传输
### 问题 2上传速度慢
**解决方案:**
- 使用压缩工具gzip/7zip
- 分批上传或使用云存储中转
- 优化网络环境
### 问题 3容器启动失败
**排查步骤:**
```bash
# 1. 查看容器状态
docker ps -a
# 2. 查看错误日志
docker logs <container-id>
# 3. 检查端口占用
netstat -tlnp | grep 3000
# 4. 检查资源使用
docker stats
```
### 问题 4服务无法访问
**排查步骤:**
```bash
# 1. 检查容器是否运行
docker ps
# 2. 测试容器内服务
docker exec <container-id> curl http://localhost:3000
# 3. 检查防火墙设置
sudo ufw status # Ubuntu
sudo firewall-cmd --list-all # CentOS
# 4. 检查云服务器安全组设置
```
---
## 📊 性能优化建议
### 镜像优化
- 使用 Alpine Linux 基础镜像
- 实施多阶段构建
- 清理构建缓存
### 部署优化
- 使用镜像仓库Docker Registry
- 实施蓝绿部署
- 配置健康检查
### 监控建议
- 设置容器资源限制
- 配置日志收集
- 实施监控告警
---
## 🎯 下一步建议
1. **建立 CI/CD 流程**:自动化构建和部署
2. **使用镜像仓库**:替代文件传输
3. **配置域名和 SSL**:提供 HTTPS 访问
4. **设置监控告警**:及时发现问题
5. **实施自动扩容**:应对流量高峰
---
## 📞 支持联系方式
- **前端开发支持**:前端团队
- **后端运维支持**:运维团队
- **紧急问题**:电话/微信 XXX-XXXX-XXXX
---
**最后更新时间**2025年1月21日
**文档版本**v1.0
**适用范围**:前端 Docker 镜像部署到后端服务器

View File

@ -0,0 +1,205 @@
# Docker 本地镜像构建步骤指南
## 概述
本文档详细记录了在本地构建前端应用Docker镜像的完整步骤包括问题排查和解决方案。
## 环境要求
- Docker Desktop 已安装并运行
- 项目目录:`d:\work\Aiproject\DeotalandAi\apps\frontend`
## 构建步骤
### 1. 初始构建尝试
#### 1.1 执行构建命令
```bash
cd d:\work\Aiproject\DeotalandAi\apps\frontend
docker build -t deotaland-frontend .
```
#### 1.2 遇到的第一个问题
**错误信息:**
```
ERROR: pull access denied, repository does not exist or may require authorization
```
**问题原因:** 原Dockerfile使用了阿里云镜像源 `registry.cn-hangzhou.aliyuncs.com/library/node:18-alpine`,但该镜像源无法访问。
**解决方案:** 修改为使用官方Docker Hub镜像源。
### 2. Dockerfile修改
#### 2.1 修改基础镜像
将:
```dockerfile
FROM registry.cn-hangzhou.aliyuncs.com/library/node:18-alpine
```
修改为:
```dockerfile
FROM node:20-alpine
```
#### 2.2 调整依赖安装策略
将:
```dockerfile
RUN npm ci --only=production
```
修改为:
```dockerfile
RUN npm ci
```
**原因:** 需要安装开发依赖如vite来构建项目。
#### 2.3 升级Node.js版本
从Node.js 18升级到20解决Vite版本兼容性问题
```dockerfile
FROM node:18-alpine # 旧版本
FROM node:20-alpine # 新版本
```
### 3. 最终Dockerfile配置
```dockerfile
# 使用官方Node.js镜像
FROM node:20-alpine
# 设置工作目录
WORKDIR /app
# 复制 package.json 和 package-lock.json
COPY package*.json ./
# 使用国内npm镜像源
RUN npm config set registry https://registry.npmmirror.com/
# 复制项目文件
COPY . .
# 安装所有依赖(包括开发依赖)
RUN npm ci
# 构建生产版本
RUN npm run build
# 安装 serve 来提供静态文件服务
RUN npm install -g serve
# 暴露端口
EXPOSE 3000
# 启动命令
CMD ["serve", "-s", "dist", "-l", "3000"]
```
### 4. 成功构建
#### 4.1 执行构建
```bash
docker build -t deotaland-frontend .
```
#### 4.2 构建输出
```
=> [internal] load build definition from Dockerfile
=> [internal] load .dockerignore
=> [internal] load metadata for docker.io/library/node:20-alpine
=> [1/8] FROM docker.io/library/node:20-alpine
=> [2/8] WORKDIR /app
=> [3/8] COPY package*.json ./
=> [4/8] RUN npm config set registry https://registry.npmmirror.com/
=> [5/8] COPY . .
=> [6/8] RUN npm ci
=> [7/8] RUN npm run build
=> [8/8] RUN npm install -g serve
=> exporting to image
=> => exporting layers
=> => writing image sha256:...
=> => naming to docker.io/library/deotaland-frontend:latest
```
### 5. 运行容器
#### 5.1 启动容器
```bash
docker run -d -p 3001:3000 --name deotaland-frontend-container deotaland-frontend
```
#### 5.2 验证运行状态
```bash
docker ps
```
#### 5.3 查看容器日志
```bash
docker logs deotaland-frontend-container
```
**预期输出:**
```
INFO Accepting connections at http://localhost:3000
```
### 6. 访问应用
应用运行在http://localhost:3001
## 常见问题及解决方案
### 6.1 端口冲突
**问题:** 端口3000已被占用
**解决方案:** 使用不同的主机端口映射如3001:3000
### 6.2 容器名称冲突
**问题:** 容器名称已存在
**解决方案:**
```bash
docker rm deotaland-frontend-container
```
### 6.3 构建失败
**问题:** vite命令未找到
**解决方案:** 确保安装了所有依赖,不要使用`--only=production`
### 6.4 Node.js版本不兼容
**问题:** Vite需要Node.js 20.19+或22.12+
**解决方案:** 升级到Node.js 20
## 最佳实践
### 7.1 镜像优化建议
- 使用多阶段构建减少镜像大小
- 合理利用缓存层
- 使用`.dockerignore`文件排除不必要的文件
### 7.2 安全建议
- 使用非root用户运行应用
- 定期更新基础镜像
- 扫描镜像漏洞
### 7.3 性能优化
- 使用国内npm镜像源加速构建
- 合理设置工作目录
- 优化文件复制顺序
## 后续步骤
1. 配置CI/CD流水线自动构建
2. 设置镜像仓库推送
3. 配置生产环境部署
4. 设置监控和日志收集
## 相关文件
- `Dockerfile` - Docker镜像构建配置
- `.dockerignore` - Docker构建忽略文件
- `package.json` - 项目依赖配置
- `docker-compose.yml` - Docker Compose配置如存在
---
**构建完成时间:** $(date)
**构建状态:** ✅ 成功
**镜像名称:** deotaland-frontend
**容器名称:** deotaland-frontend-container
**访问地址:** http://localhost:3001

29
apps/frontend/Dockerfile Normal file
View File

@ -0,0 +1,29 @@
# 使用官方Node.js镜像
FROM node:20-alpine
# 设置工作目录
WORKDIR /app
# 复制 package.json 和 package-lock.json
COPY package*.json ./
# 使用国内npm镜像源
RUN npm config set registry https://registry.npmmirror.com/
# 复制项目文件
COPY . .
# 安装所有依赖(包括开发依赖)
RUN npm ci
# 构建生产版本
RUN npm run build
# 安装 serve 来提供静态文件服务
RUN npm install -g serve
# 暴露端口
EXPOSE 3000
# 启动命令
CMD ["serve", "-s", "dist", "-l", "3000"]

View File

@ -1,55 +0,0 @@
# Vercel 部署说明 🚀
## 快速开始
本项目已配置为可直接部署到 Vercel。以下是快速部署步骤
### 1. 推送代码到 Git 仓库
```bash
git add .
git commit -m "准备部署到 Vercel"
git push origin main
```
### 2. 在 Vercel 中导入项目
1. 访问 [vercel.com](https://vercel.com)
2. 点击 "New Project"
3. 选择你的 Git 仓库
4. 选择 `frontend` 作为根目录
5. 点击 "Deploy"
### 3. 配置环境变量
在 Vercel 项目设置中添加以下环境变量:
```
VITE_GOOGLE_API_KEY=你的_Google_AI_API_密钥
VITE_APP_BASE_API=你的_API_地址
VITE_STRIPE_PUBLISHABLE_KEY=你的_Stripe_公钥
NODE_ENV=production
```
## 重要文件说明
- `vercel.json` - Vercel 部署配置文件
- `vite.config.js` - 优化的 Vite 配置(包含 Vercel 特定设置)
- `.env.example` - 环境变量示例
- `docs/vercel-deployment-guide.md` - 完整部署指南
- `docs/environment-setup.md` - 环境变量详细说明
## 立即部署
想要现在就开始?运行以下命令:
```bash
# 安装 Vercel CLI
npm i -g vercel
# 登录并部署
vercel login
cd frontend
vercel --prod
```
## 更多详情
查看 [完整部署指南](docs/vercel-deployment-guide.md) 获取详细的部署说明和故障排除指南。

View File

@ -0,0 +1,64 @@
#!/bin/bash
# 前端 Docker 构建打包脚本
# 这个脚本用于在没有 Docker 环境的本地打包项目,然后上传到服务器构建
echo "🚀 开始打包前端项目..."
# 1. 清理旧的构建文件
echo "📦 清理旧文件..."
rm -rf dist-build.tar.gz docker-build.tar.gz
# 2. 构建生产版本
echo "🔨 构建生产版本..."
npm run build
# 3. 创建构建目录
echo "📁 创建构建包..."
mkdir -p build-package
# 4. 复制必要文件
echo "📋 复制文件..."
cp Dockerfile build-package/
cp docker-compose.yml build-package/
cp package.json build-package/
cp package-lock.json build-package/
cp -r dist build-package/
# 5. 复制其他必要文件(跳过 node_modules
cp -r src build-package/
cp -r public build-package/
cp index.html build-package/
cp vite.config.js build-package/
# 6. 创建部署脚本
cat > build-package/deploy.sh << 'EOF'
#!/bin/bash
# 服务器部署脚本
echo "🚀 开始部署前端应用..."
# 安装依赖
echo "📦 安装依赖..."
npm ci --only=production
# 构建镜像
echo "🔨 构建 Docker 镜像..."
docker build -t deotaland-frontend .
# 运行容器
echo "🐳 运行容器..."
docker run -d -p 80:3000 --restart=always --name frontend deotaland-frontend
echo "✅ 部署完成!访问 http://your-server-ip"
EOF
chmod +x build-package/deploy.sh
# 7. 打包
echo "📦 创建压缩包..."
tar -czf docker-build.tar.gz build-package/
echo "✅ 打包完成!"
echo "📁 文件docker-build.tar.gz"
echo "📤 下一步:上传到服务器并解压运行"

View File

@ -0,0 +1,92 @@
# ✅ 前端 Docker 部署检查清单
## 🔧 前端开发者检查项
### 构建前检查
- [ ] 代码已提交到版本控制
- [ ] 本地构建测试通过 (`npm run build`)
- [ ] 环境变量配置正确
- [ ] Dockerfile 已更新到最新版本
### 构建过程
- [ ] Docker 镜像构建成功
- [ ] 本地容器测试通过
- [ ] 镜像大小合理(< 1GB
- [ ] 镜像版本号已标记
### 导出准备
- [ ] 镜像导出为 tar 文件
- [ ] 文件已压缩(节省传输时间)
- [ ] 生成了版本信息文件
- [ ] 准备了部署文档
---
## 📋 需要提供给后端的文件
### 必需文件
```
📦 deotaland-frontend-v{版本号}.tar.gz # 压缩后的镜像文件
📄 docker-compose.prod.yml # 生产环境配置
📄 DEPLOYMENT_GUIDE.md # 部署指南
```
### 可选文件
```
📄 version.txt # 版本信息
📄 changelog.md # 更新日志
📄 rollback-plan.md # 回滚方案
```
---
## 🖥️ 后端运维检查项
### 环境准备
- [ ] 服务器资源充足CPU、内存、磁盘
- [ ] Docker 和 Docker Compose 已安装
- [ ] 端口 3000 未被占用
- [ ] 防火墙配置正确
### 部署过程
- [ ] 镜像文件成功上传到服务器
- [ ] 镜像导入无错误
- [ ] 容器启动成功
- [ ] 健康检查通过
### 验证测试
- [ ] 本地访问测试通过 (`curl localhost:3000`)
- [ ] 外部访问测试通过(如果开放)
- [ ] 日志无错误信息
- [ ] 性能指标正常
---
## 🚨 紧急联系方式
| 角色 | 姓名 | 联系方式 | 负责内容 |
|------|------|----------|----------|
| 前端开发 | - | - | 构建问题、代码问题 |
| 后端运维 | - | - | 部署问题、服务器问题 |
| 项目负责人 | - | - | 整体协调 |
---
## 📊 部署信息记录
### 本次部署信息
- **部署版本**v1.0.0
- **部署时间**2025-01-21
- **前端开发者**
- **后端运维**
- **镜像大小**
- **部署结果**:☐ 成功 ☐ 失败
### 回滚信息
- **回滚版本**
- **回滚原因**
- **回滚时间**
---
**✅ 所有检查项完成后,请在相应方框内打勾**

View File

@ -0,0 +1,113 @@
@echo off
echo 🚀 前端 Docker 镜像构建和导出脚本
echo ========================================
:: 设置变量
set VERSION=%1
if "%VERSION%"=="" set VERSION=1.0.0
set IMAGE_NAME=deotaland-frontend
set IMAGE_TAG=%IMAGE_NAME%:v%VERSION%
set EXPORT_FILE=%IMAGE_NAME%-v%VERSION%.tar
set COMPRESSED_FILE=%EXPORT_FILE%.gz
echo 📋 构建信息:
echo 镜像名称:%IMAGE_TAG%
echo 导出文件:%EXPORT_FILE%
echo 压缩文件:%COMPRESSED_FILE%
echo.
:: 步骤1构建镜像
echo 🔨 步骤1构建 Docker 镜像...
docker build -t %IMAGE_TAG% .
if %errorlevel% neq 0 (
echo ❌ 镜像构建失败!
pause
exit /b 1
)
echo ✅ 镜像构建成功!
echo.
:: 步骤2本地测试
echo 🧪 步骤2本地测试容器...
docker run -d -p 3001:3000 --name test-frontend %IMAGE_TAG%
:: 等待5秒让容器启动
echo ⏳ 等待容器启动...
timeout /t 5 /nobreak > nul
:: 检查容器状态
docker ps | findstr test-frontend > nul
if %errorlevel% neq 0 (
echo ❌ 容器启动失败!
docker logs test-frontend
docker rm -f test-frontend
pause
exit /b 1
)
echo ✅ 容器启动成功!
echo.
:: 步骤3停止测试容器
echo 🛑 步骤3清理测试容器...
docker stop test-frontend
docker rm test-frontend
echo ✅ 测试容器已清理
echo.
:: 步骤4导出镜像
echo 📦 步骤4导出镜像...
docker save -o %EXPORT_FILE% %IMAGE_TAG%
if %errorlevel% neq 0 (
echo ❌ 镜像导出失败!
pause
exit /b 1
)
echo ✅ 镜像导出成功!
echo.
:: 步骤5压缩文件
echo 🗜️ 步骤5压缩镜像文件...
powershell -Command "Compress-Archive -Path '%EXPORT_FILE%' -DestinationPath '%EXPORT_FILE%.zip' -Force"
if %errorlevel% neq 0 (
echo ⚠️ 压缩失败,但镜像文件可用
) else (
echo ✅ 镜像压缩成功!
:: 删除原始tar文件
del %EXPORT_FILE%
echo 🗑️ 已删除原始tar文件
)
echo.
:: 步骤6生成版本信息
echo 📝 步骤6生成版本信息...
echo %date% %time% > version-v%VERSION%.txt
echo 镜像:%IMAGE_TAG% >> version-v%VERSION%.txt
echo 版本v%VERSION% >> version-v%VERSION%.txt
echo ✅ 版本信息已生成
echo.
:: 步骤7显示结果
echo 🎉 构建完成!
echo ========================================
echo 📁 输出文件:
if exist %EXPORT_FILE%.zip (
echo - %EXPORT_FILE%.zip
) else (
echo - %EXPORT_FILE%
)
echo - version-v%VERSION%.txt
echo.
echo 📊 镜像信息:
docker images %IMAGE_NAME%
echo.
echo 🚀 下一步:
echo 1. 将生成的文件提供给后端运维
echo 2. 参考 DEPLOYMENT_GUIDE.md 进行部署
echo.
pause

View File

@ -0,0 +1,136 @@
#!/bin/bash
# 🚀 前端 Docker 镜像部署脚本(后端运维使用)
# ========================================
# 设置变量
VERSION=${1:-"1.0.0"}
REMOTE_USER=${2:-"deploy"}
REMOTE_HOST=${3:-"192.168.1.100"}
REMOTE_PORT=${4:-"22"}
LOCAL_IMAGE_FILE="deotaland-frontend-v${VERSION}.tar.gz"
LOCAL_COMPOSE_FILE="docker-compose.prod.yml"
LOCAL_CHECKLIST="deploy-checklist.md"
REMOTE_DIR="/app/frontend"
IMAGE_NAME="deotaland-frontend:v${VERSION}"
echo "🚀 前端 Docker 镜像部署脚本"
echo "======================================="
echo "📋 部署信息:"
echo " 版本: v${VERSION}"
echo " 远程主机: ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PORT}"
echo " 远程目录: ${REMOTE_DIR}"
echo " 镜像文件: ${LOCAL_IMAGE_FILE}"
echo ""
# 检查本地文件
if [ ! -f "${LOCAL_IMAGE_FILE}" ]; then
echo "❌ 镜像文件 ${LOCAL_IMAGE_FILE} 不存在!"
echo "请先运行 build-and-export.bat 构建镜像"
exit 1
fi
if [ ! -f "${LOCAL_COMPOSE_FILE}" ]; then
echo "❌ docker-compose 文件 ${LOCAL_COMPOSE_FILE} 不存在!"
exit 1
fi
echo "✅ 本地文件检查通过"
echo ""
# 步骤1创建远程目录
echo "📁 步骤1创建远程目录..."
ssh -p ${REMOTE_PORT} ${REMOTE_USER}@${REMOTE_HOST} "mkdir -p ${REMOTE_DIR}"
if [ $? -ne 0 ]; then
echo "❌ 创建远程目录失败!"
exit 1
fi
echo "✅ 远程目录创建成功"
echo ""
# 步骤2上传文件
echo "📤 步骤2上传文件到服务器..."
echo "正在上传镜像文件(这可能需要几分钟)..."
scp -P ${REMOTE_PORT} ${LOCAL_IMAGE_FILE} ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/
if [ $? -ne 0 ]; then
echo "❌ 镜像文件上传失败!"
exit 1
fi
scp -P ${REMOTE_PORT} ${LOCAL_COMPOSE_FILE} ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/docker-compose.yml
if [ $? -ne 0 ]; then
echo "❌ docker-compose 文件上传失败!"
exit 1
fi
if [ -f "${LOCAL_CHECKLIST}" ]; then
scp -P ${REMOTE_PORT} ${LOCAL_CHECKLIST} ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/
fi
echo "✅ 文件上传成功"
echo ""
# 步骤3在服务器上执行部署命令
echo "🔧 步骤3在服务器上执行部署..."
ssh -p ${REMOTE_PORT} ${REMOTE_USER}@${REMOTE_HOST} << EOF
cd ${REMOTE_DIR}
echo "📦 正在解压镜像文件..."
gunzip -f ${LOCAL_IMAGE_FILE}
echo "🐳 正在导入 Docker 镜像..."
docker load -i deotaland-frontend-v${VERSION}.tar
echo "📊 镜像导入成功,当前镜像列表:"
docker images | grep deotaland-frontend
echo "🛑 停止旧容器(如果存在)..."
docker-compose down || true
echo "🚀 启动新容器..."
docker-compose up -d
echo "⏳ 等待容器启动..."
sleep 10
echo "🔍 检查容器状态:"
docker-compose ps
echo "🧪 健康检查:"
curl -f http://localhost:3000 > /dev/null 2>&1
if [ \$? -eq 0 ]; then
echo "✅ 应用健康检查通过!"
else
echo "⚠️ 应用健康检查失败,请检查日志:"
docker-compose logs --tail=50
fi
echo "📊 容器资源使用情况:"
docker stats --no-stream \$(docker-compose ps -q)
EOF
if [ $? -ne 0 ]; then
echo "❌ 服务器部署失败!"
exit 1
fi
echo ""
echo "🎉 部署完成!"
echo "======================================="
echo "📊 部署信息:"
echo " 版本: v${VERSION}"
echo " 远程主机: ${REMOTE_USER}@${REMOTE_HOST}"
echo " 访问地址: http://${REMOTE_HOST}:3000"
echo ""
echo "🔍 验证步骤:"
echo "1. 在浏览器访问: http://${REMOTE_HOST}:3000"
echo "2. 检查容器状态: docker-compose ps"
echo "3. 查看日志: docker-compose logs -f"
echo ""
echo "🚀 部署成功!🎉"

View File

@ -0,0 +1,33 @@
version: '3.8'
services:
frontend:
image: deotaland-frontend:v1.0.0 # 使用预构建镜像
ports:
- "3000:3000"
environment:
- NODE_ENV=production
restart: unless-stopped
container_name: deotaland-frontend-prod
# 生产环境配置
deploy:
resources:
limits:
memory: 512M
cpus: '0.5'
# 健康检查
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# 日志配置
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"

View File

@ -0,0 +1,9 @@
services:
frontend: # 服务名称
build: . # 基于当前目录构建镜像
ports: # 端口映射
- "3000:3000" # 主机端口:容器端口
environment: # 环境变量
- NODE_ENV=production # 设置Node.js环境为生产环境
restart: unless-stopped # 自动重启策略
container_name: deotaland-frontend-compose # 容器名称

View File

@ -1,2 +1,2 @@
export const API_BASE_URL = 'https://traebackendzo2n.vercel.app';
// export const API_BASE_URL = 'http://localhost:3001';
// export const API_BASE_URL = 'https://traebackendzo2n.vercel.app';
export const API_BASE_URL = 'http://localhost:3001';

View File

@ -4,12 +4,9 @@ import { useRouter, useRoute } from 'vue-router'
import MainLayout from '@/components/layout/MainLayout.vue'
import AppHeader from '@/components/layout/AppHeader.vue'
import AppSidebar from '@/components/layout/AppSidebar.vue'
const route = useRoute()
//
const isLoginPage = computed(() => route.path === '/login')
//
const isFullScreenPage = computed(() => route.meta.fullScreen)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -0,0 +1,469 @@
<template>
<div v-if="show" class="guide-modal-overlay" @click="handleOverlayClick">
<div class="guide-modal-container" @click.stop>
<!-- 关闭按钮 -->
<button class="close-button" @click="closeModal" title="跳过引导">
<span class="close-icon">×</span>
</button>
<!-- 进度指示器 -->
<div class="progress-indicator">
<div
v-for="(step, index) in guideSteps"
:key="index"
class="progress-dot"
:class="{ active: index === currentStep, completed: index < currentStep }"
></div>
</div>
<!-- 引导内容区域 -->
<div class="guide-content">
<!-- 添加轮播容器 -->
<div class="guide-content-wrapper" :style="{ transform: `translateX(-${currentStep * 100}%)` }">
<div v-for="(step, index) in guideSteps" :key="index" class="guide-step">
<!-- 左侧图片区域 -->
<div class="guide-image-container">
<div class="image-wrapper">
<img
:src="step.image"
:alt="step.title"
class="guide-image"
/>
<div class="image-decoration"></div>
</div>
</div>
<!-- 右侧文字描述区域 -->
<div class="guide-text-container">
<div class="text-content">
<h2 class="guide-title">{{ step.title }}</h2>
<p class="guide-description">{{ step.description }}</p>
<!-- 额外提示信息 -->
<div v-if="step.tips" class="guide-tips">
<div class="tips-icon">💡</div>
<div class="tips-text">{{ step.tips }}</div>
</div>
</div>
<!-- 按钮区域 -->
<div class="guide-actions">
<!-- 上一步按钮 -->
<button
v-if="currentStep > 0"
class="action-button secondary"
@click="prevStep"
>
上一步
</button>
<!-- 跳过按钮 -->
<button
class="action-button skip"
@click="skipGuide"
>
跳过引导
</button>
<!-- 下一步/完成按钮 -->
<button
class="action-button primary"
@click="nextStep"
>
{{ isLastStep ? '开始创作' : '下一步' }}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 步骤指示器 -->
<div class="step-indicator">
<span class="step-text">{{ currentStep + 1 }} / {{ guideSteps.length }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
// props
const props = defineProps({
show: {
type: Boolean,
default: false
}
});
// emits
const emit = defineEmits(['close', 'complete']);
//
const currentStep = ref(0);
//
const guideSteps = ref([
{
id: 1,
title: '参考图片',
description: '选择您喜欢的图片作为创作参考',
image: new URL('@/assets/step/creatProject/step1.png', import.meta.url).href,
tips: '点击生成按钮后平台会根据您的选择生成相应的3D模型。'
},
{
id: 2,
title: '模型生成/文字优化',
description: '根据您的参考图片平台会生成对应的3D模型。',
image: new URL('@/assets/step/creatProject/step2.png', import.meta.url).href,
tips: '您也可以输入文字描述,平台会根据您的需求进行图片优化。'
},
{
id: 3,
title: '查看详情',
description: '点击查看详情按钮您可以查看更多关于您创作的3D模型的信息。',
image: new URL('@/assets/step/creatProject/step3.png', import.meta.url).href,
tips: ''
},
{
id: 4,
title: '定制到家',
description:'根据您的需求平台会为您定制专属的3D模型机器人确保符合您的要求。',
image: new URL('@/assets/step/creatProject/step4.png', import.meta.url).href,
tips: '您可以优先在智能体中配置模型角色',
}
]);
//
const currentStepData = computed(() => {
return guideSteps.value[currentStep.value] || {};
});
//
const isLastStep = computed(() => {
return currentStep.value === guideSteps.value.length - 1;
});
//
const prevStep = () => {
if (currentStep.value > 0) {
currentStep.value--;
}
};
//
const nextStep = () => {
if (currentStep.value < guideSteps.value.length - 1) {
currentStep.value++;
} else {
//
completeGuide();
}
};
//
const closeModal = () => {
emit('close');
};
//
const completeGuide = () => {
emit('complete');
closeModal();
};
//
const skipGuide = () => {
closeModal();
};
</script>
<style scoped>
.guide-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(3px);
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.guide-modal-container {
background: linear-gradient(135deg, rgba(107, 70, 193, 0.85) 0%, rgba(147, 51, 234, 0.85) 100%);
backdrop-filter: blur(10px);
border-radius: 20px;
width: 90%;
max-width: 900px;
max-height: 85vh;
overflow: hidden;
position: relative;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
animation: slideUp 0.4s ease-out;
color: white;
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
}
.close-button {
position: absolute;
top: 20px;
right: 20px;
width: 40px;
height: 40px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.2);
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
z-index: 10;
}
.close-button:hover {
background-color: rgba(255, 255, 255, 0.25);
transform: scale(1.1);
}
.close-icon {
font-size: 24px;
line-height: 1;
font-weight: 300;
}
.progress-indicator {
display: flex;
justify-content: center;
padding: 20px 0 10px;
gap: 8px;
}
.progress-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.3);
transition: all 0.3s ease;
}
.progress-dot.active {
width: 24px;
border-radius: 4px;
background-color: white;
}
.progress-dot.completed {
background-color: rgba(255, 255, 255, 0.7);
}
.guide-content {
display: flex;
flex: 1;
overflow: hidden;
position: relative;
}
/* 添加轮播容器 */
.guide-content-wrapper {
display: flex;
width: 100%;
height: 100%;
transition: transform 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.guide-step {
min-width: 100%;
display: flex;
flex: 1;
}
.guide-image-container {
flex: 1;
padding: 0 20px 20px 40px;
display: flex;
align-items: center;
justify-content: center;
}
.image-wrapper {
position: relative;
width: 100%;
height: 100%;
max-height: 350px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.guide-image {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.5s ease;
}
.image-wrapper:hover .guide-image {
transform: scale(1.03);
}
.image-decoration {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(45deg, rgba(107, 70, 193, 0.2) 0%, rgba(147, 51, 234, 0.2) 100%);
}
.guide-text-container {
flex: 1;
padding: 0 40px 20px 20px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.text-content {
flex: 1;
}
.guide-title {
font-size: 28px;
font-weight: 700;
margin: 0 0 16px;
line-height: 1.2;
}
.guide-description {
font-size: 16px;
line-height: 1.6;
margin: 0 0 20px;
opacity: 0.9;
}
.guide-tips {
display: flex;
align-items: flex-start;
background-color: rgba(255, 255, 255, 0.15);
border-radius: 12px;
padding: 12px;
margin-top: 16px;
}
.tips-icon {
font-size: 18px;
margin-right: 8px;
flex-shrink: 0;
}
.tips-text {
font-size: 14px;
line-height: 1.4;
}
.guide-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
}
.action-button {
padding: 12px 24px;
border-radius: 8px;
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
border: none;
display: flex;
align-items: center;
justify-content: center;
}
.action-button.primary {
background-color: rgba(255, 255, 255, 0.9);
color: #6B46C1;
}
.action-button.primary:hover {
background-color: rgba(255, 255, 255, 0.95);
transform: translateY(-2px);
}
.action-button.secondary {
background-color: rgba(255, 255, 255, 0.15);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.action-button.secondary:hover {
background-color: rgba(255, 255, 255, 0.25);
}
.action-button.skip {
background-color: transparent;
color: rgba(255, 255, 255, 0.7);
margin-right: auto;
}
.action-button.skip:hover {
color: white;
text-decoration: underline;
}
.step-indicator {
text-align: center;
padding: 16px 0;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.step-text {
font-size: 14px;
opacity: 0.7;
}
/* 响应式设计 */
@media (max-width: 768px) {
.guide-step {
flex-direction: column;
}
.guide-image-container,
.guide-text-container {
padding: 0 20px 20px;
}
.guide-title {
font-size: 24px;
}
.guide-actions {
flex-wrap: wrap;
}
.action-button.skip {
margin-right: 0;
margin-bottom: 8px;
}
}
</style>

View File

@ -57,6 +57,12 @@
position="top-right"
/>
<button class="guide-btn" @click="emit('openGuideModal')" title="使用指南">
<el-icon class="guide-icon">
<Guide />
</el-icon>
</button>
<ThemeToggle
:compact="false"
position="top-right"
@ -64,24 +70,22 @@
</div>
</header>
</template>
<script setup>
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { Star, ArrowLeft, Edit, Check } from '@element-plus/icons-vue'
import { Star, ArrowLeft, Edit, Check, Guide } from '@element-plus/icons-vue'
import { ElButton, ElIcon, ElInput } from 'element-plus'
import ThemeToggle from '../ui/ThemeToggle.vue'
import LanguageToggle from '../ui/LanguageToggle.vue'
const emit = defineEmits(['openGuideModal'])
// API
const projectName = ref('数字创作平台')
//
const userPoints = ref(1280)
//
const isMember = ref(true)
//
const showGuideModal = ref(false)
//
const isEditing = ref(false)
const editedProjectName = ref('')
@ -464,6 +468,49 @@ html.dark .project-name-input :deep(.el-input__wrapper:focus) {
background-color: #9333EA;
}
/* 指南按钮样式 */
.guide-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: none;
background-color: #F3F4F6;
color: #6B7280;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
}
.guide-btn:hover {
background-color: #E5E7EB;
color: #6B46C1;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.guide-btn:active {
transform: translateY(0);
}
.guide-icon {
font-size: 18px;
font-weight: 600;
}
/* 暗色主题适配 */
html.dark .guide-btn {
background-color: #374151;
color: #9CA3AF;
}
html.dark .guide-btn:hover {
background-color: #4B5563;
color: #A78BFA;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
/* 暗色主题适配 */
html.dark .header-component {
background-color: #1F2937;

View File

@ -30,7 +30,7 @@
<!-- <div class="toolbar-separator"></div> -->
<div class="toolbar-item label" @click="handleCustomize">
<el-icon class="toolbar-icon"><Present /></el-icon>
<span class="toolbar-label">定制到家</span>
<span class="toolbar-label">{{ t('modelModal.customizeToHome') }}</span>
</div>
<!-- <div class="toolbar-item label" @click="handleDeepFix">
<el-icon class="toolbar-icon"><MagicStick /></el-icon>
@ -45,6 +45,11 @@
:modelData="modelData"
@close="showCustomizeModal=false"
@buy="handleBuyFromCustomize" />
<OrderProcessModal
:show="showOrderProcessModal"
:modelData="modelData"
@close="showOrderProcessModal=false"
@acknowledge="handleAcknowledge" />
<PurchaseModal
:show="showPurchaseModal"
:template="purchaseInfo.template"
@ -55,12 +60,17 @@
<script setup>
import { ref, watch, onBeforeUnmount } from 'vue';
import { useI18n } from 'vue-i18n';
import { CloseBold, View, Picture, MagicStick, Present } from '@element-plus/icons-vue'
import { ElIcon } from 'element-plus'
import ThreeModelViewer from '../ThreeModelViewer/index.vue';
import CustomizationModal from '../CustomizationModal/index.vue';
import OrderProcessModal from '../OrderProcessModal/index.vue';
import PurchaseModal from '../PurchaseModal/index.vue';
// 使
const { t } = useI18n();
//
const props = defineProps({
//
@ -84,6 +94,7 @@ const isRotating = ref(true);
const isWireframe = ref(false);
const isWhiteMode = ref(false);
const showCustomizeModal = ref(false);
const showOrderProcessModal = ref(false);
const showPurchaseModal = ref(false);
const purchaseInfo = ref({ id: '', template: '', modelUrl: '' });
@ -150,7 +161,12 @@ const toggleWhiteMode = () => {
}
};
const handleCustomize = () => { showCustomizeModal.value = true };
const handleCustomize = () => {
//
// showCustomizeModal.value = true;
//
showOrderProcessModal.value = true;
};
const handleDeepFix = () => {};
const handleBuyFromCustomize = (payload) => {
@ -159,6 +175,17 @@ const handleBuyFromCustomize = (payload) => {
showPurchaseModal.value = true
};
const handleAcknowledge = (modelData) => {
// ""
// handleBuyFromCustomize使
const payload = {
id: modelData?.cardId || modelData?.taskId || '',
template: 'white', //
modelUrl: modelData?.modelUrl || ''
};
handleBuyFromCustomize(payload);
};
watch(() => props.show, (newVal) => {
if (newVal) {
//

View File

@ -0,0 +1,407 @@
<template>
<div v-if="show" class="order-process-overlay" @click="handleOverlayClick">
<div class="order-process-container" @click.stop>
<button class="close-button" @click="onClose" :aria-label="$t('common.close') || '关闭'">
<el-icon class="close-icon"><CloseBold /></el-icon>
</button>
<div class="process-header">
<h2 class="title">{{ $t('orderProcess.title') || '定制到家流程' }}</h2>
<p class="subtitle">{{ $t('orderProcess.subtitle') || '了解您的模型从制作到发货的全过程' }}</p>
</div>
<div class="process-timeline">
<div class="timeline-item" v-for="(step, index) in processSteps" :key="index">
<div class="timeline-marker">
<div class="marker-icon">
<el-icon><component :is="step.icon" /></el-icon>
</div>
<div v-if="index < processSteps.length - 1" class="marker-line"></div>
</div>
<div class="timeline-content">
<h3 class="step-title">{{ step.title }}</h3>
<p class="step-description">{{ step.description }}</p>
<div class="step-time">{{ step.time }}</div>
<!-- 缩略图显示 -->
<div v-if="step.hasThumbnail" class="step-thumbnail">
<img
src="https://picsum.photos/seed/product-parts/300/200.jpg"
alt="产品零件示例图"
class="thumbnail-image"
/>
<p class="thumbnail-caption">产品零件示例图</p>
</div>
</div>
</div>
</div>
<div class="process-note">
<el-icon class="note-icon"><InfoFilled /></el-icon>
<p>{{ $t('orderProcess.note') || '注意以上时间为工作日计算节假日可能会顺延。如有问题请联系客服13121765685' }}</p>
</div>
<div class="actions">
<button class="acknowledge-btn" @click="handleAcknowledge">
{{ $t('orderProcess.acknowledge') || '我已知晓' }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElIcon } from 'element-plus'
import {
CloseBold,
CreditCard,
Document,
Calendar,
Setting,
Picture,
Van,
InfoFilled
} from '@element-plus/icons-vue'
const { t } = useI18n()
const props = defineProps({
show: { type: Boolean, default: false },
modelData: { type: Object, default: null }
})
const emit = defineEmits(['close', 'acknowledge'])
const handleOverlayClick = () => onClose()
const onClose = () => emit('close')
const handleAcknowledge = () => {
emit('acknowledge', props.modelData)
onClose()
}
//
const processSteps = computed(() => [
{
icon: 'CreditCard',
title: t('orderProcess.steps.payment.title'),
description: t('orderProcess.steps.payment.description'),
time: t('orderProcess.steps.payment.time')
},
{
icon: 'Document',
title: t('orderProcess.steps.review.title'),
description: t('orderProcess.steps.review.description'),
time: t('orderProcess.steps.review.time')
},
{
icon: 'Calendar',
title: t('orderProcess.steps.scheduling.title'),
description: t('orderProcess.steps.scheduling.description'),
time: t('orderProcess.steps.scheduling.time')
},
{
icon: 'Setting',
title: t('orderProcess.steps.production.title'),
description: t('orderProcess.steps.production.description'),
time: t('orderProcess.steps.production.time')
},
{
icon: 'Picture',
title: t('orderProcess.steps.inspection.title'),
description: t('orderProcess.steps.inspection.description'),
time: t('orderProcess.steps.inspection.time'),
hasThumbnail: true
},
{
icon: 'Van',
title: t('orderProcess.steps.shipping.title'),
description: t('orderProcess.steps.shipping.description'),
time: t('orderProcess.steps.shipping.time')
}
])
</script>
<style scoped>
.order-process-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(6px);
z-index: 1001;
display: flex;
align-items: center;
justify-content: center;
}
.order-process-container {
width: 90vw;
max-width: 800px;
max-height: 90vh;
background: var(--content-bg, #111827);
border: 1px solid var(--border-color, #374151);
border-radius: 16px;
color: var(--text-color, #f9fafb);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
}
.close-button {
position: absolute;
top: 12px;
right: 12px;
width: 40px;
height: 40px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.22);
background: rgba(17, 24, 39, 0.6);
color: #fff;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.close-icon {
color: #ffffff;
font-size: 18px;
}
.process-header {
padding: 24px 24px 16px;
text-align: center;
}
.title {
margin: 0;
font-size: 24px;
font-weight: 700;
color: #ffffff;
}
.subtitle {
margin: 8px 0 0;
font-size: 14px;
color: var(--text-secondary, #cbd5e1);
}
.process-timeline {
padding: 0 24px;
overflow-y: auto;
flex: 1;
/* Custom scrollbar for timeline */
scrollbar-width: thin;
scrollbar-color: rgba(167, 139, 250, 0.3) transparent;
}
/* Webkit scrollbar for timeline */
.process-timeline::-webkit-scrollbar {
width: 6px;
}
.process-timeline::-webkit-scrollbar-track {
background: transparent;
}
.process-timeline::-webkit-scrollbar-thumb {
background: rgba(167, 139, 250, 0.3);
border-radius: 3px;
}
.process-timeline::-webkit-scrollbar-thumb:hover {
background: rgba(167, 139, 250, 0.5);
}
.timeline-item {
display: flex;
margin-bottom: 24px;
position: relative;
}
.timeline-marker {
display: flex;
flex-direction: column;
align-items: center;
margin-right: 16px;
}
.marker-icon {
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(139, 92, 246, 0.2);
border: 2px solid rgba(139, 92, 246, 0.5);
display: flex;
align-items: center;
justify-content: center;
color: #a78bfa;
font-size: 18px;
}
.marker-line {
width: 2px;
height: 60px;
background: rgba(139, 92, 246, 0.3);
margin-top: 8px;
}
.timeline-content {
flex: 1;
padding-top: 4px;
}
.step-title {
margin: 0 0 8px 0;
font-size: 18px;
font-weight: 600;
color: #ffffff;
}
.step-description {
margin: 0 0 8px 0;
font-size: 14px;
color: var(--text-secondary, #cbd5e1);
line-height: 1.5;
}
.step-time {
font-size: 13px;
color: #a78bfa;
font-weight: 500;
}
.step-thumbnail {
margin-top: 12px;
border-radius: 8px;
overflow: hidden;
border: 1px solid rgba(139, 92, 246, 0.2);
}
.thumbnail-image {
width: 100%;
height: auto;
display: block;
border-radius: 8px 8px 0 0;
}
.thumbnail-caption {
margin: 0;
padding: 8px 12px;
font-size: 12px;
color: var(--text-secondary, #cbd5e1);
text-align: center;
background: rgba(139, 92, 246, 0.05);
}
.process-note {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 16px 24px;
background: rgba(139, 92, 246, 0.1);
border-top: 1px solid rgba(139, 92, 246, 0.2);
}
.note-icon {
color: #a78bfa;
font-size: 18px;
margin-top: 2px;
}
.process-note p {
margin: 0;
font-size: 13px;
color: var(--text-secondary, #cbd5e1);
line-height: 1.5;
}
.actions {
display: flex;
justify-content: center;
padding: 16px 24px 24px;
}
.acknowledge-btn {
height: 44px;
padding: 0 24px;
border-radius: 10px;
border: 1px solid rgba(139, 92, 246, 0.35);
background: rgba(139, 92, 246, 0.25);
color: #fff;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.acknowledge-btn:hover {
background: rgba(139, 92, 246, 0.35);
border-color: rgba(139, 92, 246, 0.5);
}
/* 响应式设计 */
@media (max-width: 768px) {
.order-process-container {
width: 96vw;
max-height: 95vh;
}
.process-header {
padding: 20px 20px 12px;
}
.title {
font-size: 20px;
}
.subtitle {
font-size: 13px;
}
.process-timeline {
padding: 0 20px;
}
.timeline-item {
margin-bottom: 20px;
}
.marker-icon {
width: 36px;
height: 36px;
font-size: 16px;
}
.marker-line {
height: 50px;
}
.step-title {
font-size: 16px;
}
.step-description {
font-size: 13px;
}
.process-note {
padding: 12px 20px;
}
.actions {
padding: 12px 20px 20px;
}
.acknowledge-btn {
height: 40px;
padding: 0 20px;
font-size: 15px;
}
}
</style>

View File

@ -51,42 +51,45 @@ const props = defineProps({
}
})
const authStore = useAuthStore()
const isProcessing = ref(false)
// Google Identity Services
const loadGoogleScript = () => {
return new Promise((resolve, reject) => {
if (window.google && window.google.accounts && window.google.accounts.id) {
return resolve()
}
const script = document.createElement('script')
script.src = 'https://accounts.google.com/gsi/client'
script.async = true
script.defer = true
script.onload = () => resolve()
script.onerror = (e) => reject(new Error('Google Identity Services 脚本加载失败'))
document.head.appendChild(script)
})
}
// Google
const handleGoogleLogin = async () => {
if (isProcessing.value || props.loading) return
if (isProcessing.value ) return
isProcessing.value = true
try {
// Google OAuth
const mockGoogleUser = {
id: 'google_user_' + Date.now(),
email: 'creator@demo.com',
name: 'Demo Creator',
picture: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCA0MCA0MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGNpcmNsZSBjeD0iMjAiIGN5PSIyMCIgcj0iMjAiIGZpbGw9IiNGMkY0RjgiLz4KPHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4PSI4IiB5PSI4Ij4KPHBhdGggZD0iTTEyIDJDNi40OCAyIDIgNi40OCAyIDEyUzYuNDggMjIgMTIgMjJTMjIgMTcuNTIgMjIgMTJTMTcuNTIgMiAxMiAyWk0xMiA3QzkuMjQgNyA3IDkuMjQgNyAxMlM5LjI0IDE3IDEyIDE3UzE3IDE0Ljc2IDE3IDEyUzE0Ljc2IDcgMTIgN1pNMTIgMTZDMTAuMzQgMTYgOSAxNC42NiA5IDEzUzEwLjM0IDEwIDEyIDEwUzE1IDExLjM0IDE1IDEzUzEzLjY2IDE2IDEyIDE2WiIgZmlsbD0iIzQyODVGNCIvPgo8L3N2Zz4KPC9zdmc+'
await loadGoogleScript()
const clientId = '680509991778-f5qgqbampabs1atblvm1jkoi4itl1nni.apps.googleusercontent.com'
const callback = async (response) => {
const idToken = response && response.credential
if (!idToken) {
errorMessage.value = '未获取到 Google 身份凭证'
return
}
console.log(idToken,'idTokenidToken');
}
// store Google
const result = await authStore.loginWithGoogle(mockGoogleUser)
if (result.success) {
emit('success', result.user)
} else {
emit('error', result.error)
}
} catch (error) {
const errorMessage = error.message || t('login.google_login_processing_error')
emit('error', errorMessage)
} finally {
isProcessing.value = false
}
console.log('window.google');
// One Tap
window.google.accounts.id.initialize({ client_id: clientId, callback })
//
window.google.accounts.id.prompt()
}
onMounted(() => {
loadGoogleScript()
})
// Google OAuth SDK
// 使 @google-cloud/local-auth
//

View File

@ -75,24 +75,18 @@
<script setup>
import { ref, computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useI18n } from 'vue-i18n'
import { WarningFilled, View, Hide, InfoFilled } from '@element-plus/icons-vue'
import { useVuelidate } from '@vuelidate/core'
import { required, email, minLength } from '@vuelidate/validators'
const { t } = useI18n()
const emit = defineEmits(['success', 'error'])
const emit = defineEmits(['login', 'error'])
const props = defineProps({
loading: {
type: Boolean,
default: false
}
})
const authStore = useAuthStore()
//
const form = ref({
email: '',
@ -150,32 +144,12 @@ watch(() => form.value.password, validatePassword)
const handleLogin = async () => {
//
await v$.value.$validate()
if (!v$.value.$valid) {
if (v$.value.$invalid) {
validateEmail()
validatePassword()
return
}
try {
const result = await authStore.login({
email: form.value.email,
password: form.value.password
})
if (result.success) {
emit('success', result.user)
//
form.value.email = ''
form.value.password = ''
} else {
emit('error', result.error)
}
} catch (error) {
const errorMessage = error.message || t('login.login_processing_error')
emit('error', errorMessage)
}
emit('login', form.value)
}
</script>

View File

@ -51,7 +51,6 @@
</div>
<div v-if="passwordError" class="error-message">{{ passwordError }}</div>
</div>
<!-- 确认密码输入 -->
<div class="form-group">
<label class="form-label" for="confirmPassword">{{ t('register.confirm_password_label') }}</label>

View File

@ -470,7 +470,10 @@ export default {
.brand-name {
font-size: 20px;
font-weight: 700;
color: var(--text-primary, #1f2937);
background: linear-gradient(135deg, #6B46C1 0%, #A78BFA 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: -0.025em;
}

View File

@ -141,7 +141,7 @@ export default {
{
id: 'agent-management',
path: '/agent-management',
label: t('sidebar.agentManagement'),
label: t('sidebar.agentManagement.title'),
icon: 'BrainIcon',
badge: null
},
@ -152,13 +152,13 @@ export default {
icon: 'OrdersIcon',
badge: null
},
{
id: 'device-settings',
path: '/device-settings',
label: t('sidebar.deviceSettings'),
icon: 'SettingsIcon',
badge: null
}
// {
// id: 'device-settings',
// path: '/device-settings',
// label: t('sidebar.deviceSettings'),
// icon: 'SettingsIcon',
// badge: null
// }
])
//

View File

@ -44,7 +44,122 @@ export default {
creationWorkspace: '项目',
projectGallery: '画廊',
deviceSettings: '设置',
agentManagement: '智能体'
agentManagement: {
title: '智能体',
description: '管理和配置您的AI智能体',
createAgent: '创建智能体',
createTitle: '创建智能体',
name: '智能体名称',
namePlaceholder: '请输入智能体名称',
nameRequired: '请输入智能体名称',
modelPlaceholder: '请选择语言模型',
filters: {
status: '状态筛选',
search: '搜索智能体',
sort: '排序方式',
category: '分类筛选'
},
searchPlaceholder: '搜索智能体名称、描述...',
stats: {
totalAgents: '智能体总数',
online: '在线',
offline: '离线',
totalConversations: '总对话数'
},
agentsList: '智能体列表',
actions: {
view: '查看详情',
edit: '编辑',
delete: '删除',
configure: '配置',
more: '更多操作',
connect: '连接设备',
test: '测试对话'
},
empty: {
title: '暂无智能体',
description: '您还没有创建任何智能体',
action: '创建智能体'
},
status: {
all: '全部',
active: '活跃',
inactive: '未激活',
maintenance: '维护中'
},
sort: {
name: '名称',
createdAt: '创建时间',
lastActive: '最后活跃',
status: '状态'
},
category: {
all: '全部分类',
assistant: '助手',
customerService: '客服',
content: '内容',
education: '教育',
business: '商业'
},
form: {
name: '智能体名称',
description: '描述',
category: '分类',
model: '模型',
prompt: '系统提示',
temperature: '温度',
maxTokens: '最大令牌数',
status: '状态',
active: '激活',
inactive: '未激活'
},
dialog: {
createTitle: '创建智能体',
editTitle: '编辑智能体',
deviceBindTitle: '绑定设备'
}
}
},
modelModal: {
customizeToHome: '定制到家'
},
orderProcess: {
title: '定制到家流程',
subtitle: '了解您的订单从支付到发货的全过程',
note: '注意以上时间为工作日计算节假日可能会顺延。如有问题请联系客服13121765685',
acknowledge: '我已知晓',
steps: {
payment: {
title: '支付订单',
description: '选择支付方式完成订单支付,支付成功后订单将进入审核状态。',
time: '即时处理'
},
review: {
title: '订单审核',
description: '后台将审核订单对应的模型是否可以制作,审核通过后进入排期生产,审核不通过将自动退款。',
time: '1-2个工作日'
},
scheduling: {
title: '生产排期',
description: '审核通过后,订单将进入生产排期队列,等待生产开始。',
time: '1个工作日'
},
production: {
title: '模型制作',
description: '专业团队使用高精度3D打印机制作您的定制模型确保每个细节都完美呈现。',
time: '7-10个工作日'
},
inspection: {
title: '产品检测包装',
description: '模型制作完成后,将进行产品质量检测和零件整理包装,确保产品完好无损。',
time: '1个工作日'
},
shipping: {
title: '物流发货',
description: '包装完成后将通过顺丰速运发货,您将收到包含跟踪号码的邮件通知。',
time: '1-3个工作日'
}
}
},
header: {
searchPlaceholder: '搜索功能、内容或帮助...',
@ -69,7 +184,7 @@ export default {
description: '统一设计、国际化与性能优化已集成。',
floatingCards: {
orders: '订单',
settings: '设置',
settings: '智能体',
gallery: '画廊'
},
welcome: {
@ -99,7 +214,23 @@ export default {
analyticsDesc: '查看创作数据统计和趋势分析',
templates: '模板库',
templatesDesc: '使用专业模板快速开始创作',
tryNow: '立即体验'
tryNow: '立即体验',
create: {
title: '智能创作',
desc: 'AI驱动的创意内容生成平台'
},
orders: {
title: '订单管理',
desc: '查看和管理您的所有订单'
},
settings: {
title: '系统设置',
desc: '个性化配置您的创作环境'
},
gallery: {
title: '作品画廊',
desc: '浏览和分享您的创意作品'
}
},
recentActivity: {
title: '最近活动',
@ -668,7 +799,9 @@ export default {
no_account: '还没有账号?',
register_now: '立即注册',
theme_toggle_tooltip: '切换到深色主题',
language_toggle_tooltip: '切换到英文'
language_toggle_tooltip: '切换到英文',
email_label: '邮箱地址',
email_placeholder: '请输入邮箱地址',
},
register: {
title: '创建账号',
@ -676,8 +809,34 @@ export default {
back_to_login: '返回登录',
has_account: '已有账号?',
login_now: '立即登录',
no_account: '还没有账号?',
register_now: '立即注册',
theme_toggle_tooltip: '切换到深色主题',
language_toggle_tooltip: '切换到英文'
language_toggle_tooltip: '切换到英文',
email_label: '邮箱地址',
email_placeholder: '请输入邮箱地址',
password_label: '密码',
password_placeholder: '请输入密码',
confirm_password_label: '确认密码',
confirm_password_placeholder: '请确认密码',
username_label: '用户名',
username_placeholder: '请输入用户名',
register_button: '注册',
registering: '注册中...',
terms_agreement: '注册即表示您同意我们的',
terms_link: '服务条款',
and: '和',
privacy_link: '隐私政策',
email_empty_error: '请输入邮箱地址',
email_invalid_error: '请输入有效的邮箱地址',
password_empty_error: '请输入密码',
password_min_error: '密码至少需要6个字符',
password_strength_error: '密码必须包含字母和数字',
confirm_password_empty_error: '请确认密码',
password_mismatch_error: '两次输入的密码不一致',
username_min_error: '用户名至少需要3个字符',
username_invalid_error: '用户名只能包含字母、数字和下划线',
register_processing_error: '注册过程中发生错误'
},
common: {
close: '关闭',
@ -706,7 +865,41 @@ export default {
login: 'Login',
register: 'Register',
forgotPassword: 'Forgot Password',
modelPurchase: 'Model Purchase'
modelPurchase: {
inputLabel: 'Please fill in the model link or ID',
inputPlaceholder: 'https://studio.tripo3d.ai/workspace/generate?project=... or Model ID',
timeline: {
title: 'Order Status',
orderPlaced: 'Order Placed',
orderShipped: 'Order Shipped',
delivered: 'Delivered',
hint: 'Shipping information will be sent via email, you can check order status anytime'
}
}
},
forgotPassword: {
title: 'Reset Password',
subtitle: 'Enter your email address and we will send you a password reset link',
back_to_login: 'Back to Login',
remember_password: 'Remember your password?',
login_now: 'Login Now',
no_account: 'No account yet?',
register_now: 'Register Now',
theme_toggle_tooltip: 'Switch to dark theme',
language_toggle_tooltip: 'Switch to Chinese',
email_label: 'Email Address',
email_placeholder: 'Enter your email',
email_empty_error: 'Please enter email address',
email_invalid_error: 'Please enter a valid email address',
send_reset_email: 'Send Reset Email',
sending: 'Sending...',
email_sent_title: 'Email Sent Successfully',
email_sent_description: 'We have sent a password reset link to your email, please check your inbox',
email_not_received: "Haven't received the email?",
resend_after: 'Resend after',
resend_email: 'Resend Email',
reset_processing_error: 'An error occurred during password reset processing',
resend_processing_error: 'An error occurred during email resending processing'
},
sidebar: {
dashboard: 'Dashboard',
@ -735,6 +928,7 @@ export default {
createAgent: 'Create Agent',
createTitle: 'Create Agent',
name: 'Agent Name',
namePlaceholder: 'Enter agent name',
filters: {
status: 'Status Filter',
search: 'Search Agents',
@ -803,6 +997,47 @@ export default {
},
deviceSettings: 'Settings'
},
modelModal: {
customizeToHome: 'Customize to Home'
},
orderProcess: {
title: 'Customize to Home Process',
subtitle: 'Understand the complete process from payment to delivery',
note: 'Note: The above times are calculated in working days. Holidays may cause delays. If you have any questions, please contact customer service: 13121765685',
acknowledge: 'I Acknowledge',
steps: {
payment: {
title: 'Payment',
description: 'Select a payment method to complete your order. After successful payment, the order will enter the review status.',
time: 'Instant processing'
},
review: {
title: 'Order Review',
description: 'Our team will review whether the model corresponding to your order can be produced. If approved, it will enter production scheduling; if rejected, the order will be automatically refunded.',
time: '1-2 business days'
},
scheduling: {
title: 'Production Scheduling',
description: 'After approval, your order will enter the production queue and wait for production to begin.',
time: '1 business day'
},
production: {
title: 'Model Creation',
description: 'Our professional team will use high-precision 3D printers to create your custom model, ensuring every detail is perfectly presented.',
time: '7-10 business days'
},
inspection: {
title: 'Product Inspection & Packaging',
description: 'After the model is completed, we will conduct product quality inspection and parts packaging to ensure the product is intact.',
time: '1 business day'
},
shipping: {
title: 'Shipping',
description: 'Once packaged, your order will be shipped via SF Express. You will receive an email notification with a tracking number.',
time: '1-3 business days'
}
}
},
header: {
searchPlaceholder: 'Search features, content or help...',
notifications: 'Notifications',
@ -826,7 +1061,7 @@ export default {
description: 'Design system, i18n, and performance optimizations integrated.',
floatingCards: {
orders: 'Orders',
settings: 'Settings',
settings: 'Agent',
gallery: 'Gallery'
},
welcome: {
@ -856,7 +1091,23 @@ export default {
analyticsDesc: 'View creation data statistics and trend analysis',
templates: 'Templates',
templatesDesc: 'Start creating quickly with professional templates',
tryNow: 'Try Now'
tryNow: 'Try Now',
create: {
title: 'Smart Creation',
desc: 'AI-powered creative content generation platform'
},
orders: {
title: 'Order Management',
desc: 'View and manage all your orders'
},
settings: {
title: 'System Settings',
desc: 'Personalize your creative environment'
},
gallery: {
title: 'Gallery',
desc: 'Browse and share your creative works'
}
},
recentActivity: {
title: 'Recent Activity',
@ -1078,6 +1329,41 @@ export default {
theme_toggle_light: 'Switch to light theme',
theme_toggle_dark: 'Switch to dark theme',
},
register: {
title: 'Create Account',
subtitle: 'Join us and start your creative journey',
back_to_login: 'Back to Login',
has_account: 'Already have an account?',
login_now: 'Login Now',
no_account: 'No account yet?',
register_now: 'Register Now',
theme_toggle_tooltip: 'Switch to dark theme',
language_toggle_tooltip: 'Switch to Chinese',
email_label: 'Email Address',
email_placeholder: 'Enter your email',
password_label: 'Password',
password_placeholder: 'Enter your password',
confirm_password_label: 'Confirm Password',
confirm_password_placeholder: 'Confirm your password',
username_label: 'Username',
username_placeholder: 'Enter your username',
register_button: 'Register',
registering: 'Registering...',
terms_agreement: 'By registering, you agree to our ',
terms_link: 'Terms of Service',
and: ' and ',
privacy_link: 'Privacy Policy',
email_empty_error: 'Please enter email address',
email_invalid_error: 'Please enter a valid email address',
password_empty_error: 'Please enter password',
password_min_error: 'Password must be at least 6 characters',
password_strength_error: 'Password must contain letters and numbers',
confirm_password_empty_error: 'Please confirm your password',
password_mismatch_error: 'Passwords do not match',
username_min_error: 'Username must be at least 3 characters',
username_invalid_error: 'Username can only contain letters, numbers and underscores',
register_processing_error: 'An error occurred during registration processing'
},
payment: {
title: 'Pay Order',
orderId: 'Order ID',

View File

@ -3,7 +3,7 @@ import { useAuthStore } from '@/stores/auth'
const ModernHome = () => import('../views/ModernHome.vue')
const List = () => import('../views/List.vue')
const Login = () => import('../views/Login.vue')
const Login = () => import('../views/Login/Login.vue')
const Register = () => import('../views/Register.vue')
const ForgotPassword = () => import('../views/ForgotPassword.vue')
const CreationWorkspace = () => import('../views/CreationWorkspace.vue')
@ -120,11 +120,10 @@ const router = createRouter({
// 路由守卫
router.beforeEach(async (to, from, next) => {
next()
return
const authStore = useAuthStore()
// 获取当前用户状态
authStore.initAuth()
const isAuthenticated = authStore.isAuthenticated
const currentUser = authStore.user

View File

@ -1,248 +1,61 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { jwtVerify } from 'jose'
// 用户角色标识定义
export const USER_ROLES = {
CREATOR: 'creator',
ADMIN: 'admin',
VIEWER: 'viewer'
}
// 角色标识显示名称
export const ROLE_LABELS = {
[USER_ROLES.CREATOR]: '创作者',
[USER_ROLES.ADMIN]: '管理员',
[USER_ROLES.VIEWER]: '观察者'
}
import { request } from '../utils/request'
export const useAuthStore = defineStore('auth', () => {
// 状态定义
const user = ref(null)
const token = ref('')
const loading = ref(false)
const error = ref(null)
// 计算属性
const isAuthenticated = computed(() => !!token.value && !!user.value)
const userRole = computed(() => user.value?.role || null)
const isCreator = computed(() => userRole.value === USER_ROLES.CREATOR)
const isAdmin = computed(() => userRole.value === USER_ROLES.ADMIN)
const isViewer = computed(() => userRole.value === USER_ROLES.VIEWER)
const hasRole = computed(() => (role) => {
return userRole.value === role
})
// 角色权限检查(简化版本 - 仅检查角色标识)
const checkPermission = (requiredRole) => {
if (!isAuthenticated.value) return false
const roleHierarchy = {
[USER_ROLES.CREATOR]: 3,
[USER_ROLES.ADMIN]: 2,
[USER_ROLES.VIEWER]: 1
}
const userLevel = roleHierarchy[userRole.value] || 0
const requiredLevel = roleHierarchy[requiredRole] || 0
return userLevel >= requiredLevel
}
// 登录方法
const login = async (credentials) => {
const login = async (data) => {
loading.value = true
error.value = null
try {
// 模拟登录逻辑
// 实际项目中这里会调用后端API
const response = await mockLogin(credentials)
user.value = response.user
token.value = response.token
// 保存到localStorage
localStorage.setItem('auth_user', JSON.stringify(response.user))
localStorage.setItem('auth_token', response.token)
return { success: true }
} catch (err) {
error.value = err.message || '登录失败'
return { success: false, error: error.value }
} finally {
loading.value = false
}
}
// Google OAuth登录
const loginWithGoogle = async (googleUser) => {
loading.value = true
error.value = null
try {
// 验证Google token实际项目中需要后端验证
const response = await mockGoogleLogin(googleUser)
user.value = response.user
token.value = response.token
// 保存到localStorage
localStorage.setItem('auth_user', JSON.stringify(response.user))
localStorage.setItem('auth_token', response.token)
return { success: true }
} catch (err) {
error.value = err.message || 'Google登录失败'
return { success: false, error: error.value }
} finally {
loading.value = false
}
}
// 登出方法
const logout = () => {
user.value = null
token.value = ''
error.value = null
// 清除localStorage
localStorage.removeItem('auth_user')
localStorage.removeItem('auth_token')
}
// 初始化认证状态从localStorage恢复
const initAuth = () => {
const savedUser = localStorage.getItem('auth_user')
const savedToken = localStorage.getItem('auth_token')
if (savedUser && savedToken) {
try {
user.value = JSON.parse(savedUser)
token.value = savedToken
// 验证token有效性简化版本
// 实际项目中应该检查token是否过期
} catch (err) {
// 清除无效的认证信息
logout()
const res = await request.common(request.url.LOGIN, data)
if(res.code === 200){
// 登录成功保存token和用户信息
token.value = res.data.token
user.value = res.data.user
localStorage.setItem('token', res.data.token)
return res
}
return res
} catch (error) {
console.error('登录失败:', error)
throw error
} finally {
loading.value = false
}
}
// 角色分配方法
const assignRole = (role, userInfo = {}) => {
if (!Object.values(USER_ROLES).includes(role)) {
throw new Error('无效的用户角色')
// 登出方法
const logout = async () => {
loading.value = true
try {
const res = await request.common(request.url.LOGOUT)
if(res.code === 200){
// 登出成功清除token和用户信息
user.value = null
token.value = ''
localStorage.removeItem('token')
return res
}
return res
} catch (error) {
console.error('登出失败:', error)
throw error
} finally {
loading.value = false
}
const userData = {
...userInfo,
role: role,
roleLabel: ROLE_LABELS[role]
}
user.value = userData
// 更新localStorage
localStorage.setItem('auth_user', JSON.stringify(userData))
return userData
}
// 获取用户信息
const getUserInfo = () => {
if (!user.value) return null
return {
...user.value,
roleLabel: ROLE_LABELS[user.value.role] || '未知角色'
}
}
return {
// 状态
user,
token,
loading,
error,
// 计算属性
isAuthenticated,
userRole,
isCreator,
isAdmin,
isViewer,
hasRole,
// 方法
login,
loginWithGoogle,
logout,
initAuth,
assignRole,
getUserInfo,
checkPermission
user,
token,
login,
logout,
getUserInfo,
loading
}
})
// 模拟登录函数
async function mockLogin(credentials) {
// 模拟API延迟
await new Promise(resolve => setTimeout(resolve, 1000))
// 简单验证
if (!credentials.email || !credentials.password) {
throw new Error('请输入邮箱和密码')
}
// 模拟用户角色分配(根据邮箱白名单)
let role = USER_ROLES.VIEWER
if (credentials.email.includes('admin')) {
role = USER_ROLES.ADMIN
} else if (credentials.email.includes('creator')) {
role = USER_ROLES.CREATOR
}
return {
user: {
id: Date.now(),
email: credentials.email,
name: credentials.email.split('@')[0],
role: role,
roleLabel: ROLE_LABELS[role],
avatar: null,
createdAt: new Date().toISOString()
},
token: `mock_token_${Date.now()}_${role}`,
expiresAt: Date.now() + 24 * 60 * 60 * 1000 // 24小时后过期
}
}
// 模拟Google OAuth登录
async function mockGoogleLogin(googleUser) {
// 模拟API延迟
await new Promise(resolve => setTimeout(resolve, 800))
// Google OAuth 用户默认分配创作者角色
const role = USER_ROLES.CREATOR
return {
user: {
id: googleUser.id || Date.now(),
email: googleUser.email,
name: googleUser.name || googleUser.email.split('@')[0],
role: role,
roleLabel: ROLE_LABELS[role],
avatar: googleUser.picture,
provider: 'google',
createdAt: new Date().toISOString()
},
token: `google_token_${Date.now()}_${role}`,
expiresAt: Date.now() + 24 * 60 * 60 * 1000
}
}

View File

@ -50,14 +50,43 @@ html, body {
overflow-x: hidden;
}
/* Hide scrollbars globally but keep functionality */
* {
-ms-overflow-style: none;
scrollbar-width: none;
/* Custom scrollbar styles */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar {
display: none;
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
transition: background-color var(--t) var(--e);
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
}
/* Dark theme scrollbar */
html.dark ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
}
html.dark ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
/* Firefox scrollbar */
* {
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
}
html.dark * {
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
}
/* Subtle global transitions for key properties */

View File

@ -0,0 +1,4 @@
import login from './login.js';
export default {
...login
};

View File

@ -0,0 +1,9 @@
const login = {
LOGIN:{url:'/user/login',method:'POST'},// 登录
LOGOUT:{url:'/user/logout',method:'POST'},// 登出
REGISTER:{url:'/user/register',method:'POST'},// 注册
SEND_EMAIL_CODE:{url:'/user/send-email-code',method:'POST'},// 发送邮箱验证码
OAUTH_GOOGLE:{url:'/user/oauth/google',method:'POST'},// google弹窗授权
OAUTH_GOOGLE_CODE:{url:'/captcha/code',method:'GET'},// 后台验证码
}
export default login;

View File

@ -1,8 +1,8 @@
import axios from 'axios';
import URL from './api/index';
// 创建axios实例
const service = axios.create({
baseURL: import.meta.env.VITE_APP_BASE_API || '/api', // 使用环境变量配置基础URL
baseURL: import.meta.env.VITE_APP_BASE_API, // 使用环境变量配置基础URL
timeout: 300000, // 请求超时时间
// 不设置默认的Content-Type让axios根据数据类型自动设置
});
@ -80,6 +80,7 @@ service.interceptors.response.use(
// 封装请求方法
export const request = {
url:URL,
// GET请求
get(url, params = {}) {
return service({
@ -117,12 +118,23 @@ export const request = {
params
});
},
common(plug,data={}){
const config = {
url:plug.url,
method:plug.method,
params:data
};
if (plug.method.toLowerCase() === 'get') {
config.params = data;
} else {
config.data = data;
}
return service(config);
},
// 上传文件
upload(url, file, onUploadProgress) {
const formData = new FormData();
formData.append('file', file);
return service({
url,
method: 'post',
@ -144,5 +156,4 @@ export const request = {
});
}
};
export default service;

View File

@ -2,7 +2,7 @@
<div class="creative-zone" :style="{ '--grid-size': `${gridSize}px` }">
<!-- 顶部固定头部组件 -->
<div class="header-wrapper">
<HeaderComponent />
<HeaderComponent @openGuideModal="showGuideModal = true" />
</div>
<!-- 导入的侧边栏组件 -->
@ -97,6 +97,14 @@
:url="importUrl"
@close="closeImportModal"
/>
<!-- 引导弹窗 -->
<GuideModal
:show="showGuideModal"
@close="closeGuideModal"
@complete="completeGuide"
/>
</div>
</template>
@ -110,12 +118,14 @@ import ModelCard from '../components/modelCard/index.vue';
import ModelModal from '../components/ModelModal/index.vue';
import CharacterImportModal from '../components/CharacterImportModal/index.vue';
import HeaderComponent from '../components/HeaderComponent/HeaderComponent.vue';
import GuideModal from '../components/GuideModal/index.vue';
//
const showModelModal = ref(false);
const selectedModel = ref(null);
const showImportModal = ref(false);
const importUrl = ref('https://xiaozhi.me/console/agents');
const showGuideModal = ref(false);
//
const cleanupFunctions = ref({});
@ -148,6 +158,16 @@ const closeImportModal = () => {
showImportModal.value = false;
};
//
const closeGuideModal = () => {
showGuideModal.value = false;
};
//
const completeGuide = () => {
showGuideModal.value = false;
};
// ==================== ====================
/**
* 卡片层级管理系统
@ -855,6 +875,9 @@ import { addPassiveEventListener } from '@/utils/passiveEventListeners'
//
onMounted(() => {
//
showGuideModal.value = true;
// 使
const removeWheelListener = addPassiveEventListener(document, 'wheel', preventZoom);
const removeTouchStartListener = addPassiveEventListener(document, 'touchstart', preventPinchZoom);

View File

@ -118,7 +118,6 @@ const goToRegister = () => {
//
onMounted(() => {
authStore.initAuth()
//
if (authStore.isAuthenticated) {

View File

@ -16,16 +16,13 @@
/>
</div>
</div>
<!-- 主登录卡片 -->
<div class="login-container">
<div class="login-card">
<!-- Google 登录按钮 -->
<div class="google-login-section">
<GoogleOAuthButton
@success="handleLoginSuccess"
@error="handleLoginError"
:loading="authStore.loading"
/>
</div>
@ -40,12 +37,10 @@
<!-- 邮箱登录表单 -->
<div class="email-login-section">
<LoginForm
@success="handleLoginSuccess"
@error="handleLoginError"
@login="handleLogin"
:loading="authStore.loading"
/>
</div>
<!-- 角色信息展示 -->
<div class="role-info-section" v-if="false">
<div class="role-info-card">
@ -104,28 +99,27 @@
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { WarningFilled, InfoFilled, QuestionFilled, UserFilled } from '@element-plus/icons-vue'
//
import GoogleOAuthButton from '@/components/auth/GoogleOAuthButton.vue'
import LoginForm from '@/components/auth/LoginForm.vue'
import ThemeToggle from '@/components/ui/ThemeToggle.vue'
import LanguageToggle from '@/components/ui/LanguageToggle.vue'
import LOGIN from './login'
const router = useRouter()
const authStore = useAuthStore()
const { t } = useI18n()
const plugin = reactive(new LOGIN());
const handleLogin = async (data) => {
plugin.login(data)
}
//
const handleLoginSuccess = (userData) => {
console.log('登录成功:', userData)
//
if (authStore.isCreator) {
router.push('/creator')
@ -148,10 +142,7 @@ const handleEmailLoginSuccess = (userData) => {
handleLoginSuccess(userData)
}
//
const handleLoginError = (error) => {
console.error('登录失败:', error)
}
//
const goToForgotPassword = () => {
@ -167,18 +158,6 @@ const goToRegister = () => {
//
onMounted(() => {
authStore.initAuth()
//
if (authStore.isAuthenticated) {
if (authStore.isCreator) {
router.push('/creator')
} else if (authStore.isAdmin) {
router.push('/admin')
} else {
router.push('/dashboard')
}
}
})
</script>

View File

@ -0,0 +1,13 @@
import request from '@/utils/request.js';
import { useRouter } from 'vue-router'
import {ElMessage} from 'element-plus';
import { useAuthStore } from '@/stores/auth.js';
export default class Login {
constructor() {
this.router = useRouter();
this.authStore = useAuthStore();
}
async login(data) {
this.authStore.login(data);
}
}

View File

@ -0,0 +1,899 @@
<template>
<div class="modern-home">
<!-- 欢迎区域 -->
<section class="welcome-section">
<div class="welcome-content">
<div class="welcome-left">
<div class="welcome-logo">
<img src="@/assets/logo.png" alt="Logo" class="welcome-logo-image" />
<div class="logo-glow"></div>
</div>
<h1 class="welcome-title">
<span class="greeting">{{ t('home.welcome.title', { name: userName || t('home.welcome.defaultName') }) }}</span>
</h1>
<p class="welcome-subtitle">
{{ t('home.welcome.greetingMessage') }}
</p>
<div class="welcome-actions">
<el-button type="primary" size="large" class="action-btn primary-btn create-btn-large" @click="navigateToFeature({ path: '/creation-workspace' })">
{{ t('home.welcome.startCreating') }}
</el-button>
</div>
</div>
<div class="welcome-right">
<div class="welcome-visual">
<div class="animated-bg">
<div class="bg-circle circle-1"></div>
<div class="bg-circle circle-2"></div>
<div class="bg-circle circle-3"></div>
<div class="bg-circle circle-4"></div>
<div class="bg-circle circle-5"></div>
</div>
<div class="floating-cards">
<div class="floating-card card-1" @click="navigateToFeature({ path: '/order-management' })">
<el-icon><Tickets /></el-icon>
<span>{{ t('home.floatingCards.orders') }}</span>
</div>
<div class="floating-card card-2" @click="navigateToFeature({ path: '/device-settings' })">
<el-icon><Setting /></el-icon>
<span>{{ t('home.floatingCards.settings') }}</span>
</div>
<div class="floating-card card-3" @click="navigateToFeature({ path: '/project-gallery' })">
<el-icon><Picture /></el-icon>
<span>{{ t('home.floatingCards.gallery') }}</span>
</div>
</div>
<div class="welcome-avatar">
<el-avatar :size="100" :src="userAvatar">
<el-icon size="50"><User /></el-icon>
</el-avatar>
</div>
</div>
</div>
</div>
</section>
<!-- 统计卡片区 -->
<section class="stats-section">
<div class="stats-container">
<div
v-for="(stat, index) in statsData"
:key="stat.id"
class="stat-card"
:style="{ '--delay': index * 0.1 + 's' }"
>
<div class="stat-icon" :style="{ '--icon-color': stat.color }">
<component :is="stat.icon" />
</div>
<div class="stat-content">
<div class="stat-value">
<count-up :end-val="stat.value" :duration="2" />
<span class="stat-unit">{{ stat.unit }}</span>
</div>
<div class="stat-label">{{ stat.label }}</div>
</div>
<div class="stat-trend" :class="stat.trend">
<component :is="stat.trendIcon" />
<span>{{ stat.trendValue }}</span>
</div>
</div>
</div>
</section>
</div>
</template>
<script>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { useThemeStore } from '@/stores/theme'
import CountUp from 'vue-countup-v3'
//
import {
User,
Star,
Clock,
Document,
MagicStick,
ArrowUp,
ArrowDown,
Minus,
VideoPlay,
ChatDotRound,
Tickets,
Setting,
Picture
} from '@element-plus/icons-vue'
export default {
name: 'ModernHome',
components: {
User,
Star,
Clock,
Document,
MagicStick,
ArrowUp,
ArrowDown,
Minus,
VideoPlay,
ChatDotRound,
Tickets,
Setting,
Picture,
CountUp
},
setup() {
const { t } = useI18n()
const router = useRouter()
const authStore = useAuthStore()
const themeStore = useThemeStore()
// - 使
const statsData = computed(() => [
{
id: 'creations',
value: 156,
unit: '',
label: t('home.stats.creations'),
icon: 'MagicStick',
color: '#6B46C1',
trend: 'up',
trendIcon: 'ArrowUp',
trendValue: '12%'
},
{
id: 'credits',
value: 2840,
unit: '',
label: t('home.stats.credits'),
icon: 'Star',
color: '#F59E0B',
trend: 'up',
trendIcon: 'ArrowUp',
trendValue: '5%'
},
{
id: 'hours',
value: 42,
unit: 'h',
label: t('home.stats.hours'),
icon: 'Clock',
color: '#10B981',
trend: 'down',
trendIcon: 'ArrowDown',
trendValue: '8%'
},
{
id: 'projects',
value: 23,
unit: '',
label: t('home.stats.projects'),
icon: 'Document',
color: '#3B82F6',
trend: 'stable',
trendIcon: 'Minus',
trendValue: '0%'
}
])
//
const currentUser = computed(() => authStore.user)
const userName = computed(() => currentUser.value?.name || '')
const userAvatar = computed(() => currentUser.value?.avatar || '')
//
const navigateToFeature = (feature) => {
router.push(feature.path)
}
const formatTime = (timestamp) => {
const now = new Date()
const diff = now - timestamp
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (minutes < 60) {
return t('home.recentActivity.minutesAgo', { count: minutes })
} else if (hours < 24) {
return t('home.recentActivity.hoursAgo', { count: hours })
} else {
return t('home.recentActivity.daysAgo', { count: days })
}
}
onMounted(() => {
//
setTimeout(() => {
// API
}, 500)
})
return {
t,
statsData,
userName,
userAvatar,
navigateToFeature
}
}
}
</script>
<style scoped>
.modern-home {
padding: 24px;
background: var(--bg-color, #f9fafb);
min-height: 100vh;
}
/* 欢迎区域 */
.welcome-section {
position: relative;
padding: 80px 24px;
background: linear-gradient(135deg,
#6B46C1 0%,
#8B5CF6 25%,
#A78BFA 50%);
background-size: 100% 100%;
overflow: hidden;
border-radius: 24px;
box-shadow:
0 20px 60px rgba(107, 70, 193, 0.3),
0 8px 24px rgba(107, 70, 193, 0.2),
0 4px 12px rgba(107, 70, 193, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
backdrop-filter: blur(12px);
transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.welcome-section:hover {
transform: translateY(-4px);
box-shadow:
0 32px 80px rgba(107, 70, 193, 0.4),
0 12px 32px rgba(107, 70, 193, 0.25),
0 6px 16px rgba(107, 70, 193, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.3);
}
.welcome-section::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
radial-gradient(circle at 25% 25%, rgba(255, 255, 255, 0.1) 0%, transparent 50%),
radial-gradient(circle at 75% 75%, rgba(255, 255, 255, 0.05) 0%, transparent 50%),
radial-gradient(circle at 40% 60%, rgba(255, 255, 255, 0.05) 0%, transparent 50%);
}
/* 暗色主题支持 */
.dark .welcome-section {
background: linear-gradient(135deg,
#1a1625 0%,
#2d1b3d 25%,
#3730a3 50%,
#3730a3 75%,
#1e1b4b 100%);
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.6),
0 8px 24px rgba(0, 0, 0, 0.4),
0 4px 12px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.05);
}
.dark .welcome-section:hover {
box-shadow:
0 32px 80px rgba(0, 0, 0, 0.7),
0 12px 32px rgba(0, 0, 0, 0.5),
0 6px 16px rgba(0, 0, 0, 0.4),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.welcome-content {
max-width: 1200px;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 60px;
align-items: center;
position: relative;
z-index: 1;
}
.welcome-left {
max-width: 500px;
}
.welcome-logo {
position: relative;
width: 120px;
height: 120px;
margin-bottom: 32px;
animation: logoFloat 6s ease-in-out infinite;
}
.welcome-logo-image {
width: 100%;
height: 100%;
object-fit: contain;
border-radius:50%;
position: relative;
z-index: 2;
}
.logo-glow {
position: absolute;
top: -20px;
left: -20px;
right: -20px;
bottom: -20px;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
animation: glowPulse 3s ease-in-out infinite;
z-index: 1;
}
.welcome-title {
font-size: 2.5rem;
font-weight: 700;
margin: 0 0 16px 0;
line-height: 1.2;
/* 初始隐藏状态 */
opacity: 0;
transform: translateY(30px);
animation: fadeInUp 0.8s ease-out both;
color: #ffffff;
}
.greeting {
background: linear-gradient(45deg, #ffffff, #F3F4F6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.welcome-subtitle {
font-size: 1.125rem;
opacity: 0.9;
margin: 0 0 32px 0;
line-height: 1.6;
/* 初始隐藏状态 */
opacity: 0;
transform: translateY(30px);
animation: fadeInUp 0.8s ease-out 0.2s both;
color: rgba(255, 255, 255, 0.9);
}
.welcome-actions {
display: flex;
gap: 16px;
flex-wrap: wrap;
/* 初始隐藏状态 */
opacity: 0;
transform: translateY(30px);
animation: fadeInUp 0.8s ease-out 0.4s both;
}
.action-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
border-radius: 12px;
font-weight: 600;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
/* 拆件动画 */
opacity: 0;
transform: translateY(40px);
animation: buttonFloatIn 0.6s ease-out 0.2s forwards;
}
.action-btn::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.5s;
}
.action-btn:hover::before {
left: 100%;
}
.primary-btn {
background: rgba(255, 255, 255, 0.2);
border: 2px solid rgba(255, 255, 255, 0.3);
color: #ffffff;
backdrop-filter: blur(10px);
}
.primary-btn:hover {
background: rgba(255, 255, 255, 0.3);
border-color: rgba(255, 255, 255, 0.5);
transform: translateY(-2px);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.create-btn-large {
font-size: 18px !important;
padding: 16px 32px !important;
min-width: 200px !important;
height: 56px !important;
}
.create-btn-large:hover {
transform: translateY(-3px) scale(1.02) !important;
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.25) !important;
}
.secondary-btn {
background: transparent;
border: 2px solid rgba(255, 255, 255, 0.3);
color: #ffffff;
}
.secondary-btn:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.5);
transform: translateY(-2px);
}
.welcome-right {
position: relative;
}
.welcome-visual {
position: relative;
width: 100%;
height: 400px;
display: flex;
align-items: center;
justify-content: center;
}
.animated-bg {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
}
.bg-circle {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
/* 拆件动画 */
opacity: 0;
transform: translateY(30px) scale(0.8);
animation: circleFloatIn 0.8s ease-out forwards;
}
.circle-1 {
width: 120px;
height: 120px;
top: 10%;
left: 20%;
animation: circleFloatIn 0.8s ease-out 0.1s forwards, circleFloat1 8s ease-in-out infinite 0.8s;
}
.circle-2 {
width: 80px;
height: 80px;
top: 60%;
left: 70%;
animation: circleFloatIn 0.8s ease-out 0.3s forwards, circleFloat2 10s ease-in-out infinite 1.0s;
}
.circle-3 {
width: 60px;
height: 60px;
top: 30%;
left: 10%;
animation: circleFloatIn 0.8s ease-out 0.5s forwards, circleFloat3 12s ease-in-out infinite 1.2s;
}
.circle-4 {
width: 100px;
height: 100px;
top: 70%;
left: 20%;
animation: circleFloatIn 0.8s ease-out 0.7s forwards, circleFloat4 9s ease-in-out infinite 1.4s;
}
.circle-5 {
width: 40px;
height: 40px;
top: 50%;
left: 60%;
animation: circleFloatIn 0.8s ease-out 0.9s forwards, circleFloat5 7s ease-in-out infinite 1.6s;
}
.floating-cards {
position: relative;
z-index: 3;
width: 100%;
height: 100%;
}
.floating-card {
position: absolute;
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 12px;
color: white;
font-size: 14px;
font-weight: 500;
animation: cardFloat 6s ease-in-out infinite;
cursor: pointer;
transition: all 0.3s ease;
/* 初始隐藏状态 */
opacity: 0;
transform: translateY(30px);
}
.floating-card:hover {
background: rgba(255, 255, 255, 0.25);
border-color: rgba(255, 255, 255, 0.4);
transform: translateY(-8px) scale(1.05);
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.2);
}
.floating-card:active {
transform: translateY(-4px) scale(1.02);
transition: all 0.15s ease;
}
.card-1 {
top: 20%;
left: 10%;
animation: cardFloatIn 0.6s ease-out 0.4s forwards, cardFloat 6s ease-in-out infinite 1.0s;
}
.card-2 {
top: 60%;
right: 15%;
animation: cardFloatIn 0.6s ease-out 0.6s forwards, cardFloat 6s ease-in-out infinite 1.2s;
}
.card-3 {
bottom: 30%;
left: 20%;
animation: cardFloatIn 0.6s ease-out 0.8s forwards, cardFloat 6s ease-in-out infinite 1.4s;
}
.welcome-avatar {
position: relative;
z-index: 4;
/* 初始隐藏状态 */
opacity: 0;
transform: translateY(30px);
animation: fadeInUp 0.8s ease-out 1.0s both;
}
/* 响应式设计 */
@media (max-width: 768px) {
.welcome-content {
grid-template-columns: 1fr;
gap: 40px;
text-align: center;
}
.welcome-title {
font-size: 2rem;
}
.welcome-actions {
justify-content: center;
}
.action-btn {
flex: 1;
min-width: 120px;
}
.welcome-visual {
height: 300px;
}
}
/* 统计卡片区 */
.stats-section {
padding: 40px 0px;
width: 100%;
box-sizing: border-box;
}
.stats-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 24px;
}
.stat-card {
background: white;
border-radius: 16px;
padding: 24px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
display: flex;
align-items: center;
gap: 16px;
position: relative;
overflow: hidden;
transition: all 0.3s ease;
animation: fadeInUp 0.6s ease-out both;
animation-delay: var(--delay);
}
.stat-card:hover {
transform: translateY(-5px);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.1);
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: var(--icon-color);
}
.stat-icon {
width: 60px;
height: 60px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(var(--icon-color), 0.1);
color: var(--icon-color);
font-size: 24px;
}
.stat-content {
flex: 1;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: var(--text-primary, #1f2937);
margin-bottom: 4px;
display: flex;
align-items: baseline;
gap: 4px;
}
.stat-unit {
font-size: 1rem;
font-weight: 400;
color: var(--text-secondary, #6b7280);
}
.stat-label {
font-size: 0.875rem;
color: var(--text-secondary, #6b7280);
}
.stat-trend {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
font-size: 0.875rem;
font-weight: 600;
}
.stat-trend.up {
color: #10B981;
}
.stat-trend.down {
color: #EF4444;
}
.stat-trend.stable {
color: #6B7280;
}
/* 动画 */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes circleFloatIn {
from {
opacity: 0;
transform: translateY(30px) scale(0.8);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes buttonFloatIn {
from {
opacity: 0;
transform: translateY(40px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes cardFloatIn {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-15px);
}
}
@keyframes gradientShift {
0% {
background-position: 0% 50%;
}
25% {
background-position: 100% 25%;
}
50% {
background-position: 100% 100%;
}
75% {
background-position: 0% 75%;
}
100% {
background-position: 0% 50%;
}
}
@keyframes backgroundFloat {
0%, 100% {
transform: translate(0, 0) rotate(0deg);
opacity: 0.8;
}
25% {
transform: translate(10px, -15px) rotate(2deg);
opacity: 1;
}
50% {
transform: translate(-5px, 10px) rotate(-1deg);
opacity: 0.9;
}
75% {
transform: translate(15px, 5px) rotate(1deg);
opacity: 0.95;
}
}
@keyframes backgroundPulse {
0%, 100% {
transform: scale(1) rotate(0deg);
opacity: 0.6;
}
33% {
transform: scale(1.05) rotate(1deg);
opacity: 0.8;
}
66% {
transform: scale(0.95) rotate(-1deg);
opacity: 0.7;
}
}
@keyframes logoFloat {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-8px);
}
}
@keyframes glowPulse {
0%, 100% {
transform: scale(1);
opacity: 0.3;
}
50% {
transform: scale(1.1);
opacity: 0.6;
}
}
/* 深色主题 */
.dark .modern-home {
--bg-color: #111827;
--text-primary: #f9fafb;
--text-secondary: #d1d5db;
--border-color: #374151;
}
.dark .stat-card {
background: #1f2937;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.dark .stat-card:hover {
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.3);
}
/* 响应式设计 */
@media (max-width: 768px) {
.welcome-content {
grid-template-columns: 1fr;
gap: 40px;
text-align: center;
}
.welcome-title {
font-size: 2rem;
}
.welcome-actions {
justify-content: center;
}
.action-btn {
flex: 1;
min-width: 120px;
}
.welcome-visual {
height: 300px;
}
.stats-container {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -15,7 +15,7 @@
{{ t('home.welcome.greetingMessage') }}
</p>
<div class="welcome-actions">
<el-button type="primary" size="large" class="action-btn primary-btn create-btn-large" @click="navigateToFeature({ path: '/creation-workspace' })">
<el-button type="primary" size="large" class="action-btn primary-btn create-btn-large" @click="navigateToFeature({ path: '/create-project' })">
{{ t('home.welcome.startCreating') }}
</el-button>
</div>
@ -34,11 +34,11 @@
<el-icon><Tickets /></el-icon>
<span>{{ t('home.floatingCards.orders') }}</span>
</div>
<div class="floating-card card-2" @click="navigateToFeature({ path: '/device-settings' })">
<el-icon><Setting /></el-icon>
<div class="floating-card card-2" @click="navigateToFeature({ path: '/agent-management' })">
<el-icon><Cpu /></el-icon>
<span>{{ t('home.floatingCards.settings') }}</span>
</div>
<div class="floating-card card-3" @click="navigateToFeature({ path: '/project-gallery' })">
<div class="floating-card card-3" @click="navigateToFeature({ path: '/creation-workspace' })">
<el-icon><Picture /></el-icon>
<span>{{ t('home.floatingCards.gallery') }}</span>
</div>
@ -103,10 +103,11 @@ import {
ArrowDown,
Minus,
VideoPlay,
ChatDotRound,
ChatDotRound ,
Tickets,
Setting,
Picture
Picture,
Cpu
} from '@element-plus/icons-vue'
export default {
@ -120,6 +121,7 @@ export default {
ArrowUp,
ArrowDown,
Minus,
Cpu,
VideoPlay,
ChatDotRound,
Tickets,

View File

@ -105,7 +105,6 @@ const goToLogin = () => {
//
onMounted(() => {
authStore.initAuth()
//
if (authStore.isAuthenticated) {

View File

@ -0,0 +1,223 @@
# Design: 拆件流程管理页面架构设计
## 架构概述
拆件流程管理页面采用基于状态管理的步骤式设计使用Element Plus Timeline组件作为核心导航结构确保用户可以流畅地在四个步骤间切换同时保持数据的连续性和一致性。
## 技术架构
### 组件结构
```
DisassemblyWorkflow.vue (主组件)
├── StepNavigation (步骤导航)
│ └── ElementPlus Timeline
├── StepContent (步骤内容容器)
│ ├── PreviewStep (第一步)
│ ├── DisassemblyStep (第二步)
│ ├── ModelStep (第三步)
│ └── ShippingStep (第四步)
└── StepActions (步骤操作按钮)
```
### 状态管理设计
使用Vue 3 Composition API的reactive进行状态管理
```javascript
const workflowState = reactive({
currentStep: 1,
contentData: {
previewImage: '', // 预览图URL
modelUrl: '', // 3D模型URL
disassemblyImages: [], // 拆件后图片列表
generatedModel: '', // 生成的模型URL
shippingInfo: {} // 发货信息
},
stepStatus: {
1: 'completed', // preview
2: 'current', // disassembly
3: 'pending', // model
4: 'pending' // shipping
}
})
```
## 设计模式
### 1. 步骤覆盖逻辑
为了实现在已有下一步状态时,上一步操作可以覆盖下一步内容,设计了以下逻辑:
```javascript
// 步骤覆盖检查
const checkStepOverride = (targetStep) => {
const currentMaxStep = Math.max(...Object.keys(workflowState.stepStatus)
.filter(step => workflowState.stepStatus[step] !== 'pending'))
return targetStep <= currentMaxStep
}
// 覆盖下游步骤
const overrideDownstreamSteps = (fromStep) => {
Object.keys(workflowState.stepStatus).forEach(step => {
const stepNum = parseInt(step)
if (stepNum > fromStep) {
workflowState.stepStatus[step] = 'pending'
}
})
}
```
### 2. 响应式布局适配
采用"桌面优先"设计策略:
```css
/* 桌面端布局 (>=1024px) */
.disassembly-layout {
display: grid;
grid-template-columns: 1fr 300px;
gap: 24px;
}
/* 平板端布局 (768px-1024px) */
@media (max-width: 1024px) {
.disassembly-layout {
grid-template-columns: 1fr;
gap: 16px;
}
}
/* 移动端布局 (<768px) */
@media (max-width: 768px) {
.step-content {
padding: 16px;
}
.action-buttons {
flex-direction: column;
gap: 8px;
}
}
```
### 3. 国际化设计
所有用户可见文本通过Vue I18n管理
```javascript
// 国际化键值结构
const i18nKeys = {
disassembly: {
title: '拆件流程',
steps: {
preview: '内容预览',
disassembly: '拆件结果',
model: '模型生成',
shipping: '物流发货'
},
actions: {
startDisassembly: '开始拆件',
regenerate: '重新生成',
generateModel: '生成模型',
export: '导出',
ship: '发货',
submitShipping: '提交发货信息'
}
}
}
```
## 关键交互设计
### 1. 步骤导航交互
- Timeline组件显示四个步骤的进度
- 当前步骤高亮显示,已完成步骤显示勾选标记
- 点击已完成的步骤可以跳转到该步骤
- 点击未来步骤需要验证前置步骤是否完成
### 2. 预览交互设计
- **图片预览**:点击缩略图弹出全屏预览对话框
- **3D模型预览**使用ModelViewer组件支持旋转、缩放等操作
- **模型放大**:第三步的模型支持点击放大到全屏预览
### 3. 步骤覆盖交互
- 在第二步点击"重新生成"时,如果已有第三步内容,会弹出确认对话框
- 用户确认后会清空第三步和第四步的所有内容
- 状态重新计算,确保步骤间的逻辑一致性
### 4. 表单验证设计
- 第四步的发货表单使用Element Plus Form组件
- 必填字段:收件人姓名、联系电话、收货地址
- 可选字段:备注、特殊要求
- 实时验证和提交前验证相结合
## 性能优化策略
### 1. 组件懒加载
```javascript
// 路由级别的懒加载
const DisassemblyWorkflow = defineAsyncComponent(() =>
import('@/views/disassembly/DisassemblyWorkflow.vue')
)
```
### 2. 图片懒加载
```javascript
// 使用vue3-lazyload进行图片懒加载
const lazyConfig = {
loading: '/images/loading.gif',
error: '/images/error.png'
}
```
### 3. 状态缓存
- 步骤数据在组件销毁前持久化到sessionStorage
- 页面刷新后可以恢复用户的进度状态
- 关键数据:当前步骤、已生成内容、用户输入的表单数据
## 用户体验设计
### 1. 加载状态
- 每个步骤切换时显示加载动画
- 拆件和模型生成过程显示进度条
- 骨架屏占位,提升感知性能
### 2. 错误处理
- 网络错误友好提示
- 文件加载失败的重试机制
- 表单验证错误的即时反馈
### 3. 反馈设计
- 成功操作的Toast提示
- 步骤完成的状态反馈
- 关键操作的确认对话框
## 可访问性考虑
### 1. 键盘导航
- 所有可交互元素支持Tab键导航
- 步骤导航支持左右箭头键切换
- 对话框支持ESC键关闭
### 2. 屏幕阅读器支持
- 为Timeline组件添加适当的ARIA标签
- 步骤状态变化时的ARIA Live通知
- 图片和模型预览的替代文本
## 扩展性设计
### 1. 步骤配置化
```javascript
const stepConfigs = [
{
id: 1,
component: 'PreviewStep',
title: t('disassembly.steps.preview'),
required: true
},
// ...更多步骤配置
]
```
### 2. 插件化操作
- 导出功能设计为可配置的插件接口
- 支持添加新的导出格式
- 模型预览功能可扩展为支持多种3D格式
这种设计确保了拆件流程页面的可维护性、可扩展性和良好的用户体验,同时遵循了项目的技术规范和设计标准。

View File

@ -0,0 +1,79 @@
# Proposal: 创建拆件流程管理页面
## Change ID
create-disassembly-workflow
## 需求概述
为管理后台系统开发完整的拆件流程管理页面,实现从内容审核到拆件完成的完整工作流,包含四个步骤的流程导航和交互功能。
## 目标
- 构建基于Timeline时间线设计的四步骤拆件流程页面
- 实现预览图和3D模型的横排展示与交互功能
- 开发拆件结果展示、模型生成和物流发货的完整流程
- 确保响应式设计和国际化支持
## 范围
### 包含
- 拆件流程页面主结构基于Element Plus Timeline组件
- 第一步:预览图和模型横排展示,点击预览功能,拆件按钮
- 第二步:已拆件图片展示,重新生成和生成模型功能
- 第三步:已生成模型展示,放大预览,重新生成、导出、发货功能
- 第四步:发货物流信息填写表单
- 步骤间状态管理和数据覆盖逻辑
- 中英文国际化支持
### 不包含
- 实际的拆件逻辑实现(用户后续自行开发)
- 真实的3D模型渲染使用demo文件夹示例
- 后端API集成
- 复杂的业务逻辑处理
## 设计要求
- 遵循项目设计规范:深紫色主题 (#6B46C1)
- 使用Element Plus Timeline组件作为主流程设计
- 支持中英文国际化,所有用户可见文本必须支持双语
- 响应式设计,适配桌面端和平板端
- 采用8px网格系统和平滑过渡动画 (200ms)
- 卡片式布局设计,统一的视觉风格
## 技术栈
- Vue 3 (Composition API)
- Element Plus Timeline、Dialog、Form等组件
- Vue Router 4 (拆件页面路由)
- Vue I18n 9.x (国际化)
- CSS 变量 + Scoped CSS
- 现有ModelViewer组件复用
## 功能设计
### 步骤流程
1. **内容预览步骤**
- 左侧预览图(可点击放大预览)
- 右侧3D模型展示使用demo/model.glb
- 底部拆件按钮
2. **拆件结果步骤**
- 展示拆件后的图片内容
- 右侧操作区:重新生成、生成模型按钮
3. **模型生成步骤**
- 展示已生成的模型
- 点击模型可放大预览使用ModelViewer组件
- 右侧操作区:重新生成、导出、发货按钮
4. **物流发货步骤**
- 发货信息填写表单
- 包含收件人、物流公司、运单号等信息
### 交互特性
- 步骤间导航可前进后退
- 在已有下一步状态时,上一步的相关操作可覆盖下一步内容
- 支持移动端触摸交互
- 加载状态和错误处理
## 预期交付
- 完整的拆件流程页面组件 (DisassemblyWorkflow.vue)
- 四步骤完整的用户界面和交互逻辑
- 中英文国际化文案配置
- 路由配置和页面集成
- 响应式布局适配
- 项目文档和使用说明

View File

@ -0,0 +1,284 @@
# Spec: 第二步 - 拆件结果展示功能
## ADDED Requirements
### 拆件结果展示
- **Requirement**: 实现已拆件图片内容的展示布局,支持多张图片的展示
- **Scenario**: 用户可以看到拆件后的图片结果,图片以网格或列表形式展示,支持点击预览
### 重新生成功能
- **Requirement**: 添加重新生成按钮,实现重新执行拆件逻辑并更新图片内容
- **Scenario**: 用户对当前拆件结果不满意时,点击重新生成按钮,系统重新执行拆件算法并生成新的图片结果
### 生成模型功能
- **Requirement**: 添加生成模型按钮,点击后进入第三步模型生成环节
- **Scenario**: 用户满意拆件结果后,点击生成模型按钮,系统进入模型生成流程并进入第三步
### 步骤覆盖逻辑
- **Requirement**: 实现上游操作覆盖下游内容的逻辑处理
- **Scenario**: 用户在第二步点击重新生成时,如果已有第三步或第四步内容,系统会弹出确认对话框并清空下游步骤
## 技术实现细节
### 布局结构
```html
<div class="disassembly-step-content">
<div class="disassembly-results">
<div class="results-header">
<h3>{{ t('disassembly.steps.disassembly') }}</h3>
<el-tag type="success">{{ t('disassembly.status.completed') }}</el-tag>
</div>
<div class="results-grid">
<div
v-for="(image, index) in disassemblyImages"
:key="index"
class="result-image-container"
@click="openResultImagePreview(image)"
>
<img :src="image.url" :alt="`拆件结果 ${index + 1}`" />
<div class="image-overlay">
<el-icon><ZoomIn /></el-icon>
</div>
</div>
</div>
</div>
<div class="step-actions">
<el-button
type="warning"
@click="regenerateDisassembly"
:loading="regenerateLoading"
>
{{ t('disassembly.actions.regenerate') }}
</el-button>
<el-button
type="primary"
@click="generateModel"
:loading="modelGenerationLoading"
>
{{ t('disassembly.actions.generateModel') }}
</el-button>
</div>
</div>
```
### 重新生成逻辑
```javascript
const regenerateDisassembly = async () => {
// 检查是否有下游步骤需要覆盖
if (hasDownstreamContent()) {
const confirmed = await ElMessageBox.confirm(
t('disassembly.confirm.regenerateOverride'),
t('common.confirm'),
{
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning'
}
)
if (!confirmed) return
}
try {
regenerateLoading.value = true
// 重新执行拆件逻辑(用户后续实现)
// const newResults = await callDisassemblyAPI(contentId, true)
// 模拟重新生成过程
await new Promise(resolve => setTimeout(resolve, 3000))
// 更新拆件结果
disassemblyImages.value = generateMockDisassemblyResults()
// 清空下游步骤
clearDownstreamSteps(2)
ElMessage.success(t('disassembly.success.regenerateCompleted'))
} catch (error) {
ElMessage.error(t('disassembly.errors.regenerateFailed'))
} finally {
regenerateLoading.value = false
}
}
const generateModel = async () => {
try {
modelGenerationLoading.value = true
// 生成模型(用户后续实现)
// const modelData = await generateModelAPI(contentId)
// 模拟模型生成过程
await new Promise(resolve => setTimeout(resolve, 2000))
// 更新步骤状态
updateStepStatus(2, 'completed')
updateStepStatus(3, 'current')
workflowState.currentStep = 3
workflowState.contentData.generatedModel = '/src/assets/demo/model.glb'
ElMessage.success(t('disassembly.success.modelGenerationStarted'))
} catch (error) {
ElMessage.error(t('disassembly.errors.modelGenerationFailed'))
} finally {
modelGenerationLoading.value = false
}
}
```
### 下游步骤覆盖逻辑
```javascript
const hasDownstreamContent = () => {
return workflowState.currentStep > 2
}
const clearDownstreamSteps = (fromStep) => {
Object.keys(workflowState.stepStatus).forEach(step => {
const stepNum = parseInt(step)
if (stepNum > fromStep) {
workflowState.stepStatus[step] = 'pending'
if (stepNum === 3) {
workflowState.contentData.generatedModel = null
}
if (stepNum === 4) {
workflowState.contentData.shippingInfo = {}
}
}
})
// 调整当前步骤
workflowState.currentStep = fromStep
}
```
### 样式实现
```css
.disassembly-results {
background: #ffffff;
border-radius: 12px;
padding: 24px;
margin-bottom: 32px;
box-shadow: 0 2px 8px rgba(107, 70, 193, 0.1);
}
.results-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.results-header h3 {
margin: 0;
color: #1f2937;
font-size: 18px;
font-weight: 600;
}
.results-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.result-image-container {
position: relative;
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: transform 200ms ease;
}
.result-image-container:hover {
transform: scale(1.02);
}
.result-image-container img {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(107, 70, 193, 0.8);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 200ms ease;
color: white;
font-size: 24px;
}
.result-image-container:hover .image-overlay {
opacity: 1;
}
.step-actions {
display: flex;
gap: 16px;
justify-content: flex-end;
padding-top: 24px;
border-top: 1px solid #e5e7eb;
}
/* 响应式设计 */
@media (max-width: 768px) {
.disassembly-results {
padding: 16px;
}
.results-grid {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.step-actions {
flex-direction: column;
}
}
```
## 数据模拟
```javascript
// 模拟拆件结果数据
const generateMockDisassemblyResults = () => {
return [
{
url: '/src/assets/demo/suoluetu.png',
title: '拆件结果 1',
description: '主体部分'
},
{
url: '/src/assets/demo/suoluetu.png',
title: '拆件结果 2',
description: '细节部分'
},
{
url: '/src/assets/demo/suoluetu.png',
title: '拆件结果 3',
description: '组件部分'
}
]
}
```
## 验证标准
- 拆件结果图片能够正确加载和显示
- 点击图片能够弹出预览对话框
- 重新生成按钮能够触发重新拆件流程
- 生成模型按钮能够正常进入第三步
- 步骤覆盖逻辑在各种情况下正常工作
- 响应式布局在各种设备上表现良好

View File

@ -0,0 +1,360 @@
# Spec: 第三步 - 模型生成和展示功能
## ADDED Requirements
### 生成模型展示
- **Requirement**: 实现已生成模型的展示区域,支持点击放大预览功能
- **Scenario**: 用户可以看到通过第二步生成的3D模型模型以缩略图形式展示点击后可以使用ModelViewer组件进行全屏预览
### 重新生成功能
- **Requirement**: 添加重新生成模型按钮,支持更新模型内容
- **Scenario**: 用户对当前生成的模型不满意时,点击重新生成按钮,系统重新执行模型生成算法
### 导出功能
- **Requirement**: 实现导出功能,提供不同格式的模型文件导出选项
- **Scenario**: 用户可以选择不同的文件格式如GLB、OBJ、FBX等进行模型导出下载到本地
### 发货功能
- **Requirement**: 添加发货按钮,点击后进入第四步物流发货环节
- **Scenario**: 用户满意模型结果后,点击发货按钮,系统进入物流信息填写流程并进入第四步
### 模型预览集成
- **Requirement**: 集成现有的ModelViewer组件进行全屏模型预览
- **Scenario**: 点击模型缩略图后弹出全屏预览对话框,用户可以旋转、缩放、查看模型详情
## 技术实现细节
### 布局结构
```html
<div class="model-step-content">
<div class="model-display">
<div class="model-header">
<h3>{{ t('disassembly.steps.model') }}</h3>
<el-tag type="success">{{ t('disassembly.status.completed') }}</el-tag>
</div>
<div class="model-preview-container">
<div
class="model-thumbnail"
@click="openModelPreview"
>
<ModelViewer
:model-url="generatedModelUrl"
:show-controls="false"
:loading-text="t('modelViewer.loadingModel')"
style="height: 300px; width: 100%;"
/>
<div class="preview-overlay">
<el-icon><View /></el-icon>
<span>{{ t('disassembly.actions.previewModel') }}</span>
</div>
</div>
</div>
</div>
<div class="step-actions">
<el-dropdown @command="handleExport">
<el-button type="info">
{{ t('disassembly.actions.export') }}
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="glb">GLB格式</el-dropdown-item>
<el-dropdown-item command="obj">OBJ格式</el-dropdown-item>
<el-dropdown-item command="fbx">FBX格式</el-dropdown-item>
<el-dropdown-item command="gltf">GLTF格式</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button
type="warning"
@click="regenerateModel"
:loading="regenerateLoading"
>
{{ t('disassembly.actions.regenerate') }}
</el-button>
<el-button
type="primary"
@click="proceedToShipping"
:loading="shippingLoading"
>
{{ t('disassembly.actions.ship') }}
</el-button>
</div>
</div>
```
### 模型预览对话框
```javascript
const modelPreviewVisible = ref(false)
const openModelPreview = () => {
modelPreviewVisible.value = true
}
```
```html
<!-- 模型预览对话框 -->
<el-dialog
:title="t('disassembly.modelPreview')"
v-model="modelPreviewVisible"
width="90%"
top="5vh"
:z-index="3000"
:append-to-body="true"
class="model-preview-dialog"
>
<div class="model-preview-content">
<ModelViewer
ref="modelViewerRef"
:model-url="generatedModelUrl"
:show-controls="true"
:loading-text="t('modelViewer.loadingModel')"
style="height: 70vh; width: 100%;"
/>
</div>
</el-dialog>
```
### 重新生成逻辑
```javascript
const regenerateModel = async () => {
// 检查是否有第四步需要覆盖
if (workflowState.currentStep >= 4) {
const confirmed = await ElMessageBox.confirm(
t('disassembly.confirm.regenerateModelOverride'),
t('common.confirm'),
{
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning'
}
)
if (!confirmed) return
}
try {
regenerateLoading.value = true
// 重新生成模型(用户后续实现)
// const newModel = await regenerateModelAPI(contentId)
// 模拟重新生成过程
await new Promise(resolve => setTimeout(resolve, 4000))
// 更新模型数据使用不同的demo模型或随机生成
generatedModelUrl.value = generateMockModel()
// 清空第四步
if (workflowState.currentStep >= 4) {
workflowState.contentData.shippingInfo = {}
workflowState.stepStatus[4] = 'pending'
workflowState.currentStep = 3
}
ElMessage.success(t('disassembly.success.modelRegenerateCompleted'))
} catch (error) {
ElMessage.error(t('disassembly.errors.modelRegenerateFailed'))
} finally {
regenerateLoading.value = false
}
}
```
### 导出功能实现
```javascript
const handleExport = async (format) => {
try {
ElMessage.info(t('disassembly.info.exporting'))
// 调用导出API用户后续实现
// await exportModelAPI(contentId, format)
// 模拟导出过程
await new Promise(resolve => setTimeout(resolve, 2000))
// 创建下载链接
const downloadUrl = generateDownloadUrl(format)
const link = document.createElement('a')
link.href = downloadUrl
link.download = `model-${contentId}.${format}`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
ElMessage.success(t('disassembly.success.exportCompleted'))
} catch (error) {
ElMessage.error(t('disassembly.errors.exportFailed'))
}
}
const generateDownloadUrl = (format) => {
// 模拟不同格式的下载链接
const mockUrls = {
glb: '/src/assets/demo/model.glb',
obj: '/src/assets/demo/model.obj', // 假设存在
fbx: '/src/assets/demo/model.fbx', // 假设存在
gltf: '/src/assets/demo/model.gltf' // 假设存在
}
return mockUrls[format] || mockUrls.glb
}
```
### 发货流程
```javascript
const proceedToShipping = async () => {
try {
shippingLoading.value = true
// 更新步骤状态
updateStepStatus(3, 'completed')
updateStepStatus(4, 'current')
workflowState.currentStep = 4
ElMessage.success(t('disassembly.success.proceedToShipping'))
} catch (error) {
ElMessage.error(t('disassembly.errors.proceedToShippingFailed'))
} finally {
shippingLoading.value = false
}
}
```
### 样式实现
```css
.model-display {
background: #ffffff;
border-radius: 12px;
padding: 24px;
margin-bottom: 32px;
box-shadow: 0 2px 8px rgba(107, 70, 193, 0.1);
}
.model-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.model-header h3 {
margin: 0;
color: #1f2937;
font-size: 18px;
font-weight: 600;
}
.model-preview-container {
display: flex;
justify-content: center;
align-items: center;
background: #f8fafc;
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
}
.model-thumbnail {
position: relative;
cursor: pointer;
border-radius: 8px;
overflow: hidden;
transition: transform 200ms ease;
}
.model-thumbnail:hover {
transform: scale(1.02);
}
.preview-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(107, 70, 193, 0.8);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 200ms ease;
color: white;
}
.preview-overlay .el-icon {
font-size: 32px;
margin-bottom: 8px;
}
.model-thumbnail:hover .preview-overlay {
opacity: 1;
}
.step-actions {
display: flex;
gap: 16px;
justify-content: flex-end;
padding-top: 24px;
border-top: 1px solid #e5e7eb;
}
.step-actions .el-dropdown {
margin-right: auto;
}
/* 响应式设计 */
@media (max-width: 768px) {
.model-display {
padding: 16px;
}
.model-preview-container {
padding: 12px;
}
.step-actions {
flex-direction: column;
gap: 12px;
}
.step-actions .el-dropdown {
margin-right: 0;
width: 100%;
}
.step-actions .el-button {
width: 100%;
}
}
```
## 数据模拟
```javascript
// 模拟不同模型数据
const generateMockModel = () => {
const models = [
'/src/assets/demo/model.glb',
'/src/assets/demo/model2.glb', // 假设存在
'/src/assets/demo/model3.glb' // 假设存在
]
return models[Math.floor(Math.random() * models.length)]
}
```
## 验证标准
- 生成模型能够正确加载和显示
- 点击模型能够弹出全屏预览对话框
- ModelViewer组件在预览对话框中正常工作
- 重新生成按钮能够触发模型重新生成流程
- 导出功能能够正常下载不同格式的文件
- 发货按钮能够正常进入第四步
- 响应式布局在各种设备上表现良好

View File

@ -0,0 +1,178 @@
# Spec: 第一步 - 内容预览功能
## ADDED Requirements
### 预览图展示
- **Requirement**: 实现左侧预览图的横排展示布局,支持点击放大功能
- **Scenario**: 用户可以看到内容审核中选定项目的预览图,点击缩略图弹出全屏预览对话框,支持缩放操作
### 3D模型展示
- **Requirement**: 实现右侧3D模型的横排展示集成现有的ModelViewer组件
- **Scenario**: 用户可以看到对应的3D模型预览模型支持旋转、缩放等交互操作使用demo/model.glb作为示例
### 拆件按钮功能
- **Requirement**: 添加拆件按钮,点击后进入第二步拆件结果展示
- **Scenario**: 用户在预览完图片和模型后,点击拆件按钮触发拆件流程,系统显示加载状态并进入下一步
### 响应式布局
- **Requirement**: 实现桌面端横排布局,平板端和移动端垂直堆叠布局
- **Scenario**: 在不同屏幕尺寸下,预览图和模型能够合理适配屏幕空间,保证可读性和交互性
## 技术实现细节
### 布局结构
```html
<div class="preview-step-content">
<div class="preview-layout">
<div class="preview-images">
<img
:src="previewImageUrl"
class="preview-thumbnail"
@click="openImagePreview"
alt="预览图"
/>
</div>
<div class="model-viewer-container">
<ModelViewer
:model-url="modelUrl"
:show-controls="true"
style="height: 400px; width: 100%;"
/>
</div>
</div>
<div class="step-actions">
<el-button
type="primary"
size="large"
@click="startDisassembly"
:loading="disassemblyLoading"
>
{{ t('disassembly.actions.startDisassembly') }}
</el-button>
</div>
</div>
```
### 图片预览对话框
```javascript
const openImagePreview = () => {
previewImageVisible.value = true
}
// 使用现有的预览对话框组件
// 通过currentImage和imageScale进行控制
```
### 样式实现
```css
.preview-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
margin-bottom: 32px;
}
.preview-images {
display: flex;
justify-content: center;
align-items: center;
background: #f8fafc;
border-radius: 8px;
padding: 16px;
}
.preview-thumbnail {
max-width: 100%;
max-height: 400px;
border-radius: 8px;
cursor: pointer;
transition: transform 200ms ease;
}
.preview-thumbnail:hover {
transform: scale(1.02);
}
/* 平板端适配 */
@media (max-width: 1024px) {
.preview-layout {
grid-template-columns: 1fr;
gap: 16px;
}
}
/* 移动端适配 */
@media (max-width: 768px) {
.preview-layout {
margin-bottom: 24px;
}
.preview-images,
.model-viewer-container {
padding: 12px;
}
}
```
## 数据流设计
### 初始数据加载
```javascript
const loadPreviewData = async () => {
try {
// 从路由参数获取内容ID
const contentId = route.params.id
// 加载预览图和模型数据
previewImageUrl.value = `/api/content/${contentId}/preview`
modelUrl.value = `/api/content/${contentId}/model`
// 如果没有对应内容使用demo资源
if (!previewImageUrl.value) {
previewImageUrl.value = '/src/assets/demo/suoluetu.png'
}
if (!modelUrl.value) {
modelUrl.value = '/src/assets/demo/model.glb'
}
} catch (error) {
ElMessage.error(t('common.loadFailed'))
}
}
```
### 拆件流程启动
```javascript
const startDisassembly = async () => {
try {
disassemblyLoading.value = true
// 调用拆件API用户后续实现
// await callDisassemblyAPI(contentId)
// 模拟加载时间
await new Promise(resolve => setTimeout(resolve, 2000))
// 更新步骤状态
updateStepStatus(1, 'completed')
updateStepStatus(2, 'current')
workflowState.currentStep = 2
// 清空第二步可能存在的数据
overrideDownstreamSteps(2)
} catch (error) {
ElMessage.error(t('disassembly.errors.disassemblyFailed'))
} finally {
disassemblyLoading.value = false
}
}
```
## 验证标准
- 预览图能够正确加载和显示
- 点击预览图能够弹出放大对话框
- 3D模型能够正确渲染和交互
- 拆件按钮能够正常触发流程
- 响应式布局在各种设备上正常工作
- 加载状态和错误处理完善

View File

@ -0,0 +1,377 @@
# Spec: 第四步 - 物流发货信息功能
## ADDED Requirements
### 发货信息表单
- **Requirement**: 实现完整的发货信息填写表单,包含收件人、物流公司、运单号等必要信息
- **Scenario**: 用户进入第四步后可以看到发货信息表单,需要填写完整的收货地址、联系方式和物流信息才能提交
### 表单验证
- **Requirement**: 实现表单字段的完整验证机制,包括必填字段验证、格式验证和业务规则验证
- **Scenario**: 用户填写表单时能够实时看到验证反馈,提交前进行完整检查,确保数据完整性和正确性
### 发货完成流程
- **Requirement**: 实现发货信息提交后的完整流程,包括状态更新、确认反馈和流程完成
- **Scenario**: 用户确认发货信息后,系统处理发货请求,更新订单状态,并提供发货完成的确认反馈
### 返回上一步功能
- **Requirement**: 添加返回上一步的导航功能,允许用户修改前面步骤的信息
- **Scenario**: 用户在发货页面可以点击返回按钮修改模型或拆件结果,已填写的发货信息会被保留
### 表单数据持久化
- **Requirement**: 实现表单数据的本地持久化,防止页面刷新导致数据丢失
- **Scenario**: 用户在填写发货信息过程中刷新页面,已填写的数据能够自动恢复
## 技术实现细节
### 布局结构
```html
<div class="shipping-step-content">
<div class="shipping-form-container">
<div class="form-header">
<h3>{{ t('disassembly.steps.shipping') }}</h3>
<el-tag type="info">{{ t('disassembly.status.current') }}</el-tag>
</div>
<el-form
ref="shippingFormRef"
:model="shippingForm"
:rules="shippingRules"
label-width="120px"
class="shipping-form"
>
<!-- 收件人信息 -->
<el-form-item :label="t('disassembly.shipping.recipient')" prop="recipient">
<el-input
v-model="shippingForm.recipient"
:placeholder="t('disassembly.shipping.recipientPlaceholder')"
/>
</el-form-item>
<el-form-item :label="t('disassembly.shipping.phone')" prop="phone">
<el-input
v-model="shippingForm.phone"
:placeholder="t('disassembly.shipping.phonePlaceholder')"
/>
</el-form-item>
<!-- 收货地址 -->
<el-form-item :label="t('disassembly.shipping.address')" prop="address">
<el-input
v-model="shippingForm.address"
type="textarea"
:rows="3"
:placeholder="t('disassembly.shipping.addressPlaceholder')"
/>
</el-form-item>
<!-- 物流公司 -->
<el-form-item :label="t('disassembly.shipping.logistics')" prop="logistics">
<el-select
v-model="shippingForm.logistics"
:placeholder="t('disassembly.shipping.logisticsPlaceholder')"
style="width: 100%;"
>
<el-option label="顺丰速运" value="sf" />
<el-option label="圆通快递" value="yt" />
<el-option label="中通快递" value="zt" />
<el-option label="韵达快递" value="yd" />
<el-option label="申通快递" value="st" />
<el-option label="其他" value="other" />
</el-select>
</el-form-item>
<!-- 运单号 -->
<el-form-item :label="t('disassembly.shipping.trackingNumber')" prop="trackingNumber">
<el-input
v-model="shippingForm.trackingNumber"
:placeholder="t('disassembly.shipping.trackingNumberPlaceholder')"
/>
</el-form-item>
<!-- 备注信息 -->
<el-form-item :label="t('disassembly.shipping.remarks')">
<el-input
v-model="shippingForm.remarks"
type="textarea"
:rows="2"
:placeholder="t('disassembly.shipping.remarksPlaceholder')"
/>
</el-form-item>
</el-form>
</div>
<div class="step-actions">
<el-button @click="goToPreviousStep">
{{ t('disassembly.actions.backToModel') }}
</el-button>
<el-button
type="primary"
@click="submitShipping"
:loading="submitLoading"
:disabled="!shippingFormValid"
>
{{ t('disassembly.actions.submitShipping') }}
</el-button>
</div>
</div>
```
### 表单数据结构和验证
```javascript
const shippingForm = reactive({
recipient: '',
phone: '',
address: '',
logistics: '',
trackingNumber: '',
remarks: ''
})
const shippingRules = {
recipient: [
{ required: true, message: t('disassembly.validation.recipientRequired'), trigger: 'blur' },
{ min: 2, max: 20, message: t('disassembly.validation.recipientLength'), trigger: 'blur' }
],
phone: [
{ required: true, message: t('disassembly.validation.phoneRequired'), trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: t('disassembly.validation.phoneFormat'), trigger: 'blur' }
],
address: [
{ required: true, message: t('disassembly.validation.addressRequired'), trigger: 'blur' },
{ min: 10, max: 200, message: t('disassembly.validation.addressLength'), trigger: 'blur' }
],
logistics: [
{ required: true, message: t('disassembly.validation.logisticsRequired'), trigger: 'change' }
],
trackingNumber: [
{ required: true, message: t('disassembly.validation.trackingNumberRequired'), trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9]{8,20}$/, message: t('disassembly.validation.trackingNumberFormat'), trigger: 'blur' }
]
}
```
### 表单提交逻辑
```javascript
const submitShipping = async () => {
try {
// 表单验证
const valid = await shippingFormRef.value.validate()
if (!valid) return
submitLoading.value = true
// 调用发货API用户后续实现
// const result = await submitShippingAPI(contentId, shippingForm)
// 模拟提交过程
await new Promise(resolve => setTimeout(resolve, 2000))
// 更新步骤状态
updateStepStatus(4, 'completed')
workflowState.contentData.shippingInfo = { ...shippingForm }
// 显示成功消息
ElMessage.success(t('disassembly.success.shippingSubmitted'))
// 跳转到完成页面或返回列表
setTimeout(() => {
router.push('/admin/content-review')
}, 2000)
} catch (error) {
ElMessage.error(t('disassembly.errors.shippingSubmitFailed'))
} finally {
submitLoading.value = false
}
}
const goToPreviousStep = () => {
workflowState.currentStep = 3
workflowState.stepStatus[4] = 'pending'
}
```
### 数据持久化
```javascript
// 保存表单数据到sessionStorage
const saveFormData = () => {
sessionStorage.setItem('shippingFormData', JSON.stringify(shippingForm))
}
// 从sessionStorage恢复表单数据
const restoreFormData = () => {
const savedData = sessionStorage.getItem('shippingFormData')
if (savedData) {
try {
const parsed = JSON.parse(savedData)
Object.assign(shippingForm, parsed)
} catch (error) {
console.error('Failed to restore form data:', error)
}
}
}
// 监听表单变化自动保存
watch(shippingForm, saveFormData, { deep: true })
// 组件挂载时恢复数据
onMounted(() => {
restoreFormData()
})
```
### 计算属性
```javascript
const shippingFormValid = computed(() => {
return shippingForm.recipient &&
shippingForm.phone &&
shippingForm.address &&
shippingForm.logistics &&
shippingForm.trackingNumber
})
```
### 样式实现
```css
.shipping-form-container {
background: #ffffff;
border-radius: 12px;
padding: 24px;
margin-bottom: 32px;
box-shadow: 0 2px 8px rgba(107, 70, 193, 0.1);
}
.form-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32px;
padding-bottom: 16px;
border-bottom: 1px solid #e5e7eb;
}
.form-header h3 {
margin: 0;
color: #1f2937;
font-size: 18px;
font-weight: 600;
}
.shipping-form {
max-width: 600px;
}
.shipping-form .el-form-item {
margin-bottom: 24px;
}
.shipping-form .el-form-item__label {
font-weight: 500;
color: #374151;
}
.step-actions {
display: flex;
gap: 16px;
justify-content: space-between;
padding-top: 24px;
border-top: 1px solid #e5e7eb;
}
.step-actions .el-button:first-child {
margin-right: auto;
}
.step-actions .el-button:last-child {
min-width: 120px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.shipping-form-container {
padding: 16px;
}
.shipping-form {
max-width: 100%;
}
.shipping-form .el-form-item {
margin-bottom: 16px;
}
.step-actions {
flex-direction: column;
gap: 12px;
}
.step-actions .el-button {
width: 100%;
}
.step-actions .el-button:first-child {
margin-right: 0;
order: 2;
}
.step-actions .el-button:last-child {
order: 1;
}
}
/* 表单验证样式 */
.el-form-item.is-error .el-input__wrapper,
.el-form-item.is-error .el-textarea__inner {
border-color: #f56c6c;
}
.el-form-item__error {
color: #f56c6c;
font-size: 12px;
margin-top: 4px;
}
/* 成功状态样式 */
.shipping-form-container.completed {
background: #f0f9ff;
border: 1px solid #0ea5e9;
}
```
### 自动填充逻辑
```javascript
// 如果用户之前有过发货记录,可以自动填充部分信息
const autoFillFromHistory = async () => {
try {
// 调用历史记录API用户后续实现
// const history = await getShippingHistory(userId)
// 模拟获取历史数据
const history = {
recipient: '张三',
phone: '138****8888',
address: '北京市朝阳区***街道**号',
logistics: 'sf'
}
// 只填充非空的历史数据
Object.keys(history).forEach(key => {
if (history[key] && !shippingForm[key]) {
shippingForm[key] = history[key]
}
})
} catch (error) {
console.error('Failed to auto-fill from history:', error)
}
}
```
## 验证标准
- 发货表单能够正确显示和提交
- 表单验证规则能够正确工作
- 返回上一步功能正常
- 表单数据持久化功能完整
- 响应式布局在各种设备上表现良好
- 发货完成流程正常

View File

@ -0,0 +1,75 @@
# Spec: 拆件流程导航和状态管理
## ADDED Requirements
### 流程导航组件
- **Requirement**: 实现基于Element Plus Timeline的四步骤流程导航组件
- **Scenario**: 用户进入拆件页面后,可以看到清晰的步骤进度指示,当前步骤高亮,已完成步骤显示勾选标记
### 步骤状态管理
- **Requirement**: 建立完整的步骤状态管理系统支持pending/current/completed三种状态
- **Scenario**: 系统能够准确跟踪用户在四个步骤间的进度,并在页面刷新后恢复状态
### 步骤覆盖逻辑
- **Requirement**: 实现在已有下一步状态时,上一步操作覆盖下游内容的逻辑
- **Scenario**: 用户在第二步点击重新生成时,系统会弹出确认对话框,确认后清空第三步和第四步的所有内容
### 步骤间导航
- **Requirement**: 支持用户点击已完成的步骤进行跳转,验证前置步骤完成情况
- **Scenario**: 用户可以点击Timeline中已完成的步骤快速跳转到该步骤但无法跳转到未完成的后续步骤
## MODIFIED Requirements
### 页面路由配置
- **Requirement**: 在管理后台路由中添加拆件流程页面的路由规则
- **Scenario**: 通过 `/admin/disassembly/:id` 路由访问拆件流程页面,支持从内容审核页面通过参数跳转
### 组件响应式设计
- **Requirement**: 优化流程导航在平板端和桌面端的显示效果
- **Scenario**: Timeline组件在不同屏幕尺寸下保持良好的可读性和交互性
## 技术实现细节
### 状态管理架构
```javascript
const workflowState = reactive({
currentStep: 1,
contentData: {
previewImage: null,
modelUrl: null,
disassemblyImages: [],
generatedModel: null,
shippingInfo: {}
},
stepStatus: {
1: 'current',
2: 'pending',
3: 'pending',
4: 'pending'
}
})
```
### Timeline组件配置
```javascript
const timelineData = computed(() => [
{
title: t('disassembly.steps.preview'),
status: workflowState.stepStatus[1],
clickable: true
},
{
title: t('disassembly.steps.disassembly'),
status: workflowState.stepStatus[2],
clickable: workflowState.stepStatus[1] === 'completed'
},
// ...更多步骤
])
```
## 验证标准
- Timeline组件正确显示四个步骤及其状态
- 步骤状态切换逻辑准确无误
- 步骤覆盖功能在各种场景下正常工作
- 响应式布局在各种设备上表现良好
- 路由传参和状态保持功能完整

View File

@ -0,0 +1,91 @@
# Tasks: 创建拆件流程管理页面
## 阶段 1: 项目基础检查和准备
- [ ] 1.1 检查现有项目的国际化配置和路由设置
- [ ] 1.2 确认 Element Plus Timeline 组件可用性
- [ ] 1.3 验证 demo 文件夹中的模型和图片资源
- [ ] 1.4 检查现有 ModelViewer 组件的功能
- [ ] 1.5 确认项目的主题色彩和样式变量配置
## 阶段 2: 拆件流程页面核心组件开发
- [ ] 2.1 创建 DisassemblyWorkflow.vue 主组件文件
- [ ] 2.2 实现基于 Element Plus Timeline 的四步骤流程结构
- [ ] 2.3 添加步骤间的导航逻辑和数据状态管理
- [ ] 2.4 实现步骤状态的覆盖逻辑(上游操作影响下游内容)
- [ ] 2.5 配置响应式布局的基础结构
## 阶段 3: 第一步 - 内容预览功能
- [ ] 3.1 实现左侧预览图的横排布局
- [ ] 3.2 添加预览图点击放大功能(使用现有对话框组件)
- [ ] 3.3 实现右侧 3D 模型展示(使用 demo/model.glb
- [ ] 3.4 集成 ModelViewer 组件进行模型预览
- [ ] 3.5 添加拆件按钮和点击处理逻辑
## 阶段 4: 第二步 - 拆件结果展示
- [ ] 4.1 创建拆件结果展示区域
- [ ] 4.2 实现已拆件图片内容的显示(使用 demo/suoluetu.png
- [ ] 4.3 添加右侧操作区:重新生成和生成模型按钮
- [ ] 4.4 实现重新生成按钮功能(模拟重新拆件逻辑)
- [ ] 4.5 实现生成模型按钮功能(推进到第三步)
## 阶段 5: 第三步 - 模型生成和展示
- [ ] 5.1 创建已生成模型展示区域
- [ ] 5.2 实现模型点击放大预览功能(集成 ModelViewer
- [ ] 5.3 添加右侧操作区:重新生成、导出、发货按钮
- [ ] 5.4 实现重新生成按钮功能(更新模型内容)
- [ ] 5.5 实现导出功能(提供不同格式选项)
- [ ] 5.6 实现发货按钮功能(推进到第四步)
## 阶段 6: 第四步 - 物流发货信息
- [ ] 6.1 创建发货信息填写表单
- [ ] 6.2 实现表单验证和字段配置(收件人、物流公司、运单号等)
- [ ] 6.3 添加表单提交和确认逻辑
- [ ] 6.4 实现发货完成后的状态反馈
- [ ] 6.5 添加返回上一步的导航功能
## 阶段 7: 国际化配置
- [ ] 7.1 添加拆件流程相关的中文国际化文案
- [ ] 7.2 添加对应的英文国际化文案
- [ ] 7.3 配置所有用户可见文本的国际化支持
- [ ] 7.4 验证双语切换功能的完整性
- [ ] 7.5 检查文案长度适配和布局影响
## 阶段 8: 样式和主题适配
- [ ] 8.1 应用项目的紫色主题色彩系统
- [ ] 8.2 实现 8px 网格间距系统
- [ ] 8.3 添加平滑过渡动画效果200ms
- [ ] 8.4 优化卡片、按钮、对话框等组件样式
- [ ] 8.5 确保响应式设计在桌面端和平板端的表现
- [ ] 8.6 添加步骤间的视觉连接和进度指示
## 阶段 9: 路由和页面集成
- [ ] 9.1 配置拆件流程页面的路由规则
- [ ] 9.2 在内容审核页面添加跳转到拆件流程的链接
- [ ] 9.3 实现路由传参和状态保持
- [ ] 9.4 添加面包屑导航支持
- [ ] 9.5 配置页面标题和元信息
## 阶段 10: 测试和优化
- [ ] 10.1 测试四个步骤间的导航和状态切换
- [ ] 10.2 验证预览图和模型预览功能
- [ ] 10.3 测试步骤覆盖逻辑的正确性
- [ ] 10.4 检查响应式布局在不同屏幕尺寸的表现
- [ ] 10.5 验证国际化切换功能的完整性
- [ ] 10.6 测试表单验证和错误处理
- [ ] 10.7 优化加载状态和用户体验
## 阶段 11: 文档和部署
- [ ] 11.1 更新 README.md添加拆件流程功能说明
- [ ] 11.2 创建组件使用文档和 API 说明
- [ ] 11.3 添加功能演示截图或说明
- [ ] 11.4 验证项目构建和开发服务器启动
- [ ] 11.5 最终测试和代码审查
## 验证标准
- 四步骤流程完整可用,导航流畅
- 预览图和模型展示功能正常
- 步骤覆盖逻辑正确实现
- 响应式设计在桌面端和平板端表现良好
- 国际化功能完全可用
- 所有按钮和交互功能正常工作
- 加载状态和错误处理完善

View File

@ -0,0 +1,52 @@
# 工作区首次进入弹窗需求
## 背景
用户第一次进入工作区时,对创作流程与生产流程不熟悉,容易迷失操作路径。
通过一次性弹窗,在首屏集中展示核心流程,降低学习成本,提升首次创作转化率。
## 需求目标
1. 用户首次进入“工作区”即触发弹窗(仅首次)。
2. 弹窗内容清晰说明“创作流程”与“生产流程”两步走。
3. 用户可主动关闭弹窗;关闭后不再自动弹出。
4. 记录“已阅”状态,刷新/重进工作区仍保持关闭状态。
## 触发条件
- 本地无 `workspaceGuideShown = true` 标记。
- 路由命中 `/workspace` 且用户身份已登录。
## 弹窗内容
| 步骤 | 标题 | 说明 | 示意图(占位) |
|----|------|------|--------------|
| ① 创作流程 | 上传素材 → AI 解析 → 在线编辑 | 支持 3D 模型、图片、文本一键上传AI 自动拆解可编辑元素 | ![创作流程图] |
| ② 生产流程 | 预览效果 → 一键打包 → 多端发布 | 实时预览场景,智能压缩与格式转换,直接发布至 Web/VR/小程序 | ![生产流程图] |
## 交互细则
1. 弹窗居中,蒙层遮罩,宽度 640px高度自适应最大 80vh。
2. 顶部大标题“欢迎来到工作区3 分钟带你完成首个作品”。
3. 步骤卡片横向排布PC 端左右滑动,移动端上下滑动。
4. 底部固定按钮:
- 主按钮“开始创作”——关闭弹窗并定位到“上传区域”。
- 次按钮“稍后提醒”——关闭弹窗,不写入标记,下次进入再弹。
5. 右上角“X”直接关闭写入标记视为“已阅”。
## 埋点需求
| 事件 | 参数 |
|------|------|
| workspace_guide_show | { user_id, timestamp } |
| workspace_guide_close | { user_id, way: 'start'/'later'/'x', timestamp } |
| workspace_guide_slide | { user_id, step: 1/2, timestamp } |
## 异常处理
- 若用户禁用了 localStorage降级使用 sessionStorage关闭标签页即失效。
- 若图片加载失败,显示占位灰图,不阻塞流程。
## 验收标准
✅ 首次进入出现弹窗;刷新后不再出现。
✅ 点击“开始创作”自动滚动到上传区域并焦点高亮 3 秒。
✅ 点击“稍后提醒”关闭,重新进入工作区再次弹出。
✅ 所有埋点正确上报,可在大数据平台查询。
## 上线计划
- 前端2024-07-15 提测
- 测试2024-07-18 完成
- 上线2024-07-20 随 v3.4.0 发布

View File

@ -0,0 +1,15 @@
用户点击拆件按钮 AdminContentReview.vue 162-162 跳转拆件页面,具体页面功能如下:
1.页面呈现流程图一样的设计可以参考element Plus的Timeline 时间线组件设计风格,第一步是预览图和模型横排展示,点击可以进行预览,然后提供拆件按钮,点击拆件按钮调用方法,具体方法我来写,然后展示第二步骤
2.第二步骤展示已经拆件的图片内容右侧给两个按钮一个是重新生成和生成模型重新生成第二部需要重新执行拆件逻辑更换第二部的图片内容生成模型生成第三步内容展示生成的模型生成模型方法占位后面我来写可以先用项目已经有的demo文件夹里的模型进行模拟
3.第三步展示已经生成的模型,点击可进行放大预览,可以使用已有的组件展示,右侧按钮提供重新生成,导出和发货,导出可以选择不同格式的文件导出
4.点击发货按钮进入第四步让管理员填写发货物流信息
注意点:
1.缩略图和模型可以先用demo文件夹里的内容进行模型展示
2.如果在已经有下一步的状态情况下,上一步点击有关下一步的按钮可以覆盖下一步内容

View File