This commit is contained in:
13121765685 2025-12-31 17:40:55 +08:00
parent 1025a0e26e
commit 324b670ed5
45 changed files with 1872 additions and 859 deletions

View File

@ -1,203 +1,201 @@
# 优化IPCard组件生成状态样式
# 新建项目系列选择功能实现
## 现状分析
## 需求分析
当前IPCard组件在图片生成过程中显示的占位符.generating-placeholder样式较为简单主要包含
* 当用户点击"新建项目"卡片时,需要弹出系列选择弹窗
* 深灰色背景(#2a2a2a
* 系列弹窗包含两个选项Done 和 Oone对应图片在 src/assets/xh 文件夹中
* 基础的圆形旋转加载动画
* 选中后将系列名称作为 type 参数传递给 createNewProject 函数
* 静态文本"正在生成图片..."
## 实现计划
## 优化目标
### 1. 创建 SeriesSelector 组件
使生成状态更加灵动、美观,符合项目的设计风格(深紫色主色调),提升用户体验。
* **文件路径**`src/views/components/SeriesSelector.vue`
## 优化方案
* **功能**
### 1. 背景效果优化
* 显示两个系列选项Done 和 Oone
* 将纯色背景改为渐变背景,与主题色呼应
* 每个选项显示对应的图片
* 添加微妙的呼吸动画效果
* 支持选中状态切换
### 2. 加载动画优化
* 提供确认和取消按钮
* 设计双层旋转动画,增强视觉层次感
* 通过 emit 事件返回选中的系列名称
* 使用主题色(#A78BFA作为动画主色调
### 2. 修改 CreationWorkspace.vue
* 添加脉冲效果,使动画更加生动
* **引入组件**:在 CreationWorkspace.vue 中引入 SeriesSelector 组件
### 3. 文本效果优化
* **添加状态管理**
* 添加渐变文字效果
* `showSeriesSelector`:控制系列选择弹窗的显示/隐藏
* 保持与主题色的一致性
* **修改新建项目逻辑**
### 4. 整体布局优化
* 点击"新建项目"卡片时,显示系列选择弹窗
* 添加卡片阴影和立体感
* 监听 SeriesSelector 的确认事件,获取选中的系列名称
* 确保动画流畅,性能优化
* 将系列名称作为 type 参数调用 createNewProject 函数
## 具体实现
### 3. 样式设计
### 修改样式代码
* 系列选择弹窗采用与现有删除确认弹窗一致的设计风格
1. **更新.generating-placeholder样式**
* 系列选项卡片包含图片和名称,支持悬停和选中效果
* 添加渐变背景
* 确认和取消按钮使用现有按钮样式
* 添加呼吸动画
## 代码结构
* 优化文本样式
### SeriesSelector.vue
2. **更新.generating-spinner样式**
```vue
<template>
<!-- 系列选择弹窗 -->
<div v-if="show" class="modal-overlay" @click="onCancel">
<div class="modal-content" @click.stop>
<!-- 模态头部 -->
<div class="modal-header">
<h2 class="modal-title">{{ t('creationWorkspace.selectSeries') }}</h2>
</div>
* 设计双层旋转结构
<!-- 模态内容 -->
<div class="modal-body">
<div class="series-selector-content">
<!-- 系列选项列表 -->
<div class="series-list">
<!-- Done 系列 -->
<div
class="series-item"
:class="{ active: selectedSeries === 'Done' }"
@click="selectedSeries = 'Done'"
>
<div class="series-image">
<img src="@/assets/xh/Done.webp" alt="Done" />
</div>
<div class="series-name">Done</div>
</div>
* 使用主题色作为动画颜色
<!-- Oone 系列 -->
<div
class="series-item"
:class="{ active: selectedSeries === 'Oone' }"
@click="selectedSeries = 'Oone'"
>
<div class="series-image">
<img src="@/assets/xh/Oone.webp" alt="Oone" />
</div>
<div class="series-name">Oone</div>
</div>
</div>
* 添加脉冲动画
<!-- 操作按钮 -->
<div class="series-actions">
<button class="modal-action-btn cancel" @click="onCancel">
{{ t('creationWorkspace.cancel') }}
</button>
<button class="modal-action-btn primary" @click="onConfirm">
{{ t('creationWorkspace.confirm') }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
3. **添加新的动画关键帧**
<script setup>
import { ref, defineProps, defineEmits } from 'vue'
import { useI18n } from 'vue-i18n'
* 双层旋转动画
const { t } = useI18n()
* 呼吸效果动画
const props = defineProps({
show: {
type: Boolean,
default: false
}
})
* 脉冲效果动画
const emit = defineEmits(['confirm', 'cancel'])
## 预期效果
const selectedSeries = ref('')
* 生成状态更加灵动、美观
const onConfirm = () => {
if (selectedSeries.value) {
emit('confirm', selectedSeries.value)
}
}
* 与项目设计风格保持一致
const onCancel = () => {
emit('cancel')
}
</script>
* 提升用户等待体验
<style scoped>
/* 系列选择弹窗样式 */
.series-selector-content {
text-align: center;
}
* 动画流畅,性能优化
## 实现步骤
1. 修改IPCard组件的样式部分更新.generating-placeholder相关样式
2. 添加新的动画关键帧
3. 确保样式与主题色保持一致
4. 测试动画性能和效果
## 代码示例
```css
/* 生成状态样式优化 */
.generating-placeholder {
width: 100%;
height: 100%;
.series-list {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
justify-content: center;
background: linear-gradient(135deg, #2a2a2a 0%, #1a1a2e 100%);
color: white;
border-radius: inherit;
position: relative;
margin-bottom: 32px;
}
.series-item {
background: #f8fafc;
border: 2px solid #e2e8f0;
border-radius: 12px;
padding: 16px;
cursor: pointer;
transition: all 0.3s ease;
width: 200px;
}
.series-item:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
}
.series-item.active {
border-color: #6B46C1;
background: rgba(107, 70, 193, 0.05);
box-shadow: 0 4px 16px rgba(107, 70, 193, 0.2);
}
.series-image {
width: 100%;
height: 120px;
overflow: hidden;
animation: breathe 3s ease-in-out infinite;
border-radius: 8px;
margin-bottom: 12px;
}
/* 添加背景装饰效果 */
.generating-placeholder::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(167, 139, 250, 0.1) 0%, transparent 70%);
animation: rotate 20s linear infinite;
}
.generating-spinner {
position: relative;
width: 48px;
height: 48px;
margin-bottom: 16px;
z-index: 1;
}
.generating-spinner::before,
.generating-spinner::after {
content: '';
position: absolute;
.series-image img {
width: 100%;
height: 100%;
border-radius: 50%;
border: 3px solid transparent;
animation: spin 2s linear infinite;
object-fit: cover;
}
.generating-spinner::before {
border-top-color: #A78BFA;
border-right-color: #A78BFA;
animation-duration: 1s;
animation-timing-function: ease-in-out;
.series-name {
font-size: 16px;
font-weight: 600;
color: #1f2937;
}
.generating-spinner::after {
border-bottom-color: #6B46C1;
border-left-color: #6B46C1;
animation-duration: 2s;
animation-timing-function: ease-in-out;
}
/* 添加脉冲效果 */
.generating-spinner::before {
box-shadow: 0 0 10px rgba(167, 139, 250, 0.3);
animation: spin 1s linear infinite, pulse 2s ease-in-out infinite;
}
.generating-text {
font-size: 14px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #A78BFA 0%, #6B46C1 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-weight: 500;
z-index: 1;
position: relative;
}
/* 新增动画关键帧 */
@keyframes breathe {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.95;
}
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes pulse {
0%, 100% {
box-shadow: 0 0 10px rgba(167, 139, 250, 0.3);
transform: scale(1);
}
50% {
box-shadow: 0 0 20px rgba(167, 139, 250, 0.6);
transform: scale(1.05);
}
.series-actions {
display: flex;
gap: 16px;
justify-content: center;
}
</style>
```
这个优化方案将使IPCard

View File

@ -0,0 +1,36 @@
# 实现3MF格式模型导出功能
## 1. 安装依赖
- 安装 `three-3mf-exporter` 插件库用于支持3MF格式导出
## 2. 修改 ModelViewer.vue 组件
### 2.1 导入3MF导出器
在组件的脚本部分添加3MF导出器的导入
### 2.2 更新导出选项
在导出下拉菜单中添加3MF格式选项
### 2.3 实现3MF导出功能
添加 `exportAs3MF` 方法使用3MF导出器将模型导出为3MF格式
### 2.4 更新导出命令处理
`handleExportCommand` 方法中添加对3MF格式的支持
### 2.5 添加国际化支持
在国际化文件中添加3MF相关的文本翻译
## 3. 测试功能
- 运行开发服务器测试3MF导出功能是否正常工作
- 检查导出的3MF文件是否可以正常打开
## 4. 兼容性检查
- 确保3MF导出功能与现有导出功能兼容
- 确保3MF导出功能在不同设备上都能正常工作
## 5. 代码优化
- 确保代码符合项目的代码风格和最佳实践
- 优化导出性能,确保导出过程流畅
## 6. 文档更新
- 更新组件文档说明3MF导出功能的使用方法
- 更新项目文档添加3MF格式支持的说明

View File

@ -20,6 +20,7 @@
"konva": "^10.0.12",
"pinia": "^2.2.6",
"three": "^0.180.0",
"three-3mf-exporter": "^45.0.0",
"vue": "^3.5.24",
"vue-i18n": "^9.14.2",
"vue-konva": "^3.2.6",

View File

@ -35,7 +35,8 @@ import {
Lock,
Key,
List,
Coin
Coin,
ShoppingCartFull
} from '@element-plus/icons-vue'
const props = defineProps({
@ -60,7 +61,8 @@ const iconMap = {
Lock,
Key,
List,
Coin
Coin,
ShoppingCartFull
}
const getIconComponent = (iconName) => {

View File

@ -91,6 +91,10 @@
<span class="format-dot" style="color: #f56c6c;"></span>
{{ t('modelViewer.exportAsFBX') }}
</el-dropdown-item>
<el-dropdown-item command="3mf">
<span class="format-dot" style="color: #20c997;"></span>
{{ t('modelViewer.exportAs3MF') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
@ -116,7 +120,6 @@ import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js'
import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js'
import { ElMessage } from 'element-plus'
import { Loading, Warning, Refresh, Grid, Position, Download, ArrowDown } from '@element-plus/icons-vue'
const { t } = useI18n()
// Props
@ -445,6 +448,9 @@ const handleExportCommand = (command) => {
case 'fbx':
exportAsFBX()
break
case '3mf':
exportAs3MF()
break
default:
ElMessage.error('不支持的导出格式')
break
@ -644,6 +650,57 @@ const exportAsFBX = () => {
}
}
// 3MF
const exportAs3MF = async () => {
if (!model || !scene) {
ElMessage({
type: 'warning',
message: 'No model to export'
})
return
}
isExporting.value = true
ElMessage({
type: 'info',
message: t('modelViewer.exportInProgress'),
duration: 2000
})
try {
// three-3mf-exporter
const { exportTo3MF } = await import('three-3mf-exporter')
// exportTo3MF
const blob = await exportTo3MF(model, {
compression: 'standard',
})
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${modelInfo.value || 'model'}.3mf`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
ElMessage({
type: 'success',
message: t('modelViewer.exportSuccess')
})
} catch (error) {
console.error('3MF export error:', error)
ElMessage({
type: 'error',
message: t('modelViewer.exportFailed')
})
} finally {
isExporting.value = false
}
}
//
const handleMouseDown = () => {
// OrbitControls

View File

@ -40,6 +40,7 @@ export default {
exportAsOBJ: 'Export as OBJ',
exportAsSTL: 'Export as STL',
exportAsFBX: 'Export as FBX',
exportAs3MF: 'Export as 3MF',
selectFormat: 'Select Export Format',
preparingExport: 'Preparing export...'
},
@ -243,6 +244,7 @@ export default {
yes: 'Yes',
no: 'No',
action: 'Action',
actions: 'Actions',
close: 'Close',
back: 'Back'
},
@ -260,6 +262,8 @@ export default {
userList: 'User List',
pointsManagement: 'Points Management',
commissionManagement: 'Commission Management',
promptManagement: 'Prompt Management',
productManagement: 'Product Management',
logout: 'Logout',
profile: 'Profile',
settings: 'Settings'
@ -780,6 +784,76 @@ export default {
approved: 'Approved',
rejected: 'Rejected'
}
},
promptManagement: {
title: 'Prompt Management',
addPrompt: 'Add Prompt',
editPrompt: 'Edit Prompt',
promptDetail: 'Prompt Detail',
inactivePrompts: 'Inactive Prompts',
activePrompts: 'Active Prompts',
type: 'Type',
selectType: 'Select Type',
animal: 'Animal',
person: 'Person',
general: 'General',
O1: 'O1',
title: 'Title',
enterTitle: 'Please enter prompt title',
content: 'Content',
enterContent: 'Please enter prompt content',
referenceImage: 'Reference Image',
imageTip: 'Supports JPG and PNG formats, max size 2MB',
active: 'Active',
deleteConfirm: 'Are you sure you want to delete this prompt?',
deleteSuccess: 'Deleted successfully',
saveSuccess: 'Saved successfully',
activeSuccess: 'Set to active status',
inactiveSuccess: 'Removed from active status'
},
productManagement: {
title: 'Product Management',
addProduct: 'Add Product',
editProduct: 'Edit Product',
productDetail: 'Product Detail',
productId: 'Product ID',
productName: 'Product Name',
enterProductName: 'Enter Product Name',
productImage: 'Product Image',
imageUploadTip: 'Support JPG, PNG, GIF format, max size 5MB',
imageTypeError: 'Please upload image file only',
imageSizeError: 'Image size cannot exceed 5MB',
imageUploadSuccess: 'Image uploaded successfully',
imageUploadFailed: 'Failed to upload image',
noImage: 'No image',
description: 'Description',
enterDescription: 'Enter Description',
amount: 'Amount',
enterAmount: 'Enter Amount',
currency: 'Currency',
selectCurrency: 'Select Currency',
price: 'Price',
status: 'Status',
selectStatus: 'Select Status',
active: 'Active',
deleted: 'Deleted',
createdAt: 'Created At',
updatedAt: 'Updated At',
updatePrice: 'Update Price',
newAmount: 'New Amount',
enterNewAmount: 'Enter New Amount',
currentPrice: 'Current Price',
fetchFailed: 'Failed to fetch products',
addSuccess: 'Product added successfully',
addFailed: 'Failed to add product',
updateSuccess: 'Product updated successfully',
updateFailed: 'Failed to update product',
deleteSuccess: 'Product deleted successfully',
deleteFailed: 'Failed to delete product',
priceUpdateSuccess: 'Price updated successfully',
priceUpdateFailed: 'Failed to update price',
detailFailed: 'Failed to fetch product detail',
deleteConfirm: 'Are you sure you want to delete this product?'
}
},
modelUpload: {

View File

@ -278,6 +278,7 @@ orderManagement: {
pointsManagement: '充值包管理',
commissionManagement: '佣金管理',
promptManagement: '提示词管理',
productManagement: '产品管理',
logout: '退出登录',
profile: '个人资料',
settings: '设置',
@ -790,6 +791,7 @@ orderManagement: {
animal: '动物',
person: '人物',
general: '通用',
O1: 'O1',
title: '标题',
enterTitle: '请输入提示词标题',
content: '内容',
@ -802,6 +804,50 @@ orderManagement: {
saveSuccess: '保存成功',
activeSuccess: '已设置为生效状态',
inactiveSuccess: '已从生效状态移除'
},
productManagement: {
title: '产品管理',
addProduct: '添加产品',
editProduct: '编辑产品',
productDetail: '产品详情',
productId: '产品ID',
productName: '产品名称',
enterProductName: '请输入产品名称',
productImage: '产品图片',
imageUploadTip: '支持JPG、PNG、GIF格式最大5MB',
imageTypeError: '请上传图片文件',
imageSizeError: '图片大小不能超过5MB',
imageUploadSuccess: '图片上传成功',
imageUploadFailed: '图片上传失败',
noImage: '暂无图片',
description: '描述',
enterDescription: '请输入产品描述',
amount: '金额',
enterAmount: '请输入金额',
currency: '货币',
selectCurrency: '选择货币',
price: '价格',
status: '状态',
selectStatus: '选择状态',
active: '活跃',
deleted: '已删除',
createdAt: '创建时间',
updatedAt: '更新时间',
updatePrice: '更新价格',
newAmount: '新金额',
enterNewAmount: '请输入新金额',
currentPrice: '当前价格',
fetchFailed: '获取产品列表失败',
addSuccess: '产品添加成功',
addFailed: '产品添加失败',
updateSuccess: '产品更新成功',
updateFailed: '产品更新失败',
deleteSuccess: '产品删除成功',
deleteFailed: '产品删除失败',
priceUpdateSuccess: '价格更新成功',
priceUpdateFailed: '价格更新失败',
detailFailed: '获取产品详情失败',
deleteConfirm: '确定要删除这个产品吗?'
}
},
modelUpload: {
@ -854,6 +900,8 @@ orderManagement: {
save: '保存',
delete: '删除',
edit: '编辑',
detail: '详情',
actions: '操作',
search: '搜索',
loading: '加载中...',
viewAll: '查看全部',
@ -887,6 +935,7 @@ orderManagement: {
exportAsOBJ: '导出为OBJ',
exportAsSTL: '导出为STL',
exportAsFBX: '导出为FBX',
exportAs3MF: '导出为3MF',
selectFormat: '选择导出格式',
preparingExport: '准备导出中...'
},

View File

@ -56,8 +56,10 @@ const initRoutes = () => {
}
}
}
initRoutes()
if (window.location.hostname.indexOf('local') === -1) {
initRoutes()
}
// initRoutes()
// 配置路由
app.use(router)

View File

@ -23,6 +23,7 @@ const AdminUserList = () => import('@/views/admin/AdminRoleManagement/AdminUserL
const AdminPointsManagement = () => import('@/views/admin/AdminPointsManagement/AdminPointsManagement.vue')
const AdminCommissionManagement = () => import('@/views/admin/AdminCommissionManagement/AdminCommissionManagement.vue')
const AdminPromptManagement = () => import('@/views/admin/AdminPromptManagement/AdminPromptManagement.vue')
const AdminProductManagement = () => import('@/views/admin/ProductManagement/ProductManagement.vue')
//权限路由映射表
export const permissionRoutes = [
{
@ -35,6 +36,17 @@ export const permissionRoutes = [
requiresAuth: true,
menuOrder: 1
}
},
{
path: 'product-management',
name: 'AdminProductManagement',
component: AdminProductManagement,
meta: {
title: 'admin.layout.productManagement',
icon: 'ShoppingCartFull',
menuOrder: 2,
requiresAuth: true
}
},
{
path: 'orders',
@ -58,17 +70,7 @@ export const permissionRoutes = [
requiresAuth: true,
},
},
{
path: 'disassembly-orders',
name: 'AdminDisassemblyOrders',
component: AdminDisassemblyOrders,
meta: {
title: 'admin.layout.disassemblyOrders',
icon: 'EditPen',
menuOrder: 2,
requiresAuth: true,
}
},
{
path: 'order-list',
name: 'AdminOrdersList',
@ -126,6 +128,7 @@ export const permissionRoutes = [
requiresAuth: true
}
},
{
path: 'permission',
name: 'AdminPermission',
@ -222,6 +225,17 @@ const routes = [
requiresAuth: true
},
children: [
{
path: 'disassembly-orders',
name: 'AdminDisassemblyOrders',
component: AdminDisassemblyOrders,
meta: {
title: 'admin.layout.disassemblyOrders',
icon: 'EditPen',
menuOrder: 2,
requiresAuth: true,
}
},
{
path: 'disassembly-orders/:id',
name: 'AdminDisassemblyDetail',
@ -269,6 +283,7 @@ const routes = [
requiresAuth: true
}
},
...(window.location.hostname.indexOf('local') === -1 ? [] : permissionRoutes)
]//[]//
},
{

View File

@ -11,7 +11,7 @@ export const useAuthStore = defineStore('auth', {
permissionButton: JSON.parse(localStorage.getItem('permissionButton') || '[]'),
router: null,
routesUpdated: 0,//路由更新次数
routerList: []//侧边栏路由
routerList: window.location.hostname.indexOf('local') === -1 ? [] : permissionRoutes,//侧边栏路由
}),
getters: {

View File

@ -82,6 +82,7 @@
<el-option :label="t('admin.promptManagement.animal')" value="animal" />
<el-option :label="t('admin.promptManagement.person')" value="person" />
<el-option :label="t('admin.promptManagement.general')" value="general" />
<el-option :label="t('admin.promptManagement.O1')" value="O1" />
</el-select>
</el-form-item>

View File

@ -0,0 +1,737 @@
<template>
<div class="product-management">
<!-- 页面头部 -->
<div class="page-header">
<h2>{{ t('admin.productManagement.title') }}</h2>
<el-button type="primary" @click="showAddDialog">
<el-icon><Plus /></el-icon>
{{ t('admin.productManagement.addProduct') }}
</el-button>
</div>
<!-- 搜索和筛选区域 -->
<div class="search-filter">
<el-form :inline="true" :model="searchForm" class="search-form">
<el-form-item :label="t('admin.productManagement.productName')">
<el-input
v-model="searchForm.name"
:placeholder="t('admin.productManagement.enterProductName')"
clearable
@keyup.enter="fetchProducts"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="fetchProducts">
<el-icon><Search /></el-icon>
{{ t('common.search') }}
</el-button>
<el-button @click="resetSearch">
{{ t('common.reset') }}
</el-button>
</el-form-item>
</el-form>
</div>
<!-- 产品列表 -->
<div class="product-list-container">
<el-table
v-loading="loading"
:data="products"
stripe
style="width: 100%"
>
<el-table-column prop="id" :label="t('admin.productManagement.productId')" width="100" />
<el-table-column prop="name" :label="t('admin.productManagement.productName')" min-width="200" />
<el-table-column prop="description" :label="t('admin.productManagement.description')" min-width="250" />
<el-table-column prop="current_price" :label="t('admin.productManagement.price')" width="120">
<template #default="scope">
<span v-if="scope.row.current_price">
${{ scope.row.current_price.amount }} {{ scope.row.current_price.currency }}
</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="created_at" :label="t('admin.productManagement.createdAt')" width="180">
<template #default="scope">
{{ formatDate(scope.row.created_at) }}
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="380" fixed="right">
<template #default="scope">
<el-button
type="primary"
size="small"
@click="showDetailDialog(scope.row)"
>
<el-icon><View /></el-icon>
{{ t('common.detail') }}
</el-button>
<el-button
type="warning"
size="small"
@click="showEditDialog(scope.row)"
v-if="scope.row.is_delete === 0"
>
<el-icon><Edit /></el-icon>
{{ t('common.edit') }}
</el-button>
<el-button
type="danger"
size="small"
@click="deleteProduct(scope.row)"
v-if="scope.row.is_delete === 0"
>
<el-icon><Delete /></el-icon>
{{ t('common.delete') }}
</el-button>
<el-button
type="success"
size="small"
@click="updateProductPrice(scope.row)"
v-if="scope.row.is_delete === 0"
>
<el-icon><Money /></el-icon>
{{ t('admin.productManagement.updatePrice') }}
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="pagination.currentPage"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
<!-- 添加/编辑产品弹窗 -->
<el-dialog
v-model="dialogVisible"
:title="isEditing ? t('admin.productManagement.editProduct') : t('admin.productManagement.addProduct')"
width="600px"
>
<el-form :model="formData" label-width="100px" class="product-form">
<el-form-item :label="t('admin.productManagement.productName')" required>
<el-input
v-model="formData.name"
:placeholder="t('admin.productManagement.enterProductName')"
/>
</el-form-item>
<el-form-item :label="t('admin.productManagement.productImage')">
<div class="image-upload-container">
<el-upload
class="image-uploader"
:show-file-list="false"
:before-upload="beforeImageUpload"
:on-change="handleImageChange"
accept="image/*"
>
<img v-if="formData.image" :src="formData.image" class="uploaded-image" />
<el-icon v-else class="uploader-icon"><Plus /></el-icon>
</el-upload>
<div class="image-upload-tip">
{{ t('admin.productManagement.imageUploadTip') }}
</div>
</div>
</el-form-item>
<el-form-item :label="t('admin.productManagement.description')" required>
<el-input
v-model="formData.description"
type="textarea"
:rows="4"
:placeholder="t('admin.productManagement.enterDescription')"
/>
</el-form-item>
<el-form-item v-if="!isEditing" :label="t('admin.productManagement.amount')" required>
<el-input
v-model="formData.amount"
type="number"
:placeholder="t('admin.productManagement.enterAmount')"
min="0"
step="0.01"
/>
</el-form-item>
<el-form-item v-if="!isEditing" :label="t('admin.productManagement.currency')" required>
<el-select
v-model="formData.currency"
:placeholder="t('admin.productManagement.selectCurrency')"
>
<el-option label="USD" value="USD" />
<el-option label="EUR" value="EUR" />
<el-option label="GBP" value="GBP" />
<el-option label="CNY" value="CNY" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="saveProduct">{{ t('common.save') }}</el-button>
</span>
</template>
</el-dialog>
<!-- 产品详情弹窗 -->
<el-dialog
v-model="detailVisible"
:title="t('admin.productManagement.productDetail')"
width="600px"
>
<div v-if="selectedProduct" class="product-detail">
<el-descriptions :column="1" border>
<el-descriptions-item :label="t('admin.productManagement.productId')">
{{ selectedProduct.id }}
</el-descriptions-item>
<el-descriptions-item :label="t('admin.productManagement.productName')">
{{ selectedProduct.name }}
</el-descriptions-item>
<el-descriptions-item :label="t('admin.productManagement.productImage')">
<img v-if="selectedProduct?.product_info?.image" :src="selectedProduct?.product_info?.image" class="detail-image" />
<span v-else>{{ t('admin.productManagement.noImage') }}</span>
</el-descriptions-item>
<el-descriptions-item :label="t('admin.productManagement.description')">
{{ selectedProduct.description }}
</el-descriptions-item>
<el-descriptions-item :label="t('admin.productManagement.price')">
${{ selectedProduct.current_price.amount }} {{ selectedProduct.current_price.currency }}
</el-descriptions-item>
<el-descriptions-item :label="t('admin.productManagement.createdAt')">
{{ formatDate(selectedProduct.created_at) }}
</el-descriptions-item>
<el-descriptions-item :label="t('admin.productManagement.updatedAt')">
{{ formatDate(selectedProduct.updated_at) }}
</el-descriptions-item>
</el-descriptions>
</div>
<el-skeleton v-else :rows="8" animated />
<template #footer>
<span class="dialog-footer">
<el-button @click="detailVisible = false">{{ t('common.close') }}</el-button>
</span>
</template>
</el-dialog>
<!-- 更新产品价格弹窗 -->
<el-dialog
v-model="priceDialogVisible"
:title="t('admin.productManagement.updatePrice')"
width="500px"
>
<el-form :model="priceForm" label-width="100px" class="price-form">
<el-form-item :label="t('admin.productManagement.productName')">
<el-input v-model="priceForm.productName" disabled />
</el-form-item>
<el-form-item :label="t('admin.productManagement.currentPrice')">
<el-input v-model="priceForm.currentPrice" disabled />
</el-form-item>
<el-form-item :label="t('admin.productManagement.newAmount')" required>
<el-input
v-model="priceForm.amount"
type="number"
:placeholder="t('admin.productManagement.enterNewAmount')"
min="0"
step="0.01"
/>
</el-form-item>
<el-form-item :label="t('admin.productManagement.currency')" required>
<el-select
v-model="priceForm.currency"
:placeholder="t('admin.productManagement.selectCurrency')"
>
<el-option label="USD" value="USD" />
<el-option label="EUR" value="EUR" />
<el-option label="GBP" value="GBP" />
<el-option label="CNY" value="CNY" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="priceDialogVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="saveProductPrice">{{ t('common.save') }}</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { Plus, Search, Edit, Delete, View, Money } from '@element-plus/icons-vue'
import { ProductManagement } from './index.js'
import { FileServer } from '@deotaland/utils'
const { t } = useI18n()
const fileServer = new FileServer()
// API
const productApi = new ProductManagement()
//
const dialogVisible = ref(false)
const detailVisible = ref(false)
const priceDialogVisible = ref(false)
const isEditing = ref(false)
const loading = ref(false)
const products = ref([])
const total = ref(0)
const selectedProduct = ref(null)
//
const searchForm = ref({
name: '',
is_delete: ''
})
//
const pagination = ref({
currentPage: 1,
pageSize: 10
})
//
const formData = ref({
id: '',
name: '',
description: '',
amount: 0,
currency: 'USD',
current_price_id: '',
image: ''
})
//
const priceForm = ref({
productId: '',
productName: '',
currentPrice: '',
amount: 0,
currency: 'USD'
})
//
const fetchProducts = async () => {
try {
loading.value = true
const params = {
page: pagination.value.currentPage,
page_size: pagination.value.pageSize,
name: searchForm.value.name,
is_delete: searchForm.value.is_delete === '' ? undefined : searchForm.value.is_delete
}
const response = await productApi.getProductList(params)
if (response.success && response.data) {
products.value = response.data.items || []
total.value = response.data.total || 0
}
} catch (error) {
console.error('获取产品列表失败:', error)
ElMessage.error(t('admin.productManagement.fetchFailed'))
} finally {
loading.value = false
}
}
//
onMounted(() => {
fetchProducts()
})
//
const showAddDialog = () => {
isEditing.value = false
formData.value = {
id: '',
name: '',
description: '',
amount: 0,
currency: 'USD',
current_price_id: '',
image: ''
}
dialogVisible.value = true
}
//
const showEditDialog = async (product) => {
let response = await productApi.getProductDetail({ id: product.id })
response = response.data || {}
isEditing.value = true
formData.value = {
id: product.id,
name: product.name,
description: product.description,
amount: product.amount,
currency: product.currency,
current_price_id: product.current_price_id || '',
image: response.product_info?.image || ''
}
dialogVisible.value = true
}
//
const showDetailDialog = async (product) => {
try {
loading.value = true
const response = await productApi.getProductDetail({ id: product.id })
if (response.success && response.data) {
selectedProduct.value = response.data
detailVisible.value = true
}
} catch (error) {
console.error('获取产品详情失败:', error)
ElMessage.error(t('admin.productManagement.detailFailed'))
} finally {
loading.value = false
}
}
//
const updateProductPrice = (product) => {
priceForm.value = {
productId: product.id,
productName: product.name,
currentPrice: `$${product.current_price.amount} ${product.current_price.currency}`,
amount: product.current_price.amount,
currency: product.current_price.currency
}
priceDialogVisible.value = true
}
//
const saveProduct = async () => {
try {
loading.value = true
if (isEditing.value) {
//
await productApi.updateProduct(formData.value)
ElMessage.success(t('admin.productManagement.updateSuccess'))
} else {
//
await productApi.createProduct(formData.value)
ElMessage.success(t('admin.productManagement.addSuccess'))
}
dialogVisible.value = false
//
await fetchProducts()
} catch (error) {
console.error('保存产品失败:', error)
ElMessage.error(isEditing.value ? t('admin.productManagement.updateFailed') : t('admin.productManagement.addFailed'))
} finally {
loading.value = false
}
}
//
const saveProductPrice = async () => {
try {
loading.value = true
const params = {
product_id: priceForm.value.productId,
amount: priceForm.value.amount,
currency: priceForm.value.currency
}
await productApi.updateProductPrice(params)
ElMessage.success(t('admin.productManagement.priceUpdateSuccess'))
priceDialogVisible.value = false
//
await fetchProducts()
} catch (error) {
console.error('更新产品价格失败:', error)
ElMessage.error(t('admin.productManagement.priceUpdateFailed'))
} finally {
loading.value = false
}
}
//
const deleteProduct = (product) => {
ElMessageBox.confirm(
t('admin.productManagement.deleteConfirm'),
t('common.confirm'),
{
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning'
}
).then(async () => {
try {
loading.value = true
await productApi.deleteProduct({ id: product.id })
ElMessage.success(t('admin.productManagement.deleteSuccess'))
//
await fetchProducts()
} catch (error) {
console.error('删除产品失败:', error)
ElMessage.error(t('admin.productManagement.deleteFailed'))
} finally {
loading.value = false
}
}).catch(() => {
//
})
}
//
const resetSearch = () => {
searchForm.value = {
name: '',
is_delete: ''
}
pagination.value.currentPage = 1
fetchProducts()
}
//
const handleSizeChange = (size) => {
pagination.value.pageSize = size
fetchProducts()
}
const handleCurrentChange = (current) => {
pagination.value.currentPage = current
fetchProducts()
}
//
const formatDate = (dateString) => {
if (!dateString) return ''
const date = new Date(dateString)
return date.toLocaleString()
}
//
const beforeImageUpload = (file) => {
const isImage = file.type.startsWith('image/')
const isLt5M = file.size / 1024 / 1024 < 5
if (!isImage) {
ElMessage.error(t('admin.productManagement.imageTypeError'))
return false
}
if (!isLt5M) {
ElMessage.error(t('admin.productManagement.imageSizeError'))
return false
}
return true
}
//
const handleImageChange = (file) => {
if (!beforeImageUpload(file.raw)) {
return
}
const reader = new FileReader()
reader.onload = (e) => {
const imageUrl = e.target.result
fileServer.uploadFile(imageUrl).then((url) => {
formData.value.image = url
ElMessage.success(t('admin.productManagement.imageUploadSuccess'))
}).catch((error) => {
console.error('图片上传失败:', error)
ElMessage.error(t('admin.productManagement.imageUploadFailed'))
})
}
reader.readAsDataURL(file.raw)
}
</script>
<style scoped>
.product-management {
width: 100%;
height: 100%;
padding: 20px;
background-color: #f5f5f5;
}
/* 页面头部 */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding: 16px 20px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.page-header h2 {
font-size: 24px;
font-weight: 600;
color: #1f2937;
margin: 0;
}
/* 搜索和筛选区域 */
.search-filter {
background: white;
padding: 20px;
margin-bottom: 24px;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.search-form {
display: flex;
gap: 16px;
align-items: center;
}
/* 产品列表容器 */
.product-list-container {
background: white;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
padding: 20px;
margin-bottom: 24px;
}
/* 表格图片样式 */
.table-image {
width: 60px;
height: 60px;
object-fit: contain;
border-radius: 4px;
border: 1px solid #e4e7ed;
}
.no-image-text {
color: #909399;
font-size: 14px;
}
/* 操作列按钮样式 */
.product-list-container :deep(.el-table-fixed-column--right .cell) {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: nowrap;
}
.product-list-container :deep(.el-table-fixed-column--right .el-button) {
flex-shrink: 0;
padding: 5px 12px;
}
/* 分页 */
.pagination {
margin-top: 20px;
text-align: right;
}
/* 产品详情 */
.product-detail {
padding: 20px 0;
}
.detail-image {
width: 200px;
height: 200px;
object-fit: contain;
border-radius: 8px;
border: 1px solid #e4e7ed;
}
/* 图片上传容器 */
.image-upload-container {
display: flex;
flex-direction: column;
gap: 12px;
}
.image-uploader {
width: 100%;
}
.image-uploader :deep(.el-upload) {
width: 100%;
height: 200px;
border: 2px dashed #d9d9d9;
border-radius: 8px;
cursor: pointer;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
}
.image-uploader :deep(.el-upload:hover) {
border-color: #6B46C1;
}
.uploader-icon {
font-size: 48px;
color: #8c939d;
}
.uploaded-image {
width: 100%;
height: 100%;
object-fit: contain;
}
.image-upload-tip {
font-size: 12px;
color: #909399;
line-height: 1.5;
}
/* 响应式设计 */
@media (max-width: 768px) {
.product-management {
padding: 10px;
}
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.search-form {
flex-direction: column;
align-items: stretch;
}
.search-form .el-form-item {
width: 100%;
}
.product-list-container {
padding: 10px;
overflow-x: auto;
}
.pagination {
text-align: center;
}
}
@media (max-width: 1024px) {
.search-form {
flex-wrap: wrap;
}
}
</style>

View File

@ -0,0 +1,62 @@
import { requestUtils, adminApi } from '@deotaland/utils';
export class ProductManagement {
//创建产品及价格
async createProduct(data) {
let params = {
name: data.name,//产品名称
description: data.description,//产品描述
amount: data.amount,//金额,单位为美元
currency: data.currency,//货币类型默认USD
product_info:{
image:data.image,//产品图片
}
}
return await requestUtils.common(adminApi.default.createProduct, params);
}
//获取产品详情
async getProductDetail(data) {
let params = {
id: data.id,
}
return await requestUtils.common(adminApi.default.getProductDetail, params);
}
//获取产品列表
async getProductList(data) {
let params = {
"page": data.page,//页码
"page_size": data.page_size,//每页数量
"name": data.name,//产品名称
"is_delete": data.is_delete,//是否删除
}
return await requestUtils.common(adminApi.default.getProductList, params);
}
//更新产品信息
async updateProduct(data) {
let params = {
"id": data.id,//产品ID
"name": data.name,//产品名称
"description": data.description,//产品描述
"current_price_id": data.current_price_id,//当前价格ID
product_info:{
image:data.image,//产品图片
}
}
return await requestUtils.common(adminApi.default.updateProduct, params);
}
//更新产品价格
async updateProductPrice(data) {
let params = {
"product_id": data.product_id,//产品ID
"amount": data.amount,//金额,单位为美元
"currency": data.currency,//货币类型默认USD
}
return await requestUtils.common(adminApi.default.updateProductPrice, params);
}
//删除产品
async deleteProduct(data){
let params = {
"id": data.id,//产品ID
}
return await requestUtils.common(adminApi.default.deleteProduct, params);
}
}

View File

@ -68,8 +68,7 @@ import { Picture, MagicStick, ArrowLeft, Edit, Check, Guide } from '@element-plu
import { ElButton, ElIcon, ElInput } from 'element-plus'
import ThemeToggle from '../ui/ThemeToggle.vue'
import LanguageToggle from '../ui/LanguageToggle.vue'
const emit = defineEmits(['openGuideModal'])
const emit = defineEmits(['openGuideModal','back'])
const props = defineProps({
total_score: {
type: Number,
@ -92,12 +91,13 @@ const { t, locale } = useI18n()
//
const handleBack = () => {
//
if (window.history.length > 1) {
window.history.back()
} else {
//
window.location.href = '/'
}
// if (window.history.length > 1) {
// window.history.back()
// } else {
// //
// window.location.href = '/'
// }
emit('back')
}
//

View File

@ -21,11 +21,10 @@
<div class="product-info">
<h1 class="product-title">{{ $t('checkout.customModel') }}</h1>
<div class="price-info">
<span class="price">{{ $t('checkout.from') }} ${{ (amountCents ).toFixed(2) }}</span>
<span class="price">{{ $t('checkout.from') }} {{unt=='USD'?'$':unt}} {{ (amountCents ).toFixed(2) }}</span>
</div>
</div>
</section>
<!-- Content Sections -->
<div class="content-sections">
<div class="main-content">
@ -178,10 +177,12 @@ import StripePaymentForm from '@/components/StripePaymentForm.vue'
import { Country, State } from 'country-state-city'
import { useI18n } from 'vue-i18n'
import { PayServer } from '@deotaland/utils'
import { requestUtils,clientApi } from '@deotaland/utils'
const payserver = new PayServer();
const props = defineProps({
modelData: { type: Object, default: () => ({}) },
show: { type: Boolean, default: false }
show: { type: Boolean, default: false },
series: { type: String, default: '' }
})
const emit = defineEmits(['close'])
const onClose = () => emit('close')
@ -198,7 +199,7 @@ const showPayingOverlay = ref(false)
//
const amountCents = computed(() => {
let base = 299 //
let base = price.value //
if (addons.value.gloss) base += 300
if (addons.value.base) base += 400
if (addons.value.matte) base += 200
@ -219,6 +220,22 @@ const isPayButtonDisabled = computed(() => {
ipName.value.trim()
)
})
const unt = ref('');
const price = ref(0);
const seriesId = ref('');
//
const getPrice = async () => {
const res = await requestUtils.common(clientApi.default.getProductList)
if(res.code === 0){
const data = res.data.list || []
const item = data.find(item => item.name === props.series)
if(item){
price.value = item.price?.amount || 0
unt.value = item.price?.currency || ''
seriesId.value = item.id || ''
}
}
}
const incQty = () => { qty.value = Math.min(qty.value+1, 99) }
const decQty = () => { qty.value = Math.max(qty.value-1, 1) }
const goShopify = () => {//
@ -265,6 +282,8 @@ const goShopify = () => {//用户点击购买
// Save shipping and contact information if checkbox is checked
saveLocal()
//
console.log('Order Parameters:', params)
params.product_id = seriesId.value
payserver.createPayorOrder(params);
}
@ -298,6 +317,7 @@ onMounted(() => {
if (c) Object.assign(contact.value, JSON.parse(c))
updateCountryOptions()
updateStates()
getPrice();
} catch (e) {}
})
watch(() => shipping.value.country, () => { updateStates() })

View File

@ -12,26 +12,26 @@
<div class="series-selector-content">
<!-- 系列选项列表 -->
<div class="series-list">
<!-- Done 系列 -->
<div
v-for="series in seriesList"
:key="series.id"
class="series-item"
@click="selectSeries('Done')"
@click="selectSeries(series)"
>
<div class="series-image">
<img src="@/assets/xh/Done.webp" alt="Done" />
<img
:src="series.product_info?.image || ''"
:alt="series.name"
@error="handleImageError"
/>
</div>
<div class="series-name">Done</div>
<div class="series-info">
<div class="series-name">{{ series.name }}</div>
<div class="series-description">{{ series.description }}</div>
<div class="series-price">
{{ formatPrice(series.price?.amount, series.price?.currency) }}
</div>
<!-- Oone 系列 -->
<div
class="series-item"
@click="selectSeries('Oone')"
>
<div class="series-image">
<img src="@/assets/xh/Oone.webp" alt="Oone" />
</div>
<div class="series-name">Oone</div>
</div>
</div>
</div>
@ -41,9 +41,9 @@
</template>
<script setup>
import { defineProps, defineEmits } from 'vue'
import { defineProps, defineEmits,onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import {clientApi,requestUtils} from '@deotaland/utils';
const { t } = useI18n()
const props = defineProps({
@ -62,6 +62,31 @@ const selectSeries = (series) => {
const onCancel = () => {
emit('cancel')
}
const formatPrice = (amount, currency = 'USD') => {
if (amount === null || amount === undefined) return ''
return `$${amount.toFixed(2)}`
}
const handleImageError = (event) => {
event.target.src = ''
event.target.parentElement.style.background = 'linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%)'
}
const seriesList = ref([])
//
const getSeriesList = async ()=>{
const res = await requestUtils.common(clientApi.default.getProductList)
if(res.code === 0){
const data = res.data || []
seriesList.value = data.list
}
}
const init = ()=>{
getSeriesList();
}
onMounted(()=>{
init()
})
</script>
<style scoped>
@ -228,25 +253,34 @@ const onCancel = () => {
.series-image {
width: 100%;
height: 140px;
height: 180px;
overflow: hidden;
border-radius: 12px;
margin-bottom: 16px;
position: relative;
background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%);
display: flex;
align-items: center;
justify-content: center;
}
.series-image img {
width: 100%;
height: 100%;
object-fit: cover;
object-fit: contain;
transition: transform 0.3s ease-out;
padding: 8px;
box-sizing: border-box;
}
.series-item:hover .series-image img {
transform: scale(1.05);
}
.series-info {
text-align: left;
}
.series-name {
font-size: 18px;
font-weight: 700;
@ -254,6 +288,7 @@ const onCancel = () => {
position: relative;
z-index: 1;
transition: color 0.2s ease-out;
margin-bottom: 8px;
}
.series-item:hover .series-name {
@ -264,6 +299,25 @@ const onCancel = () => {
color: #6B46C1;
}
.series-description {
font-size: 14px;
color: #6B7280;
margin-bottom: 12px;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.series-price {
font-size: 20px;
font-weight: 700;
color: #6B46C1;
position: relative;
z-index: 1;
}
.series-actions {
display: flex;
gap: 16px;
@ -320,11 +374,20 @@ const onCancel = () => {
}
.series-image {
height: 100px;
height: 140px;
}
.series-name {
font-size: 14px;
font-size: 16px;
}
.series-description {
font-size: 13px;
-webkit-line-clamp: 2;
}
.series-price {
font-size: 18px;
}
.series-actions {
@ -345,7 +408,11 @@ const onCancel = () => {
}
.series-image {
height: 120px;
height: 160px;
}
.series-description {
-webkit-line-clamp: 3;
}
}
</style>

View File

@ -415,6 +415,12 @@ export default {
transition: all 0.2s ease;
}
.mobile-menu-button svg {
width: 24px;
height: 24px;
flex-shrink: 0;
}
.mobile-menu-button:hover {
background: var(--hover-bg, #f3f4f6);
}

View File

@ -79,7 +79,8 @@ import {
DataAnalysis as AnalyticsIcon,
Folder as ProjectIcon,
Bell as NotificationIcon,
Key as ApiIcon
Key as ApiIcon,
ShoppingCartFull
} from '@element-plus/icons-vue'
//
@ -112,10 +113,11 @@ const remainingPoints = ref(1280)
const currentUser = computed(() => authStore.user)
const userRole = computed(() => ({
'1': 'free',
'2': 'creator',
}[authStore.user?.user_role || '1'])) // 'free'
'2':'creator'
}[authStore.user?.user_role || authStore.user?.userRole||'1'])) // 'free'
const sidebarClasses = computed(() => ({
'sidebar-mobile': isMobile.value
'sidebar-mobile': isMobile.value,
'show': isMobile.value && !props.collapsed
}))
// (6)
@ -208,7 +210,7 @@ onUnmounted(() => {
height: 100%;
background: var(--sidebar-bg, #ffffff);
border-right: 1px solid var(--border-color, #e5e7eb);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
/* transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); */
position: relative;
overflow: hidden;
z-index: 100;
@ -640,12 +642,14 @@ onUnmounted(() => {
position: fixed;
left: 0;
top: 0;
height: 100vh;
z-index: 1000;
transform: translateX(-100%);
box-shadow: 4px 0 24px rgba(107, 70, 193, 0.2);
transition: transform 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
/* transition: transform 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); */
backdrop-filter: blur(12px);
border-right: 1px solid rgba(107, 70, 193, 0.1);
padding-top: 64px;
}
.sidebar-mobile.show {
@ -683,28 +687,53 @@ onUnmounted(() => {
@media (max-width: 767px) {
.app-sidebar {
width: 100%;
max-width: 320px;
height: 100vh;
}
.sidebar-nav {
padding: 16px 8px;
height: calc(100vh - 64px);
overflow-y: auto;
}
.nav-item {
padding: 16px 20px;
min-height: 72px;
margin: 8px 16px;
/* padding: 12px 8px; */
min-height: 50px;
/* margin: 6px 4px; */
flex-direction: column;
justify-content: center;
align-items: center;
gap: 0;
max-height: 50px;
}
.nav-icon {
width: 32px;
height: 32px;
margin-bottom: 8px;
width: 24px;
height: 24px;
margin-bottom: 0;
flex-shrink: 0;
}
.nav-icon svg {
width: 22px;
height: 22px;
width: 20px;
height: 20px;
}
.nav-text {
font-size: 15px;
display: none;
}
.sidebar-footer {
padding: 12px 8px;
}
.user-avatar-container {
width: 32px;
height: 32px;
}
.role-badge {
display: none;
}
}

View File

@ -14,6 +14,7 @@
<!-- 侧边栏容器 -->
<aside
class="sidebar-container"
:class="{ 'sidebar-visible': sidebarVisible }"
>
<AppSidebar
:collapsed="!sidebarVisible"
@ -219,7 +220,7 @@ watch(() => window.location.pathname, () => {
height: 64px;
background: var(--header-bg, #ffffff);
border-bottom: 1px solid var(--border-color, #e5e7eb);
z-index: 9999;
z-index: 200;
position: sticky;
top: 0;
}
@ -241,14 +242,15 @@ watch(() => window.location.pathname, () => {
.sidebar-container {
position: fixed;
top: 0;
left: -120px;
transform: translateX(0);
/* left: -120px; */
transform: translateX(-100%);
z-index: 999;
transition: left 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
width: 60px;
}
.sidebar-container.sidebar-visible {
left: 0;
transform: translateX(0);
}
.main-content {

View File

@ -7,7 +7,7 @@ const List = () => import('../views/List.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')
const CreationWorkspace = () => import('../views/CreationWorkspace/CreationWorkspace.vue')
const ProjectGallery = () => import('../views/ProjectGallery.vue')
const OrderManagement = () => import('../views/OrderManagement/OrderManagement.vue')
const OrderDetail = () => import('../views/OrderDetail.vue')

View File

@ -144,8 +144,8 @@ import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Plus as PlusIcon, Folder as FolderIcon, Edit as EditIcon, View as ViewIcon, Close as CloseIcon, Upload as UploadIcon, Delete as DeleteIcon } from '@element-plus/icons-vue'
import SeriesSelector from '../components/SeriesSelector.vue'
import {Project} from './Project/index'
import SeriesSelector from '../../components/SeriesSelector.vue'
import {Project} from '../Project/index'
import { dateUtils } from '@deotaland/utils'
const { formatDate } = dateUtils
@ -239,17 +239,16 @@ const handleFileSelect = (event) => {
} catch (error) {
console.error('上传失败:', error)
}
event.target.value = ''
}
const openProject = (project) => {
console.log('打开项目:', project.title)
router.push(`/project/${project.id}/${project.tags[0]}`)
}
const createNewProject = (type) => {
router.push(`/project/new/${type}`)
const createNewProject = (series) => {
console.log(series,'seriesseriesseries');
router.push(`/project/new/${series.name}`)
}
//

View File

@ -0,0 +1,3 @@
export class CreationWorkspace {
}

View File

@ -21,7 +21,7 @@
type="primary"
size="large"
class="action-btn primary-btn create-btn-large"
@click="navigateToFeature({ path: '/creation-workspace' })">
@click="navigateToFeature({ path: `/project/new/Done` })">
{{ t('home.welcome.startCreating') }}
</el-button>
<div v-else class="guest-actions">

View File

@ -2,7 +2,7 @@
<div class="creative-zone" @contextmenu.prevent>
<!-- 顶部固定头部组件 -->
<div class="header-wrapper">
<HeaderComponent :total_score="total_score" :projectName="projectInfo.title" @updateProjectInfo="projectInfo = {...projectInfo, ...$event}" @openGuideModal="showGuideModal = true" />
<HeaderComponent @back="handleBack" :total_score="total_score" :projectName="projectInfo.title" @updateProjectInfo="projectInfo = {...projectInfo, ...$event}" @openGuideModal="showGuideModal = true" />
</div>
<!-- 导入的侧边栏组件 -->
<div class="sidebar-container">
@ -143,6 +143,7 @@
@close="showOrderProcessModal=false"
@acknowledge="handleBuyFromCustomize" />
<PurchaseModal
:series="series"
:show="showPurchaseModal"
:modelData="CustomizeModalData"
@close="showPurchaseModal=false" />
@ -164,15 +165,10 @@ import {useRoute,useRouter} from 'vue-router';
import {MeshyServer,GiminiServer,FileServer} from '@deotaland/utils';
import OrderProcessModal from '../../components/OrderProcessModal/index.vue';
import PurchaseModal from '../../components/PurchaseModal/index.vue';
import { ElMessage } from 'element-plus';
import {Project} from './index';
import {ModernHome} from '../ModernHome/index.js'
const fileServer = new FileServer();
const modernHome = new ModernHome();
const Limits = ref({
generateCount: 0,
modelCount: 0,
})
const router = useRouter();
const PluginProject = new Project();
//
@ -236,13 +232,15 @@ const getMaxZIndex = (type)=>{
getMaxZIndexNum.value = cards.value.reduce((max, card) => Math.max(max, card.zIndex || 0), 0)+1;
return getMaxZIndexNum.value;
}
const handleBack = ()=>{
router.replace(`/creation-workspace`);
}
const combinedPromptJson = ref({});
//
const getCombinedPrompt = async ()=>{
try {
const data = await PluginProject.getCombinedPrompt(series.value);
combinedPromptJson.value = data;
console.log(combinedPromptJson.value);
} catch (error) {
console.error(error);
}
@ -1029,6 +1027,7 @@ const init = ()=>{
const route = useRoute();
projectId.value = route.params.id;
series.value = route.params.series;
console.log(series.value);
if(projectId.value === 'new'){
createProject();
return

View File

@ -729,18 +729,20 @@ export class Project{
}, 1000);
}
//获取动态提示词
async getCombinedPrompt(series){//series:项目系列Done Oone
async getCombinedPrompt(series){//series:项目系列D1 O1
try {
return new Promise(async (resolve, reject) => {
const res = await requestUtils.common(clientApi.default.combined)
if(res.code === 0){
let data = res.data;
// 如果是Oone系列过滤掉title中包含"动物坐姿"或"人物姿势"的提示词
if (series === 'Oone') {
if (series === 'O1') {
data = data.filter(item => {
if (!item.title) return true;
return !item.title.includes('动物坐姿') && !item.title.includes('人物姿势');
return !item.title.includes('动物坐姿') && !item.title.includes('人物姿势')&& item.type != 'D1';
});
}else if(series === 'D1'){// 如果是Done系列过滤掉type为O1的提示词
data = data.filter(item => item.type !== 'O1');
}
// 初始化返回数据结构
const result = {
@ -756,11 +758,10 @@ export class Project{
// 按sortOrder排序
data.sort((a, b) => a.sortOrder - b.sortOrder);
// 处理person和general类型的数据
const personAndGeneral = data.filter(item => item.type === 'person' || item.type === 'general');
const personAndGeneral = data.filter(item => item.type === 'person' || item.type === 'general'|| item.type === 'O1');
personAndGeneral.forEach(item => {
// 拼接content
result.person.content += item.content;
// 处理图片
if (item.imageUrls) {
try {
@ -774,7 +775,7 @@ export class Project{
}
});
// 处理animal和general类型的数据
const animalAndGeneral = data.filter(item => item.type === 'animal' || item.type === 'general');
const animalAndGeneral = data.filter(item => item.type === 'animal' || item.type === 'general'|| item.type === 'O1');
animalAndGeneral.forEach(item => {
// 拼接content
result.animal.content += item.content;

View File

@ -278,7 +278,7 @@ const userController = new UserController()
//
const userData = ref({
role: authStore.user?.inviteCode === '2' ? 'creator' : 'free',
role: authStore.user?.userRole == '2' ? 'creator' : 'free',
//
avatar: authStore.user?.avatarUrl,
nickname: authStore.user?.nickname,
@ -405,13 +405,46 @@ const saveNickname = () => {
//
const copyInviteCode = (code) => {
const InviteLink = `🎉 限时福利!送你专属邀请码:${code},注册立得积分+解锁高级功能,快来一起体验吧!${window.location.origin}/#/login?inviteCode=${code}`
const fallbackCopyText = (text) => {
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
textArea.style.top = '-999999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
try {
const successful = document.execCommand('copy')
document.body.removeChild(textArea)
return successful
} catch (err) {
document.body.removeChild(textArea)
return false
}
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(InviteLink)
.then(() => {
ElMessage.success(t('userCenter.invitation.copySuccess'))
})
.catch(() => {
if (fallbackCopyText(InviteLink)) {
ElMessage.success(t('userCenter.invitation.copySuccess'))
} else {
ElMessage.error(t('userCenter.invitation.copyFailed'))
}
})
} else {
if (fallbackCopyText(InviteLink)) {
ElMessage.success(t('userCenter.invitation.copySuccess'))
} else {
ElMessage.error(t('userCenter.invitation.copyFailed'))
}
}
}
//

View File

@ -0,0 +1,231 @@
<template>
<div
class="frame-animation"
:style="containerStyle"
>
<img
v-if="currentFrame"
:src="currentFrame"
:alt="alt"
class="frame-image"
:style="imageStyle"
@load="onImageLoad"
@error="onImageError"
/>
<div v-if="loading" class="frame-loading">
<div class="loading-spinner"></div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
defineOptions({
name: 'FrameAnimation'
})
const props = defineProps({
//
frames: {
type: Array,
required: true,
default: () => [],
validator: (value) => Array.isArray(value)
},
//
duration: {
type: Number,
default: 100,
validator: (value) => value > 0
},
//
width: {
type: [String, Number],
default: 'auto'
},
//
height: {
type: [String, Number],
default: 'auto'
},
//
alt: {
type: String,
default: 'frame animation'
},
//
autoplay: {
type: Boolean,
default: true
},
//
loop: {
type: Boolean,
default: true
},
//
objectFit: {
type: String,
default: 'contain',
validator: (value) => ['fill', 'contain', 'cover', 'none', 'scale-down'].includes(value)
}
})
const emit = defineEmits(['frame-change', 'animation-end', 'error'])
const currentIndex = ref(0)
const loading = ref(true)
const timer = ref(null)
const isPlaying = ref(props.autoplay)
const currentFrame = computed(() => {
if (props.frames.length === 0) return null
return props.frames[currentIndex.value]
})
const containerStyle = computed(() => {
const width = typeof props.width === 'number' ? `${props.width}px` : props.width
const height = typeof props.height === 'number' ? `${props.height}px` : props.height
return {
width,
height
}
})
const imageStyle = computed(() => ({
objectFit: props.objectFit
}))
const play = () => {
if (timer.value) return
isPlaying.value = true
timer.value = setInterval(() => {
if (currentIndex.value < props.frames.length - 1) {
currentIndex.value++
} else if (props.loop) {
currentIndex.value = 0
} else {
pause()
emit('animation-end')
}
emit('frame-change', currentIndex.value, props.frames[currentIndex.value])
}, props.duration)
}
const pause = () => {
if (timer.value) {
clearInterval(timer.value)
timer.value = null
}
isPlaying.value = false
}
const reset = () => {
currentIndex.value = 0
loading.value = true
}
const onImageLoad = () => {
loading.value = false
}
const onImageError = (e) => {
loading.value = false
emit('error', e)
}
watch(() => props.frames, () => {
reset()
if (props.autoplay) {
play()
}
}, { deep: true })
watch(() => props.duration, () => {
if (isPlaying.value) {
pause()
play()
}
})
watch(() => props.autoplay, (newVal) => {
if (newVal) {
play()
} else {
pause()
}
})
onMounted(() => {
if (props.autoplay && props.frames.length > 0) {
play()
}
})
onBeforeUnmount(() => {
pause()
})
defineExpose({
play,
pause,
reset
})
</script>
<style scoped>
.frame-animation {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: var(--frame-animation-bg, transparent);
border-radius: 8px;
transition: all 0.2s ease;
}
.frame-image {
width: 100%;
height: 100%;
display: block;
transition: opacity 0.1s ease;
}
.frame-loading {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(107, 70, 193, 0.1);
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(107, 70, 193, 0.2);
border-top-color: #6B46C1;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
html.dark .frame-loading {
background: rgba(167, 139, 250, 0.1);
}
html.dark .loading-spinner {
border-color: rgba(167, 139, 250, 0.2);
border-top-color: #A78BFA;
}
</style>

View File

@ -1,12 +1,30 @@
<template>
<div class="sidebar-overlay" :class="{ 'sidebar-overlay-active': true }">
<div v-if="Show" class="sidebar-overlay" :class="{ 'sidebar-overlay-active': true }">
<FrameAnimation :frames="ipRunImgs" :duration="50" :loop="true" />
</div>
</template>
<script>
export default {
name: 'LoadingCom',
}
<script setup>
import { ref, onMounted,onUnmounted } from 'vue'
import FrameAnimation from '../FrameAnimation/index.vue'
var timer = null;
const ipRunImgs = Object.values(import.meta.glob('../../image/iprun/*.webp', { eager: true, as: 'url' })).sort((a, b) => {
const numA = parseInt(a.match(/(\d+)\.webp$/)?.[1] || 0)
const numB = parseInt(b.match(/(\d+)\.webp$/)?.[1] || 0)
return numA - numB
})
const Show = ref(false)
onMounted(()=>{
timer = setTimeout(() => {
Show.value = true
}, 500)
})
onUnmounted(()=>{
if(timer){
clearTimeout(timer)
timer = null
}
})
</script>
<style>
/* 侧边栏过渡动画蒙层 */
@ -37,7 +55,7 @@ export default {
}
/* 转圈加载动画 */
.sidebar-overlay::before {
/* .sidebar-overlay::before {
content: '';
width: 50px;
height: 50px;
@ -57,7 +75,7 @@ export default {
border-bottom: 2px solid rgba(113, 77, 199, 0.3);
border-radius: 50%;
animation: spin 1.5s linear infinite reverse;
}
} */
@keyframes spin {
0% { transform: rotate(0deg); }

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -0,0 +1,51 @@
// UI组件库入口文件
import LoadingCom from './components/LoadingCom/index.vue'
import FrameAnimation from './components/FrameAnimation/index.vue'
import './style.css'
// 创建带有Dt前缀的组件
const DtLoadingCom = {
...LoadingCom,
name: 'DtLoadingCom',
install(app) {
app.component('DtLoadingCom', DtLoadingCom)
}
}
const DtFrameAnimation = {
...FrameAnimation,
name: 'DtFrameAnimation',
install(app) {
app.component('DtFrameAnimation', DtFrameAnimation)
}
}
// 组件列表
const components = [
DtLoadingCom,
DtFrameAnimation
]
// 导出组件
export {
DtLoadingCom,
DtFrameAnimation
}
// 批量注册组件的函数
export function registerComponents(app) {
components.forEach(component => {
if (component.install) {
app.use(component)
} else {
app.component(component.name, component)
}
})
}
// 导出安装函数用于Vue插件
export default {
install(app) {
registerComponents(app)
}
}

View File

@ -8,6 +8,7 @@ import permission from './permission.js';
import promptManagement from './promptManagement.js';
import pointsManagement from './pointsManagement.js';
import configuration from './configuration.js';
import seriespriceConfig from './seriespriceConfig.js';
export default {
...login,
...order,
@ -19,4 +20,5 @@ export default {
...promptManagement,
...pointsManagement,
...configuration,
...seriespriceConfig,
};

View File

@ -0,0 +1,9 @@
const promptManagement = {
createProduct:{url:'/api-core/admin/product/product/create',method:'POST',isLoading:true},//创建产品及价格
getProductDetail:{url:'/api-core/admin/product/product/get',method:'GET',isLoading:true},//获取产品详情
getProductList:{url:'/api-core/admin/product/product/list',method:'POST',isLoading:true},//获取产品列表
updateProduct:{url:'/api-core/admin/product/product/update',method:'POST',isLoading:true},//更新产品信息
updateProductPrice:{url:'/api-core/admin/product/product/update-price',method:'POST',isLoading:true},//更新产品价格
deleteProduct:{url:'/api-core/admin/product/product/delete',method:'POST',isLoading:true},//删除产品
}
export default promptManagement;

View File

@ -1,6 +1,7 @@
const pay = {
createPaymentintention:{url:'/createPaymentintention',method:'POST'},// 创建支付意图
createCheckoutSession:{url:'/createCheckoutSession',method:'POST'},// 创建会话支付(购物车)
createPayorOrder:{url:'/api-core/front/stripe/create-and-checkout',method:'POST'}//创建订单并且返回支付链接
createPayorOrder:{url:'/api-core/front/stripe/create-and-checkout',method:'POST'},//根据产品ID创建订单并跳转支付
getProductList:{url:'/api-core/front/stripe/product/list',method:'POST'}//获取产品列表
}
export default pay;

View File

@ -151,6 +151,7 @@ export class PayServer {
// await this.init();
return new Promise(async (resolve, reject) => {
let pamras = {
product_id: orderInfo.product_id,
"methods": [
"card"
],

View File

@ -17,7 +17,7 @@ const getEnvBaseURL = () => {
// }
var baseURL = '';
const hostname = window.location.hostname;
if(hostname=='localhost'||hostname=='127.0.0.1'){
if(hostname=='localhost'){
baseURL = '/api'
}else if(hostname.indexOf('deotaland.ai')>-1){
baseURL = 'https://api.deotaland.ai'

File diff suppressed because it is too large Load Diff