初始化
CI/CD / build (push) Failing after 6s Details

This commit is contained in:
13121765685 2026-01-09 11:49:16 +08:00
parent e6dae59650
commit 4cc9488499
28 changed files with 5526 additions and 646 deletions

View File

@ -0,0 +1,55 @@
name: CI/CD
on:
push:
branches:
- main
- dev
jobs:
build:
runs-on: ubuntu-latest
# container: docker.gitea.com/runner-images:ubuntu-latest
container:
image: docker.gitea.com/runner-images:ubuntu-latest
options: --add-host=my_gitea:host-gateway
steps:
# - name: Add host entry for Gitea
# run: |
# echo "${{ vars.GITEAHOST }} my_gitea" | tee -a /etc/hosts
- name: Test access to Gitea
run: |
curl -v http://host.docker.internal:3000
curl -v http://my_gitea:3000
- name: Checkout code
uses: actions/checkout@v3
- name: Build Docker image
run: docker build -t vue-app .
- name: Create container and extract dist
run: |
docker rm --force vue-container || true;
rm -rf /docker/front/apps/*
mkdir -p /docker/front/apps
# 创建容器但不运行
docker run --name vue-container vue-app
# -v 卷挂载是直接映射整个目录,而 docker cp 会保留源路径的目录结构。
docker cp vue-container:build/apps/. /docker/front/apps/
chmod -R 777 /docker/front/apps
# -v 卷挂载是直接映射整个目录,而 docker cp 会保留源路径的目录结构。
docker cp /docker/front/apps/frontend/dist/. my_caddy:/docker/front/www/
docker cp /docker/front/apps/frontendDesigner/dist/. my_caddy:/docker/front/admin/

View File

@ -0,0 +1,49 @@
## 实施计划
### 1. 分析当前代码结构
- **PhoneLogin.vue**:手机号登录页面主组件,包含登录卡片和子组件调用
- **PhoneLoginForm.vue**:登录表单组件,包含登录方式切换和表单字段
- **login.js**包含登录相关的API调用方法如`sendPhoneCode`和`phoneLoginCode`
### 2. 修改PhoneLoginForm.vue组件
#### 模板部分修改
- **更新登录模式切换**:将现有的三种模式(密码登录、注册、重置密码)改为仅保留登录相关的模式
- **添加密码/验证码登录切换**:在登录模式下,添加一个新的切换开关,允许用户在密码登录和验证码登录之间切换
- **调整表单字段显示**:根据选择的登录方式显示相应的表单字段
#### 脚本部分修改
- **添加登录方式状态**:添加`loginMethod` ref用于跟踪用户选择的是密码登录还是验证码登录
- **设置默认登录方式**:将默认登录方式设置为验证码登录
- **更新表单验证逻辑**:根据选择的登录方式调整验证规则
- **更新发送验证码逻辑**:发送登录验证码时传递`purpose: 'login'`
- **更新表单提交逻辑**:验证码登录时调用`phoneLoginCode`函数
### 3. 修改PhoneLogin.vue组件
- **更新handleLogin方法**:确保根据登录方式调用正确的登录函数
### 4. 实施细节
#### PhoneLoginForm.vue修改点
- **第155行**:将默认`loginMode`改为'code'
- **第38-70行**:添加密码/验证码登录切换按钮
- **第256-285行**:更新`handleSendCode`方法确保发送验证码时传递正确的purpose
- **第288-316行**:更新`handleSubmit`方法,处理验证码登录逻辑
#### PhoneLogin.vue修改点
- **第79-81行**:更新`handleLogin`方法,根据登录方式调用正确的登录函数
### 5. 预期行为
- 用户默认看到验证码登录界面
- 用户可以在密码登录和验证码登录之间切换
- 发送登录验证码时,传递`purpose: 'login'`
- 验证码登录调用`phoneLoginCode`函数
- 密码登录保持原有功能不变
### 6. 需要修改的文件
- `d:\work\Aiproject\DeotalandAi\apps\frontend\src\components\auth\PhoneLoginForm.vue`
- `d:\work\Aiproject\DeotalandAi\apps\frontend\src\views\Login\PhoneLogin.vue`

View File

@ -0,0 +1,386 @@
# 实现竖屏移动端卡片展示组件
## 组件设计目标
* 适配竖屏移动端的卡片展示组件
* 包含图片展示、加载蒙层、预览功能
* 底部功能按钮(修改、编辑、场景图、下载、删除)
* 向父组件抛出事件
## 实现步骤
### 1. 实现 `shu.vue` 组件
* 设计组件结构:主容器、图片区域、功能按钮区域
* 实现响应式布局,适配竖屏移动端
* 使用 Scoped CSS + CSS 变量实现样式隔离与主题定制
### 2. 图片展示与加载效果
* 使用 `el-image` 组件实现图片展示
* 添加加载状态管理,显示加载蒙层
* 实现图片加载完成后的过渡效果
### 3. 图片预览功能
* 集成图片预览功能,适配 H5 移动端
* 点击图片触发预览,支持手势操作
### 4. 底部功能按钮
* 实现修改、编辑、场景图、下载、删除按钮
* 每个按钮向父组件抛出对应的事件
* 按钮样式设计符合 UI/UX 要求
### 5. 与父组件交互
* 在 `CreateProjectShu.vue` 中引入并使用 `shu.vue` 组件
* 实现事件处理逻辑,接收并处理子组件抛出的事件
## 代码实现
### `shu.vue` 组件
```vue
<template>
<div class="shu-card-container">
<!-- 图片展示区域 -->
<div class="image-wrapper" @click="handlePreview">
<el-image
:src="props.imageUrl"
:fit="'cover'"
:preview-src-list="[props.imageUrl]"
@load="handleImageLoad"
@error="handleImageError"
>
<!-- 加载蒙层 -->
<template #loading>
<div class="loading-mask">
<div class="loading-spinner"></div>
<div class="loading-text">图片加载中...</div>
</div>
</template>
</el-image>
</div>
<!-- 功能按钮区域 -->
<div class="action-buttons">
<button class="action-btn" @click="handleModify" title="修改">
<el-icon class="btn-icon"><ChatDotRound /></el-icon>
<span class="btn-text">修改</span>
</button>
<button class="action-btn" @click="handleEdit" title="编辑">
<el-icon class="btn-icon"><EditPen /></el-icon>
<span class="btn-text">编辑</span>
</button>
<button class="action-btn" @click="handleScene" title="场景图">
<el-icon class="btn-icon"><Grid /></el-icon>
<span class="btn-text">场景图</span>
</button>
<button class="action-btn" @click="handleDownload" title="下载">
<el-icon class="btn-icon"><Download /></el-icon>
<span class="btn-text">下载</span>
</button>
<button class="action-btn delete-btn" @click="handleDelete" title="删除">
<el-icon class="btn-icon"><Delete /></el-icon>
<span class="btn-text">删除</span>
</button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { ElImage, ElIcon } from 'element-plus';
import { ChatDotRound, EditPen, Grid, Download, Delete } from '@element-plus/icons-vue';
// 定义组件属性
const props = defineProps({
// 图片URL
imageUrl: {
type: String,
default: ''
},
// 卡片数据
cardData: {
type: Object,
default: () => ({})
}
});
// 定义事件
const emit = defineEmits(['preview', 'modify', 'edit', 'scene', 'download', 'delete']);
// 图片加载状态
const isLoading = ref(true);
// 处理图片加载完成
const handleImageLoad = () => {
isLoading.value = false;
};
// 处理图片加载错误
const handleImageError = () => {
isLoading.value = false;
};
// 处理图片预览
const handlePreview = () => {
emit('preview', props.imageUrl);
};
// 处理修改按钮点击
const handleModify = (e) => {
e.stopPropagation();
emit('modify', props.cardData);
};
// 处理编辑按钮点击
const handleEdit = (e) => {
e.stopPropagation();
emit('edit', props.cardData);
};
// 处理场景图按钮点击
const handleScene = (e) => {
e.stopPropagation();
emit('scene', props.cardData);
};
// 处理下载按钮点击
const handleDownload = (e) => {
e.stopPropagation();
emit('download', props.imageUrl);
};
// 处理删除按钮点击
const handleDelete = (e) => {
e.stopPropagation();
emit('delete', props.cardData);
};
</script>
<style scoped>
/* 组件样式 */
.shu-card-container {
display: flex;
flex-direction: column;
width: 100%;
max-width: 400px;
margin: 0 auto;
background: #ffffff;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
/* 图片区域样式 */
.image-wrapper {
position: relative;
width: 100%;
padding-bottom: 150%; /* 2:3 竖屏比例 */
overflow: hidden;
cursor: pointer;
}
.image-wrapper :deep(.el-image) {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
transition: transform 0.3s ease;
}
.image-wrapper :deep(.el-image__inner) {
object-fit: cover;
transition: transform 0.3s ease;
}
.image-wrapper:hover :deep(.el-image__inner) {
transform: scale(1.02);
}
/* 加载蒙层样式 */
.loading-mask {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.9) 0%, rgba(248, 249, 250, 0.95) 100%);
transition: opacity 0.3s ease;
z-index: 10;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(107, 70, 193, 0.2);
border-top: 4px solid #6B46C1;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 12px;
}
.loading-text {
font-size: 14px;
color: #6B46C1;
font-weight: 500;
}
/* 功能按钮区域样式 */
.action-buttons {
display: flex;
flex-wrap: wrap;
padding: 12px;
background: #f8f9fa;
border-top: 1px solid #e9ecef;
}
.action-btn {
flex: 1;
min-width: calc(20% - 8px);
margin: 4px;
padding: 12px 8px;
border: none;
border-radius: 8px;
background: linear-gradient(135deg, #6B46C1 0%, #A78BFA 100%);
color: #ffffff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
}
.action-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(107, 70, 193, 0.3);
}
.action-btn:active {
transform: translateY(0);
}
.action-btn.delete-btn {
background: linear-gradient(135deg, #EF4444 0%, #F87171 100%);
}
.action-btn.delete-btn:hover {
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
}
.btn-icon {
font-size: 20px;
}
.btn-text {
font-size: 12px;
font-weight: 500;
}
/* 响应式设计 */
@media (max-width: 768px) {
.shu-card-container {
margin: 8px;
border-radius: 8px;
}
.action-buttons {
padding: 8px;
}
.action-btn {
padding: 10px 6px;
min-width: calc(20% - 6px);
margin: 3px;
}
.btn-icon {
font-size: 18px;
}
.btn-text {
font-size: 11px;
}
}
@media (max-width: 480px) {
.shu-card-container {
margin: 4px;
}
.action-btn {
padding: 8px 4px;
}
.btn-icon {
font-size: 16px;
}
.btn-text {
font-size: 10px;
}
}
/* 动画效果 */
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
```
### 2. 集成到父组件 `CreateProjectShu.vue`
* 在父组件中引入 `shu.vue` 组件
* 实现事件处理逻辑,接收子组件抛出的事件
* 在模板中使用新组件展示卡片
## 设计规范
* 主色调:深紫色(#6B46C1、浅紫色#A78BFA
* 辅助色:深灰色(#1F2937、浅灰色#F3F4F6
* 按钮样式圆角设计8px半径、微妙阴影、悬停效果
* 字体排版Inter字体系列、16px基础大小、响应式缩放
* 布局风格基于卡片的设计、统一间距8px网格系统
* 动画效果平滑过渡200ms缓入缓出、加载蒙层
## 测试与验证
* 确保组件在竖屏移动端正常显示
* 测试图片加载、预览功能
* 验证按钮事件正确抛出
* 检查响应式布局适配情况

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="https://draft-user.s3.us-east-2.amazonaws.com/images/2f8e057e-a677-44cd-b709-38245bdec423.svg" /> <link rel="icon" href="https://draft-user.s3.us-east-2.amazonaws.com/images/2f8e057e-a677-44cd-b709-38245bdec423.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, minimum-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">

View File

@ -0,0 +1,834 @@
<template>
<div v-if="show" class="add-modal-overlay" @click="handleOverlayClick">
<div class="add-modal-container" @click.stop>
<div class="add-header">
<h2 class="add-title">{{ $t('addModal.title') }}</h2>
<button class="close-button" @click="closeModal">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="add-content">
<!-- IP类型选择 -->
<div class="form-section">
<div class="section-label">{{ $t('addModal.ipType') }}</div>
<div class="ip-type-grid">
<div
class="ip-type-card"
:class="{ active: ipType === 1 }"
@click="handleIpTypeSelect(1)"
>
<div class="ip-type-label">{{ $t('addModal.character') }}</div>
</div>
<div
class="ip-type-card"
:class="{ active: ipType === 2 }"
@click="handleIpTypeSelect(2)"
>
<div class="ip-type-label">{{ $t('addModal.animal') }}</div>
</div>
</div>
</div>
<!-- 是否需要挂钩 - 仅当series为E1时显示 -->
<div class="form-section" v-if="series === 'E1'">
<div class="section-label">{{ $t('iPandCardLeft.needHook') }}</div>
<div class="hook-selection">
<div
class="hook-option"
:class="{ active: needHook === true }"
@click="handleHookSelect(true)"
>
<div class="hook-label">{{ $t('common.yes') }}</div>
</div>
<div
class="hook-option"
:class="{ active: needHook === false }"
@click="handleHookSelect(false)"
>
<div class="hook-label">{{ $t('common.no') }}</div>
</div>
</div>
</div>
<!-- 文本提示输入 -->
<div class="form-section">
<div class="section-label">{{ $t('addModal.textPrompt') }}</div>
<div class="prompt-input-container">
<textarea
class="prompt-input"
:placeholder="$t('addModal.placeholder')"
v-model="formData.prompt"
rows="4"
:disabled="isOptimizing"
></textarea>
<div v-if="isOptimizing" class="scan-overlay">
<div class="scan-line"></div>
</div>
<button
class="optimizer-btn"
@click="handleOptimizePrompt"
:disabled="isOptimizing"
>
<span class="btn-icon">🪄</span>
</button>
</div>
</div>
<!-- 参考图片上传 -->
<div class="form-section">
<div class="section-label">{{ $t('addModal.referenceImage') }}</div>
<div
class="image-upload-area"
@click="triggerFileUpload"
@dragover.prevent="handleDragOver"
@dragenter.prevent="handleDragEnter"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
:class="{ 'drag-over': isDragOver, 'uploading': isUploading }"
>
<!-- 图片预览 -->
<div v-if="formData.previewImage && !isUploading" class="image-preview">
<img :src="formData.previewImage" alt="Selected image">
<button class="remove-image" @click.stop="removeImage">×</button>
</div>
<!-- 上传进度 -->
<div v-else-if="isUploading" class="upload-progress">
<div class="upload-spinner">
<div class="spinner-ring"></div>
</div>
<div class="upload-progress-text">{{ $t('addModal.uploading') }}</div>
</div>
<!-- 上传提示 -->
<div v-else class="upload-prompt">
<div class="upload-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
</div>
<span class="upload-text">{{ $t('addModal.uploadOrSelect') }}</span>
</div>
<!-- 隐藏的文件输入 -->
<input
ref="fileInput"
type="file"
accept="image/*"
@change="handleFileChange"
class="hidden-file-input"
>
</div>
</div>
</div>
<!-- 生成按钮 -->
<div class="add-footer">
<button
class="generate-btn"
@click="handleGenerate"
:disabled="isGenerateDisabled"
>
{{ $t('addModal.generate') }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onBeforeUnmount } from 'vue';
import { useI18n } from 'vue-i18n';
import { FileServer, GiminiServer } from '@deotaland/utils';
const { t, locale } = useI18n();
const fileServer = new FileServer();
const giminiServer = new GiminiServer();
const props = defineProps({
show: { type: Boolean, default: false },
series: {
type: String,
default: 'E1'
},
});
const emit = defineEmits(['close', 'generate', 'handleHookSelected']);
const formData = ref({
prompt: '',
previewImage: ''
});
const ipType = ref(1);
const isDragOver = ref(false);
const isUploading = ref(false);
const isOptimizing = ref(false);
const fileInput = ref(null);
const needHook = ref(true);
const isGenerateDisabled = computed(() => {
return !formData.value.prompt.trim();
});
const handleIpTypeSelect = (type) => {
ipType.value = type;
};
const handleHookSelect = (value) => {
needHook.value = value;
emit('handleHookSelected', value);
};
const triggerFileUpload = () => {
fileInput.value?.click();
};
const handleFileChange = async (event) => {
const file = event.target.files[0];
if (file) {
await uploadImage(file);
}
};
const handleDragOver = () => {
isDragOver.value = true;
};
const handleDragEnter = () => {
isDragOver.value = true;
};
const handleDragLeave = () => {
isDragOver.value = false;
};
const handleDrop = async (event) => {
isDragOver.value = false;
const file = event.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) {
await uploadImage(file);
}
};
const uploadImage = async (file) => {
try {
isUploading.value = true;
const reader = new FileReader();
reader.onload = async (e) => {
formData.value.previewImage = e.target.result;
const url = await fileServer.uploadFile(e.target.result);
formData.value.previewImage = url;
isUploading.value = false;
};
reader.readAsDataURL(file);
} catch (error) {
console.error('上传图片失败:', error);
isUploading.value = false;
}
};
const removeImage = () => {
formData.value.previewImage = '';
if (fileInput.value) {
fileInput.value.value = '';
}
};
const handleOptimizePrompt = async () => {
isOptimizing.value = true;
const prompt = await giminiServer.handleOptimizePrompt(formData.value.prompt, {
"type": "OBJECT",
"properties": {
"name": {
"type": "STRING",
"description": "角色的名称"
},
"gender": {
"type": "STRING",
"description": "角色的性别"
},
"type": {
"type": "STRING",
"description": `角色的类型,${ipType.value == 1 ? `人物` : `动物`}`
},
"appearance": {
"type": "STRING",
"description": "角色的外观描述如果描述到了颜色相关的统一用浅米色或浅卡其色不少于50字数"
}
},
"required": [
"name",
"gender",
"appearance"
]
}, locale.value, formData.value.previewImage);
isOptimizing.value = false;
if (prompt) {
const nameKey = t('addModal.optimizedPrompt.name');
const genderKey = t('addModal.optimizedPrompt.gender');
const appearanceKey = t('addModal.optimizedPrompt.appearance');
formData.value.prompt = `${nameKey}: ${prompt.name}\n${genderKey}: ${prompt.gender}\n${appearanceKey}: ${prompt.appearance}`;
}
};
const handleGenerate = () => {
if (!formData.value.prompt.trim()) {
return;
}
emit('generate', {
ipType: ipType.value,
prompt: formData.value.prompt,
inspirationImage: formData.value.previewImage,
needHook: needHook.value
});
closeModal();
};
const handleOverlayClick = () => {
closeModal();
};
const closeModal = () => {
emit('close');
};
watch(() => props.show, (newVal) => {
document.body.style.overflow = newVal ? 'hidden' : '';
});
onBeforeUnmount(() => {
document.body.style.overflow = '';
});
</script>
<style scoped>
.add-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 9999;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.add-modal-container {
width: 100%;
max-width: 480px;
max-height: 90vh;
background: var(--bg-color, #F3F4F6);
border-radius: 16px 16px 0 0;
display: flex;
flex-direction: column;
animation: slideUp 0.3s ease;
overflow: hidden;
}
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.add-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border-color, #E5E7EB);
display: flex;
align-items: center;
justify-content: space-between;
}
.add-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--text-color, #1F2937);
}
.close-button {
width: 32px;
height: 32px;
border: none;
background: transparent;
color: var(--text-color, #1F2937);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.2s;
flex-shrink: 0;
background-color: var(--border-color, #E5E7EB);
}
.add-content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.form-section {
margin-bottom: 20px;
}
.section-label {
font-size: 14px;
font-weight: 500;
color: var(--text-color, #1F2937);
margin-bottom: 8px;
display: block;
}
.ip-type-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.ip-type-card {
background: var(--card-bg, #FFFFFF);
border: 2px solid var(--border-color, #E5E7EB);
border-radius: 12px;
padding: 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
cursor: pointer;
transition: all 0.2s;
}
.ip-type-card:hover {
border-color: var(--primary-color, #6B46C1);
transform: translateY(-2px);
}
.ip-type-card.active {
border-color: var(--primary-color, #6B46C1);
background: var(--primary-light, rgba(107, 70, 193, 0.1));
}
.ip-type-label {
font-size: 14px;
font-weight: 500;
color: var(--text-color, #1F2937);
}
.hook-selection {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.hook-option {
background: var(--card-bg, #FFFFFF);
border: 2px solid var(--border-color, #E5E7EB);
border-radius: 12px;
padding: 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
cursor: pointer;
transition: all 0.2s;
}
.hook-option:hover {
border-color: var(--primary-color, #6B46C1);
transform: translateY(-2px);
}
.hook-option.active {
border-color: var(--primary-color, #6B46C1);
background: var(--primary-light, rgba(107, 70, 193, 0.1));
}
.hook-label {
font-size: 14px;
font-weight: 500;
color: var(--text-color, #1F2937);
}
.prompt-input-container {
width: 100%;
position: relative;
}
.prompt-input {
width: 100%;
padding: 12px;
border: 1px solid var(--border-color, #E5E7EB);
border-radius: 8px;
background: var(--card-bg, #FFFFFF);
color: var(--text-color, #1F2937);
font-size: 14px;
resize: vertical;
min-height: 80px;
font-family: inherit;
}
.prompt-input::placeholder {
color: var(--text-secondary, #6B7280);
}
.prompt-input:focus {
outline: none;
border-color: var(--primary-color, #6B46C1);
}
.prompt-input:disabled {
border-color: rgba(99, 102, 241, 0.5);
box-shadow: 0 0 0 1px rgba(99, 102, 241, 0.3);
}
/* 扫描线动画效果 */
.scan-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.1);
border-radius: 8px;
overflow: hidden;
z-index: 5;
pointer-events: none;
}
.scan-line {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg,
transparent 0%,
rgba(99, 102, 241, 0.8) 20%,
rgba(99, 102, 241, 1) 50%,
rgba(99, 102, 241, 0.8) 80%,
transparent 100%);
animation: scan 1.5s ease-in-out infinite;
}
@keyframes scan {
0% {
top: 0;
opacity: 0;
}
50% {
opacity: 1;
}
100% {
top: 100%;
opacity: 0;
}
}
.optimizer-btn {
position: absolute;
bottom: 10px;
right: 8px;
padding: 6px 12px;
background-color: #6366f1;
border: none;
border-radius: 6px;
color: white;
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: background-color 0.2s;
z-index: 10;
}
.optimizer-btn:hover:not(:disabled) {
background-color: #4f46e5;
}
.optimizer-btn:disabled {
background-color: #9ca3af;
cursor: not-allowed;
}
.image-upload-area {
width: 100%;
min-height: 150px;
border: 2px dashed var(--border-color, #E5E7EB);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
background: var(--card-bg, #FFFFFF);
position: relative;
overflow: hidden;
}
.image-upload-area:hover {
border-color: var(--primary-color, #6B46C1);
}
.image-upload-area.drag-over {
border-color: var(--primary-color, #6B46C1);
background: var(--primary-light, rgba(107, 70, 193, 0.1));
}
.image-upload-area.uploading {
cursor: not-allowed;
}
.image-preview {
width: 100%;
height: 100%;
position: relative;
}
.image-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.remove-image {
position: absolute;
top: 8px;
right: 8px;
width: 28px;
height: 28px;
border: none;
background: rgba(0, 0, 0, 0.6);
color: white;
border-radius: 50%;
font-size: 20px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
}
.remove-image:hover {
background: rgba(0, 0, 0, 0.8);
}
.upload-progress {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.upload-spinner {
width: 40px;
height: 40px;
position: relative;
}
.spinner-ring {
position: absolute;
width: 100%;
height: 100%;
border: 3px solid var(--border-color, #E5E7EB);
border-top-color: var(--primary-color, #6B46C1);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.upload-progress-text {
font-size: 14px;
color: var(--text-secondary, #6B7280);
}
.upload-prompt {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
text-align: center;
}
.upload-icon {
color: var(--text-secondary, #6B7280);
}
.upload-text {
font-size: 14px;
color: var(--text-secondary, #6B7280);
}
.hidden-file-input {
display: none;
}
.add-footer {
padding: 16px;
border-top: 1px solid var(--border-color, #E5E7EB);
background: var(--card-bg, #FFFFFF);
}
.generate-btn {
width: 100%;
padding: 14px;
background: var(--primary-color, #6B46C1);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.generate-btn:hover:not(:disabled) {
background: var(--primary-hover, #5B3A9E);
transform: translateY(-1px);
}
.generate-btn:active:not(:disabled) {
transform: translateY(0);
}
.generate-btn:disabled {
background: var(--border-color, #E5E7EB);
cursor: not-allowed;
opacity: 0.6;
}
@media (min-width: 768px) {
.add-modal-overlay {
align-items: center;
}
.add-modal-container {
border-radius: 16px;
max-height: 80vh;
}
}
@media (max-width: 767px) {
.hook-option {
padding: 12px;
}
.hook-label {
font-size: 13px;
}
.ip-type-card {
padding: 12px;
}
.ip-type-label {
font-size: 13px;
}
}
html.dark .add-modal-container {
background: var(--bg-color-dark, #1F2937);
}
html.dark .add-title {
color: var(--text-color-dark, #F3F4F6);
}
html.dark .section-label {
color: var(--text-color-dark, #F3F4F6);
}
html.dark .ip-type-card {
background: var(--card-bg-dark, #374151);
border-color: var(--border-color-dark, #4B5563);
}
html.dark .ip-type-card.active {
background: rgba(107, 70, 193, 0.3);
border-color: var(--primary-color, #6B46C1);
box-shadow: 0 0 0 2px rgba(107, 70, 193, 0.2), 0 4px 12px rgba(107, 70, 193, 0.15);
}
html.dark .ip-type-label {
color: var(--text-color-dark, #F3F4F6);
}
html.dark .ip-type-card.active .ip-type-label {
color: #A78BFA;
font-weight: 600;
}
html.dark .hook-option {
background: var(--card-bg-dark, #374151);
border-color: var(--border-color-dark, #4B5563);
}
html.dark .hook-option.active {
background: rgba(107, 70, 193, 0.3);
border-color: var(--primary-color, #6B46C1);
box-shadow: 0 0 0 2px rgba(107, 70, 193, 0.2), 0 4px 12px rgba(107, 70, 193, 0.15);
}
html.dark .hook-label {
color: var(--text-color-dark, #F3F4F6);
}
html.dark .hook-option.active .hook-label {
color: #A78BFA;
font-weight: 600;
}
html.dark .prompt-input {
background: var(--card-bg-dark, #374151);
border-color: var(--border-color-dark, #4B5563);
color: var(--text-color-dark, #F3F4F6);
}
html.dark .prompt-input::placeholder {
color: var(--text-secondary-dark, #9CA3AF);
}
html.dark .image-upload-area {
background: var(--card-bg-dark, #374151);
border-color: var(--border-color-dark, #4B5563);
}
html.dark .upload-text {
color: var(--text-secondary-dark, #9CA3AF);
}
html.dark .upload-icon {
color: var(--text-secondary-dark, #9CA3AF);
}
html.dark .upload-progress-text {
color: var(--text-secondary-dark, #9CA3AF);
}
html.dark .add-footer {
background: var(--card-bg-dark, #374151);
border-color: var(--border-color-dark, #4B5563);
}
</style>

View File

@ -3,7 +3,7 @@
<!-- 图片展示区域 --> <!-- 图片展示区域 -->
<div class="image-wrapper" @click="handlePreview"> <div class="image-wrapper" @click="handlePreview">
<el-image <el-image
:src="props.imageUrl" :src="formData.internalImageUrl"
:fit="'cover'" :fit="'cover'"
:preview-src-list="[props.imageUrl]" :preview-src-list="[props.imageUrl]"
@load="handleImageLoad" @load="handleImageLoad"
@ -12,42 +12,97 @@
<!-- 加载蒙层 --> <!-- 加载蒙层 -->
<template #loading> <template #loading>
<div class="loading-mask"> <div class="loading-mask">
<div class="loading-spinner"></div> <div class="light-scan-container">
<div class="loading-text">图片加载中...</div> <div class="light-beam"></div>
<div class="light-particles">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
<div class="loading-icon">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" stroke="url(#gradient)" stroke-width="2"/>
<path d="M12 6V12L16 14" stroke="url(#gradient)" stroke-width="2" stroke-linecap="round"/>
<defs>
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#6B46C1"/>
<stop offset="100%" stop-color="#A78BFA"/>
</linearGradient>
</defs>
</svg>
</div>
</div>
<div class="loading-text">{{ t('loading') }}</div>
</div>
</template>
<template #error>
<div class="loading-mask">
<div class="loading-spinner-wrapper">
<div class="loading-spinner"></div>
<div class="loading-spinner-inner"></div>
</div>
<div class="loading-text">{{ t('loading') }}</div>
<div class="loading-dots">
<span></span>
<span></span>
<span></span>
</div>
</div> </div>
</template> </template>
</el-image> </el-image>
<!-- 购买按钮 -->
<button class="purchase-btn" @click.stop="handleCustomizeToHome" :title="t('modelModal.purchase')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="9" cy="21" r="1"/>
<circle cx="20" cy="21" r="1"/>
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>
</svg>
</button>
</div> </div>
<!-- 功能按钮区域 --> <!-- 功能按钮区域 -->
<div class="action-buttons"> <div class="action-buttons">
<button class="action-btn" @click="handleModify" title="修改"> <button class="action-btn" @click="handleModify" :title="t('modelModal.modify')">
<el-icon class="btn-icon"><ChatDotRound /></el-icon> <el-icon class="btn-icon"><ChatDotRound /></el-icon>
<span class="btn-text">修改</span> <span class="btn-text">{{ t('modelModal.modify') }}</span>
</button> </button>
<button class="action-btn" @click="handlePartialEdit" title="局部"> <button class="action-btn" @click="handlePartialEdit" :title="t('modelModal.edit')">
<el-icon class="btn-icon"><Brush /></el-icon> <el-icon class="btn-icon"><Brush /></el-icon>
<span class="btn-text">局部</span> <span class="btn-text">{{ t('modelModal.edit') }}</span>
</button> </button>
<button class="action-btn" @click="handleDownload" title="下载"> <button class="action-btn" @click="handleDownload" :title="t('modelModal.download')">
<el-icon class="btn-icon"><Download /></el-icon> <el-icon class="btn-icon"><Download /></el-icon>
<span class="btn-text">下载</span> <span class="btn-text">{{ t('modelModal.download') }}</span>
</button> </button>
<button class="action-btn delete-btn" @click="handleDelete" title="删除"> <button class="action-btn delete-btn" @click="handleDelete" :title="t('modelModal.delete')">
<el-icon class="btn-icon"><Delete /></el-icon> <el-icon class="btn-icon"><Delete /></el-icon>
<span class="btn-text">删除</span> <span class="btn-text">{{ t('modelModal.delete') }}</span>
</button> </button>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue'; import { ref,onMounted } from 'vue';
import { ElImage, ElIcon } from 'element-plus'; import { ElImage, ElIcon } from 'element-plus';
import { ChatDotRound, Brush, Download, Delete } from '@element-plus/icons-vue'; import { ChatDotRound, Brush, Download, Delete } from '@element-plus/icons-vue';
import { useI18n } from 'vue-i18n';
import { GiminiServer } from '@deotaland/utils';
const { t } = useI18n();
const cjimg = 'https://draft-user.s3.us-east-2.amazonaws.com/images/14f98f33-06a7-4629-a42e-d7cfbced786f';
const formData = ref({
internalImageUrl: '',//URL
status:'loading',//
});
// //
const props = defineProps({ const props = defineProps({
combinedPromptJson:{//
type: Object,
default: () => ({})
},
// URL // URL
imageUrl: { imageUrl: {
type: String, type: String,
@ -61,7 +116,7 @@ const props = defineProps({
}); });
// //
const emit = defineEmits(['preview', 'modify', 'edit', 'download', 'delete']); const emit = defineEmits(['preview', 'modify', 'edit', 'download', 'delete','customize-to-home']);
// //
const isLoading = ref(true); const isLoading = ref(true);
@ -90,11 +145,8 @@ const handleModify = (e) => {
// //
const handlePartialEdit = (e) => { const handlePartialEdit = (e) => {
e.stopPropagation(); e.stopPropagation();
emit('edit', props.cardData); emit('edit', formData.value.internalImageUrl);
}; };
// //
const handleDownload = (e) => { const handleDownload = (e) => {
e.stopPropagation(); e.stopPropagation();
@ -106,6 +158,99 @@ const handleDelete = (e) => {
e.stopPropagation(); e.stopPropagation();
emit('delete', props.cardData); emit('delete', props.cardData);
}; };
const giminiServer = new GiminiServer();
//
const handleGenerateImage = async () => {
const iscjt = props?.cardData?.diyPromptText&&props?.cardData?.diyPromptText?.indexOf('[CJT_DEOTA]')!=-1;
try {
// 使
let referenceImages = [];
//
if (props?.cardData?.inspirationImage) {
referenceImages.push(props.cardData.inspirationImage);
}
if(iscjt){
props.cardData.imgyt&&referenceImages.push(props.cardData.imgyt);
referenceImages.push(props.cardData.diyPromptImg);
referenceImages.push(cjimg);
}
if(props.cardData.diyPromptText||props.cardData.addDiyPromptImg){
referenceImages.push(props.cardData.diyPromptImg);
}
let dtprompt;
if(props?.cardData?.ipType==1){
dtprompt = props.combinedPromptJson.person.content;
referenceImages.push(...props.combinedPromptJson.person.imgs);
}else if(props?.cardData?.ipType==2){
dtprompt = props.combinedPromptJson.animal.content;
referenceImages.push(...props.combinedPromptJson.animal.imgs);
}
if(props.cardData.prompt){
dtprompt = `角色外观:${props.cardData.prompt}.${dtprompt}`
}
let prompt = props.cardData.diyPromptText|| dtprompt
// 姿${props.cardData.ipType==1?``:``}
if(props.cardData.prompt&&props.cardData.prompt.indexOf('nospec')!=-1){
prompt = '按原图生成'
referenceImages = [props.cardData.inspirationImage];
formData.value.internalImageUrl = props?.cardData?.inspirationImage;
return
}
const taskResult = await giminiServer.handleGenerateImage(referenceImages, prompt,{
project_id: props.cardData.project_id,
aspect_ratio:iscjt?'16:9':'9:16',
});
saveProject(taskResult);
getImageTask(taskResult.taskId,taskResult.taskQueue);
} catch (error) {
console.log(error);
emit('delete');
}
};
const handleCustomizeToHome = () => {
emit('customize-to-home', {
imageUrl: formData.value.internalImageUrl,
cardData: props.cardData
});
}
//
const getImageTask = async (taskId,taskQueue)=>{
giminiServer.getTaskGinimi(taskId,taskQueue,(imgurls)=>{
let imgItem = imgurls.splice(0,1)[0]
formData.value.internalImageUrl=imgItem.url;
saveProject({
taskId:taskId,
taskQueue:taskQueue,
});
// createRemainingImageData(imgurls);
},()=>{
ElMessage.error('Failed to generate image, please try again later.');
emit('delete');
});
}
//
const saveProject = (taskResult)=>{
emit('save-project', {
imageUrl:formData.value.internalImageUrl,
taskId:taskResult?.taskId||'',
taskQueue:taskResult?.taskQueue||'',
// status:formData.value.status,
status:'success',
});
}
const init = ()=>{
if(props.cardData.imageUrl){
formData.value.internalImageUrl = props.cardData.imageUrl;
// formData.value.internalImageUrl = '';
}else if(props.cardData.taskId&&props.cardData.taskQueue){
getImageTask(props.cardData.taskId,props.cardData.taskQueue);
}else{
handleGenerateImage();
}
}
onMounted(()=>{
init();
})
</script> </script>
<style scoped> <style scoped>
@ -122,6 +267,11 @@ const handleDelete = (e) => {
border-radius: 12px !important; border-radius: 12px !important;
} }
html.dark .shu-card-container {
background: #1f2937;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
}
/* 图片区域样式 */ /* 图片区域样式 */
.image-wrapper { .image-wrapper {
position: relative; position: relative;
@ -149,6 +299,64 @@ const handleDelete = (e) => {
transform: scale(1.02); transform: scale(1.02);
} }
/* 购买按钮样式 */
.purchase-btn {
position: absolute;
bottom: 12px;
right: 12px;
width: 44px;
height: 44px;
border: none;
border-radius: 50%;
background: linear-gradient(135deg, #6B46C1, #A78BFA);
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 12px rgba(107, 70, 193, 0.3);
z-index: 5;
}
.image-wrapper:hover .purchase-btn {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(107, 70, 193, 0.4);
}
.purchase-btn:hover {
background: linear-gradient(135deg, #5B3C9F, #9775E8);
transform: translateY(-2px) scale(1.05);
box-shadow: 0 6px 16px rgba(107, 70, 193, 0.4);
}
.purchase-btn:active {
transform: translateY(0) scale(1);
box-shadow: 0 2px 8px rgba(107, 70, 193, 0.3);
}
.purchase-btn svg {
width: 20px;
height: 20px;
stroke-width: 2;
}
/* 移动端购买按钮始终显示 */
@media (max-width: 768px) {
.purchase-btn {
opacity: 1;
width: 40px;
height: 40px;
bottom: 10px;
right: 10px;
}
.purchase-btn svg {
width: 18px;
height: 18px;
}
}
/* 加载蒙层样式 */ /* 加载蒙层样式 */
.loading-mask { .loading-mask {
position: absolute; position: absolute;
@ -160,25 +368,165 @@ const handleDelete = (e) => {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.9) 0%, rgba(248, 249, 250, 0.95) 100%); background: rgba(255, 255, 255, 0.85);
transition: opacity 0.3s ease; backdrop-filter: blur(8px);
transition: opacity 0.5s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 10; z-index: 10;
} }
.loading-spinner { html.dark .loading-mask {
width: 40px; background: rgba(31, 41, 55, 0.85);
height: 40px; backdrop-filter: blur(8px);
border: 4px solid rgba(107, 70, 193, 0.2);
border-top: 4px solid #6B46C1;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 12px;
} }
/* 加载动画容器 */
.loading-spinner-wrapper {
position: relative;
width: 48px;
height: 48px;
margin-bottom: 20px;
}
/* 外层光晕效果 */
.loading-spinner-wrapper::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 70px;
height: 70px;
transform: translate(-50%, -50%);
background: radial-gradient(circle, rgba(107, 70, 193, 0.15) 0%, transparent 70%);
animation: pulse 2s ease-in-out infinite;
}
/* 主旋转圈 */
.loading-spinner {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 2px solid transparent;
border-top-color: #6B46C1;
border-right-color: #A78BFA;
border-radius: 50%;
animation: spin 1s linear infinite;
}
/* 内层反向旋转圈 */
.loading-spinner-inner {
position: absolute;
top: 6px;
left: 6px;
width: calc(100% - 12px);
height: calc(100% - 12px);
border: 2px solid transparent;
border-bottom-color: #A78BFA;
border-left-color: #6B46C1;
border-radius: 50%;
animation: spinReverse 0.8s linear infinite;
}
/* 中心装饰点 */
.loading-spinner-wrapper::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 8px;
height: 8px;
background: linear-gradient(135deg, #6B46C1 0%, #A78BFA 100%);
border-radius: 50%;
transform: translate(-50%, -50%);
animation: centerPulse 1.5s ease-in-out infinite;
}
/* 加载文字 */
.loading-text { .loading-text {
font-size: 14px; font-size: 13px;
color: #6B46C1; color: #6B46C1;
font-weight: 500; font-weight: 500;
letter-spacing: 0.3px;
margin-bottom: 12px;
opacity: 0.8;
}
html.dark .loading-text {
color: #A78BFA;
}
/* 跳动圆点 */
.loading-dots {
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
}
.loading-dots span {
width: 5px;
height: 5px;
background: linear-gradient(135deg, #6B46C1 0%, #A78BFA 100%);
border-radius: 50%;
animation: bounce 1.4s ease-in-out infinite;
box-shadow: 0 2px 8px rgba(107, 70, 193, 0.2);
}
.loading-dots span:nth-child(1) {
animation-delay: 0s;
}
.loading-dots span:nth-child(2) {
animation-delay: 0.16s;
}
.loading-dots span:nth-child(3) {
animation-delay: 0.32s;
}
/* 动画效果 */
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes spinReverse {
0% { transform: rotate(360deg); }
100% { transform: rotate(0deg); }
}
@keyframes bounce {
0%, 80%, 100% {
transform: translateY(0) scale(1);
opacity: 0.5;
}
40% {
transform: translateY(-10px) scale(1.3);
opacity: 1;
}
}
@keyframes pulse {
0%, 100% {
transform: translate(-50%, -50%) scale(1);
opacity: 0.3;
}
50% {
transform: translate(-50%, -50%) scale(1.2);
opacity: 0.6;
}
}
@keyframes centerPulse {
0%, 100% {
transform: translate(-50%, -50%) scale(1);
opacity: 0.7;
}
50% {
transform: translate(-50%, -50%) scale(1.4);
opacity: 1;
}
} }
/* 功能按钮区域样式 */ /* 功能按钮区域样式 */
@ -193,6 +541,11 @@ const handleDelete = (e) => {
z-index: 1; z-index: 1;
} }
html.dark .action-buttons {
background: #1f2937;
border-top: 1px solid #374151;
}
.action-btn { .action-btn {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
@ -216,13 +569,23 @@ const handleDelete = (e) => {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05), 0 1px 2px rgba(0, 0, 0, 0.03); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05), 0 1px 2px rgba(0, 0, 0, 0.03);
} }
html.dark .action-btn {
background: #1f2937;
color: #9ca3af;
}
/* 按钮悬停效果 */ /* 按钮悬停效果 */
.action-btn:hover { .action-btn:hover {
transform: translateY(-2px); transform: translateY(-2px);
color: #6B46C1; color: var(--el-color-primary);
background: linear-gradient(135deg, rgba(107, 70, 193, 0.1) 0%, rgba(167, 139, 250, 0.1) 100%);
box-shadow: 0 4px 12px rgba(107, 70, 193, 0.15), 0 2px 4px rgba(107, 70, 193, 0.1); box-shadow: 0 4px 12px rgba(107, 70, 193, 0.15), 0 2px 4px rgba(107, 70, 193, 0.1);
} }
html.dark .action-btn:hover {
background: linear-gradient(135deg, rgba(139, 92, 246, 0.2) 0%, rgba(167, 139, 250, 0.2) 100%);
}
/* 按钮背景渐变效果 - 仅在悬停时显示 */ /* 按钮背景渐变效果 - 仅在悬停时显示 */
.action-btn::before { .action-btn::before {
content: ''; content: '';
@ -231,24 +594,21 @@ const handleDelete = (e) => {
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: linear-gradient(135deg, #6B46C1 0%, #A78BFA 100%); background: linear-gradient(135deg, var(--el-color-primary) 0%, var(--accent-color) 100%);
opacity: 0; opacity: 0;
transition: opacity 0.25s ease; transition: opacity 0.25s ease;
z-index: -1; z-index: -1;
} }
.action-btn:hover::before {
opacity: 1;
}
.action-btn:hover .btn-icon, .action-btn:hover .btn-icon,
.action-btn:hover .btn-text { .action-btn:hover .btn-text {
color: #ffffff; color: var(--el-color-primary);
} }
/* 删除按钮特殊样式 */ /* 删除按钮特殊样式 */
.action-btn.delete-btn:hover { .action-btn.delete-btn:hover {
color: #EF4444; color: #EF4444;
background: linear-gradient(135deg, rgba(239, 68, 68, 0.1) 0%, rgba(248, 113, 113, 0.1) 100%);
} }
.action-btn.delete-btn::before { .action-btn.delete-btn::before {
@ -257,7 +617,7 @@ const handleDelete = (e) => {
.action-btn.delete-btn:hover .btn-icon, .action-btn.delete-btn:hover .btn-icon,
.action-btn.delete-btn:hover .btn-text { .action-btn.delete-btn:hover .btn-text {
color: #ffffff; color: #EF4444;
} }
.action-btn:active { .action-btn:active {

View File

@ -5,10 +5,10 @@
<button <button
type="button" type="button"
class="mode-button" class="mode-button"
:class="{ 'active': loginMode === 'login' }" :class="{ 'active': loginMode === 'password' }"
@click="loginMode = 'login'" @click="loginMode = 'password'"
> >
{{ t('login.phone_login_mode') }} {{ t('login.phone_password_login_mode') }}
</button> </button>
<button <button
type="button" type="button"
@ -18,6 +18,14 @@
> >
{{ t('login.phone_register_mode') }} {{ t('login.phone_register_mode') }}
</button> </button>
<button
type="button"
class="mode-button"
:class="{ 'active': loginMode === 'reset' }"
@click="loginMode = 'reset'"
>
{{ t('login.phone_reset_password_mode') }}
</button>
</div> </div>
<!-- 手机号输入 --> <!-- 手机号输入 -->
@ -43,8 +51,28 @@
<div v-if="phoneError" class="error-message">{{ phoneError }}</div> <div v-if="phoneError" class="error-message">{{ phoneError }}</div>
</div> </div>
<!-- 验证码输入 --> <!-- 密码/验证码登录切换 -->
<div class="form-group"> <div v-if="loginMode === 'password'" class="login-method-toggle">
<button
type="button"
class="method-button"
:class="{ 'active': loginMethod === 'code' }"
@click="loginMethod = 'code'"
>
{{ t('login.phone_code_login_method') }}
</button>
<button
type="button"
class="method-button"
:class="{ 'active': loginMethod === 'password' }"
@click="loginMethod = 'password'"
>
{{ t('login.phone_password_login_method') }}
</button>
</div>
<!-- 验证码输入验证码登录显示 -->
<div v-if="(loginMode === 'password' && loginMethod === 'code') || loginMode === 'register' || loginMode === 'reset'" class="form-group">
<label class="form-label" for="code">{{ t('login.code_label') }}</label> <label class="form-label" for="code">{{ t('login.code_label') }}</label>
<div class="input-wrapper verification-code-wrapper" :class="{ 'focused': isCodeFocused }"> <div class="input-wrapper verification-code-wrapper" :class="{ 'focused': isCodeFocused }">
<input <input
@ -75,9 +103,9 @@
<div v-if="codeError" class="error-message">{{ codeError }}</div> <div v-if="codeError" class="error-message">{{ codeError }}</div>
</div> </div>
<!-- 密码输入仅在注册模式下显示 --> <!-- 密码输入注册密码登录重置密码时显示验证码登录不显示 -->
<div v-if="loginMode === 'register'" class="form-group"> <div v-if="(loginMode === 'register' || loginMode === 'password' || loginMode === 'reset') && !(loginMode === 'password' && loginMethod === 'code')" class="form-group">
<label class="form-label" for="password">{{ t('login.password_label') }}</label> <label class="form-label" for="password">{{ loginMode === 'reset' ? t('login.new_password_label') : t('login.password_label') }}</label>
<div class="input-wrapper" :class="{ 'focused': isPasswordFocused }"> <div class="input-wrapper" :class="{ 'focused': isPasswordFocused }">
<input <input
id="password" id="password"
@ -85,11 +113,11 @@
:type="showPassword ? 'text' : 'password'" :type="showPassword ? 'text' : 'password'"
class="form-input" class="form-input"
:class="{ 'error': passwordError }" :class="{ 'error': passwordError }"
:placeholder="t('login.password_placeholder')" :placeholder="loginMode === 'reset' ? t('login.new_password_placeholder') : t('login.password_placeholder')"
@focus="isPasswordFocused = true" @focus="isPasswordFocused = true"
@blur="isPasswordFocused = false" @blur="isPasswordFocused = false"
:disabled="loading" :disabled="loading"
autocomplete="new-password" :autocomplete="loginMode === 'password' ? 'current-password' : 'new-password'"
/> />
<button <button
type="button" type="button"
@ -107,13 +135,7 @@
<div v-if="passwordError" class="error-message">{{ passwordError }}</div> <div v-if="passwordError" class="error-message">{{ passwordError }}</div>
</div> </div>
<!-- 邀请码输入 -->
<InviteCodeInput
v-model="form.inviteCode"
:error="inviteCodeError"
:disabled="loading"
@validate="validateInviteCode"
/>
<!-- 登录/注册按钮 --> <!-- 登录/注册按钮 -->
<button <button
@ -122,13 +144,23 @@
:disabled="loading || !isFormValid" :disabled="loading || !isFormValid"
> >
<span class="button-text"> <span class="button-text">
<span v-if="!loading"> <span v-if="!loading">
{{ loginMode === 'login' ? t('login.phone_login_button') : t('login.phone_register_button') }} {{
(loginMode === 'code' && loginMethod === 'code') ? t('login.phone_code_login_button') :
(loginMode === 'code' && loginMethod === 'password') ? t('login.phone_password_login_button') :
(loginMode === 'password') ? t('login.phone_password_login_button') :
(loginMode === 'register') ? t('login.phone_register_button') :
t('login.phone_reset_password_button')
}}
</span>
<span v-else>
{{
(loginMode === 'code' || loginMode === 'password') ? t('login.phone_logging') :
(loginMode === 'register') ? t('login.phone_registering') :
t('login.phone_resetting')
}}
</span>
</span> </span>
<span v-else>
{{ loginMode === 'login' ? t('login.phone_logging') : t('login.phone_registering') }}
</span>
</span>
<div v-if="loading" class="loading-spinner"></div> <div v-if="loading" class="loading-spinner"></div>
</button> </button>
</form> </form>
@ -140,9 +172,8 @@ import { useI18n } from 'vue-i18n'
import { WarningFilled, View, Hide } from '@element-plus/icons-vue' import { WarningFilled, View, Hide } from '@element-plus/icons-vue'
import { useVuelidate } from '@vuelidate/core' import { useVuelidate } from '@vuelidate/core'
import { required, minLength } from '@vuelidate/validators' import { required, minLength } from '@vuelidate/validators'
import InviteCodeInput from './InviteCodeInput.vue'
const { t } = useI18n() const { t } = useI18n()
const emit = defineEmits(['login', 'register']) const emit = defineEmits(['login', 'codeLogin', 'register', 'resetPassword', 'resetSuccess', 'sendCode'])
const props = defineProps({ const props = defineProps({
loading: { loading: {
type: Boolean, type: Boolean,
@ -150,15 +181,17 @@ const props = defineProps({
} }
}) })
// login register // password register reset
const loginMode = ref('login') const loginMode = ref('password')
// password code
const loginMethod = ref('code')
// //
const form = ref({ const form = ref({
phone: '', phone: '',
code: '', code: '',
password: '', password: ''
inviteCode: ''
}) })
// //
@ -171,7 +204,6 @@ const showPassword = ref(false)
const phoneError = ref('') const phoneError = ref('')
const codeError = ref('') const codeError = ref('')
const passwordError = ref('') const passwordError = ref('')
const inviteCodeError = ref('')
// //
const countdown = ref(0) const countdown = ref(0)
@ -180,12 +212,14 @@ const canSendCode = ref(true)
// //
const rules = { const rules = {
phone: { required }, phone: { required },
code: { required, minLength: minLength(4) }, code: {
password: { required: computed(() => loginMode.value !== 'password'),
required: computed(() => loginMode.value === 'register'), minLength: computed(() => loginMode.value !== 'password' ? minLength(4) : false)
minLength: minLength(6)
}, },
inviteCode: {} password: {
required: computed(() => loginMode.value === 'register' || loginMode.value === 'password' || loginMode.value === 'reset'),
minLength: minLength(6)
}
} }
const v$ = useVuelidate(rules, form) const v$ = useVuelidate(rules, form)
@ -197,11 +231,18 @@ const isPhoneValid = computed(() => {
// //
const isFormValid = computed(() => { const isFormValid = computed(() => {
if (!form.value.phone || !form.value.code) { //
return false if (loginMode.value === 'password' && loginMethod.value === 'code') {
return !!form.value.phone && !!form.value.code && !phoneError.value && !codeError.value
} }
if (loginMode.value === 'register' && !form.value.password) { //
if (loginMode.value === 'password' && loginMethod.value === 'password') {
return !!form.value.phone && !!form.value.password && !phoneError.value && !passwordError.value
}
//
if (!form.value.phone || !form.value.code || !form.value.password) {
return false return false
} }
@ -220,9 +261,9 @@ const validatePhone = () => {
} }
const validateCode = () => { const validateCode = () => {
if (!form.value.code) { if (loginMode.value !== 'password' && !form.value.code) {
codeError.value = t('login.code_empty_error') codeError.value = t('login.code_empty_error')
} else if (form.value.code.length < 4) { } else if (loginMode.value !== 'password' && form.value.code.length < 4) {
codeError.value = t('login.code_invalid_error') codeError.value = t('login.code_invalid_error')
} else { } else {
codeError.value = '' codeError.value = ''
@ -230,7 +271,14 @@ const validateCode = () => {
} }
const validatePassword = () => { const validatePassword = () => {
if (loginMode.value === 'register') { if (loginMode.value === 'register' || loginMode.value === 'password' || loginMode.value === 'reset') {
//
if (loginMode.value === 'password' && loginMethod.value === 'code') {
passwordError.value = ''
return
}
//
if (!form.value.password) { if (!form.value.password) {
passwordError.value = t('login.password_empty_error') passwordError.value = t('login.password_empty_error')
} else if (form.value.password.length < 6) { } else if (form.value.password.length < 6) {
@ -243,16 +291,12 @@ const validatePassword = () => {
} }
} }
const validateInviteCode = () => {
inviteCodeError.value = ''
}
// //
watch(() => form.value.phone, validatePhone) watch(() => form.value.phone, validatePhone)
watch(() => form.value.code, validateCode) watch(() => form.value.code, validateCode)
watch(() => form.value.password, validatePassword) watch(() => form.value.password, validatePassword)
watch(() => form.value.inviteCode, validateInviteCode)
watch(() => loginMode.value, validatePassword) watch(() => loginMode.value, validatePassword)
watch(() => loginMethod.value, validatePassword)
// //
const handleSendCode = async () => { const handleSendCode = async () => {
@ -260,13 +304,17 @@ const handleSendCode = async () => {
return return
} }
// //
try { try {
canSendCode.value = false canSendCode.value = false
countdown.value = 60 countdown.value = 60
// API //
console.log('发送验证码到:', form.value.phone) emit('sendCode', {
phone: form.value.phone,
purpose: loginMode.value === 'reset' ? 'reset-password' :
loginMode.value === 'password' ? 'login' : 'register'
})
// //
const timer = setInterval(() => { const timer = setInterval(() => {
@ -285,22 +333,65 @@ const handleSendCode = async () => {
// //
const handleSubmit = async () => { const handleSubmit = async () => {
// //
await v$.value.$validate() if (loginMode.value === 'password') {
if (v$.value.$invalid) { //
validatePhone() validatePhone()
validateCode()
validatePassword() validatePassword()
return if (phoneError.value || passwordError.value) {
return
}
} else {
// 使 Vuelidate
await v$.value.$validate()
if (v$.value.$invalid) {
validatePhone()
validateCode()
validatePassword()
return
}
} }
// //
if (loginMode.value === 'login') { if (loginMode.value === 'password') {
emit('login', form.value) if (loginMethod.value === 'code') {
} else { emit('codeLogin', form.value)
} else {
emit('login', form.value)
}
} else if (loginMode.value === 'register') {
emit('register', form.value) emit('register', form.value)
} else if (loginMode.value === 'reset') {
emit('resetPassword', form.value, () => {
//
loginMode.value = 'password'
//
form.value = {
phone: '',
code: '',
password: ''
}
//
phoneError.value = ''
codeError.value = ''
passwordError.value = ''
})
} }
} }
//
const toggleLoginMode = (mode, method = 'password') => {
loginMode.value = mode
loginMethod.value = method
//
phoneError.value = ''
codeError.value = ''
passwordError.value = ''
}
//
defineExpose({
toggleLoginMode
})
</script> </script>
<style scoped> <style scoped>
@ -346,6 +437,41 @@ const handleSubmit = async () => {
transform: translateY(-1px); transform: translateY(-1px);
} }
/* 登录方法切换 */
.login-method-toggle {
display: flex;
background: rgba(139, 92, 246, 0.05);
border: 1px solid rgba(139, 92, 246, 0.1);
border-radius: 12px;
padding: 4px;
gap: 4px;
margin-bottom: 4px;
}
.method-button {
flex: 1;
padding: 10px 16px;
background: transparent;
border: none;
border-radius: 8px;
color: #6B7280;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.method-button:hover {
color: #6B46C1;
}
.method-button.active {
background: white;
color: #6B46C1;
box-shadow: 0 2px 8px rgba(107, 70, 193, 0.15);
transform: translateY(-1px);
}
/* 表单组 */ /* 表单组 */
.form-group { .form-group {
display: flex; display: flex;
@ -644,6 +770,25 @@ html.dark .mode-button.active {
box-shadow: 0 2px 8px rgba(139, 92, 246, 0.25); box-shadow: 0 2px 8px rgba(139, 92, 246, 0.25);
} }
html.dark .login-method-toggle {
background: rgba(139, 92, 246, 0.15);
border-color: rgba(139, 92, 246, 0.25);
}
html.dark .method-button {
color: #9CA3AF;
}
html.dark .method-button:hover {
color: #a78bfa;
}
html.dark .method-button.active {
background: rgba(31, 41, 55, 0.8);
color: #a78bfa;
box-shadow: 0 2px 8px rgba(139, 92, 246, 0.25);
}
html.dark .form-label { html.dark .form-label {
color: #f3f4f6; color: #f3f4f6;
} }

View File

@ -5,8 +5,7 @@
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<ul class="nav-list"> <ul class="nav-list">
<li v-for="item in coreMenuItems" :key="item.id"> <li v-for="item in coreMenuItems" :key="item.id">
<router-link <div
:to="item.path"
class="nav-item" class="nav-item"
:class="{ 'active': isActiveRoute(item.path) }" :class="{ 'active': isActiveRoute(item.path) }"
@click="handleNavClick(item)" @click="handleNavClick(item)"
@ -25,7 +24,7 @@
<transition name="fade"> <transition name="fade">
<span v-if="!collapsed && item.badge" class="nav-badge">{{ item.badge }}</span> <span v-if="!collapsed && item.badge" class="nav-badge">{{ item.badge }}</span>
</transition> </transition>
</router-link> </div>
</li> </li>
</ul> </ul>
</nav> </nav>

View File

@ -45,6 +45,8 @@
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue' import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
import AppHeader from './AppHeader.vue' import AppHeader from './AppHeader.vue'
import AppSidebar from './AppSidebar.vue' import AppSidebar from './AppSidebar.vue'
import { useRouter } from 'vue-router'
// //
defineOptions({ defineOptions({
name: 'MainLayout' name: 'MainLayout'
@ -105,9 +107,10 @@ const toggleSidebar = () => {
state.sidebarVisible = !state.sidebarVisible state.sidebarVisible = !state.sidebarVisible
} }
} }
const router = useRouter()
// //
const handleNavigate = (route) => { const handleNavigate = (route) => {
router.replace(route)
// //
if (state.isMobile) { if (state.isMobile) {
state.sidebarVisible = false state.sidebarVisible = false

View File

@ -131,7 +131,27 @@ export default {
modify: '修改', modify: '修改',
sceneGraph: '场景图', sceneGraph: '场景图',
edit: '局部', edit: '局部',
download: '下载' download: '下载',
delete: '删除',
purchase: '购买',
loadingText: '图片加载中...'
},
addModal: {
title: '添加内容',
ipType: 'IP类型',
character: '人物',
animal: '动物',
textPrompt: '文本提示',
placeholder: '请输入角色描述,例如:一个可爱的卡通人物',
referenceImage: '参考图片',
uploadOrSelect: '点击或拖拽上传图片',
uploading: '上传中...',
generate: '生成',
optimizedPrompt: {
name: '名称',
gender: '性别',
appearance: '外观'
}
}, },
modelCard: { modelCard: {
generateModelButton: '生成模型', generateModelButton: '生成模型',
@ -220,6 +240,7 @@ export default {
times: '次', times: '次',
remainingCredits: '积分', remainingCredits: '积分',
recharge: '充值', recharge: '充值',
add: '添加',
guide: '使用指南', guide: '使用指南',
back: '返回', back: '返回',
skip: '跳过', skip: '跳过',
@ -229,6 +250,14 @@ export default {
skipGuide: '跳过引导', skipGuide: '跳过引导',
step: '步骤' step: '步骤'
}, },
emptyState: {
title: '开始你的创作之旅',
description: '还没有创建任何内容。点击下方按钮开始创建你的第一个作品,探索无限创意可能。',
createButton: '创建新内容',
tip1: '选择角色或动物类型',
tip2: '输入创意描述或上传参考图',
tip3: '生成并保存你的作品'
},
roles: { roles: {
creator: '达人会员', creator: '达人会员',
free: '免费会员', free: '免费会员',
@ -681,15 +710,24 @@ export default {
code_empty_error: '请输入验证码', code_empty_error: '请输入验证码',
code_invalid_error: '请输入有效的验证码', code_invalid_error: '请输入有效的验证码',
send_code: '发送验证码', send_code: '发送验证码',
resend_code_after: '{}秒后重新发送', resend_code_after: '{seconds}秒后重新发送',
phone_login_mode: '手机号登录', phone_password_login_mode: '登录',
phone_register_mode: '手机号注册', phone_register_mode: '注册',
phone_login_button: '登录', phone_reset_password_mode: '重置密码',
phone_register_button: '注册', phone_login_button: '登录',
phone_logging: '正在登录...', phone_password_login_button: '登录',
phone_registering: '正在注册...', phone_register_button: '注册',
phone_login_link: '手机号', phone_reset_password_button: '重置密码',
email_login_link: '邮箱登录', phone_logging: '正在登录...',
phone_registering: '正在注册...',
phone_resetting: '正在重置...',
phone_login_link: '手机号',
email_login_link: '邮箱登录',
new_password_label: '新密码',
new_password_placeholder: '请输入新密码',
phone_code_login_method: '验证码登录',
phone_password_login_method: '密码登录',
phone_code_login_button: '登录',
}, },
payment: { payment: {
methods: '支付方式', methods: '支付方式',
@ -1641,10 +1679,30 @@ export default {
textInputTitle: 'Text Input', textInputTitle: 'Text Input',
textInputPlaceholder: 'Please enter adjustment content, e.g. change character expression', textInputPlaceholder: 'Please enter adjustment content, e.g. change character expression',
preview: 'Preview', preview: 'Preview',
modify: 'Modify', modify: 'Edit',
sceneGraph: 'Scene Graph', sceneGraph: 'Scene Graph',
edit: 'Edit', edit: 'Part',
download: 'Download' download: 'Save',
delete: 'Remove',
purchase: 'Purchase',
loadingText: 'Loading image...'
},
addModal: {
title: 'Add Content',
ipType: 'IP Type',
character: 'Character',
animal: 'Animal',
textPrompt: 'Text Prompt',
placeholder: 'Enter character description, e.g. a cute cartoon character',
referenceImage: 'Reference Image',
uploadOrSelect: 'Click or drag to upload image',
uploading: 'Uploading...',
generate: 'Generate',
optimizedPrompt: {
name: 'Name',
gender: 'Gender',
appearance: 'Appearance'
}
}, },
modelCard: { modelCard: {
generateModelButton: 'Generate Model', generateModelButton: 'Generate Model',
@ -1733,6 +1791,7 @@ export default {
times: 'times', times: 'times',
remainingCredits: 'Credits', remainingCredits: 'Credits',
recharge: 'Recharge', recharge: 'Recharge',
add: 'Add',
guide: 'User Guide', guide: 'User Guide',
back: 'Back', back: 'Back',
skip: 'Skip', skip: 'Skip',
@ -1742,6 +1801,14 @@ export default {
skipGuide: 'Skip Guide', skipGuide: 'Skip Guide',
step: 'Step' step: 'Step'
}, },
emptyState: {
title: 'Start Your Creative Journey',
description: 'No content created yet. Click the button below to create your first work and explore infinite creative possibilities.',
createButton: 'Create New Content',
tip1: 'Choose character or animal type',
tip2: 'Enter creative description or upload reference image',
tip3: 'Generate and save your work'
},
userCenter: { userCenter: {
title: 'User Center', title: 'User Center',
description: 'Manage your account information and settings', description: 'Manage your account information and settings',
@ -2185,70 +2252,79 @@ export default {
status: 'Status', status: 'Status',
timeline: 'Tracking Timeline' timeline: 'Tracking Timeline'
}, },
login: { login: {
divider_text: 'Or', divider_text: 'OR',
role_system: 'Role System', role_system: 'Role System',
creator_role: 'Creator', creator_role: 'Creator',
admin_role: 'Administrator', admin_role: 'Admin',
viewer_role: 'Viewer', viewer_role: 'Viewer',
creator_desc: 'Full system access, including user management and system configuration', creator_desc: 'Full system access, including user management and system configuration',
admin_desc: 'Content management and user management permissions', admin_desc: 'Content and user management permissions',
viewer_desc: 'Basic feature access permissions', viewer_desc: 'Basic feature access permissions',
theme_toggle_tooltip: 'Switch to dark theme', theme_toggle_tooltip: 'Switch to dark theme',
theme_toggle_tooltip_light: 'Switch to light theme', theme_toggle_tooltip_light: 'Switch to light theme',
language_toggle_tooltip: 'Switch to English', language_toggle_tooltip: 'Switch to English',
login_success: 'Login successful', login_success: 'Login successful',
login_error: 'Login failed', login_error: 'Login failed',
google_login: 'Login with Google', google_login: 'Sign in with Google',
google_logging: 'Logging in...', google_logging: 'Signing in...',
email_login: 'Login', email_login: 'Sign in',
email_logging: 'Logging in...', email_logging: 'Signing in...',
email_placeholder: 'Please enter your email', email_placeholder: 'Enter your email',
password_placeholder: 'Please enter your password', password_placeholder: 'Enter your password',
email_label: 'Email Address', email_label: 'Email Address',
password_label: 'Password', password_label: 'Password',
email_empty_error: 'Please enter email address', email_empty_error: 'Please enter your email address',
email_invalid_error: 'Please enter a valid email address', email_invalid_error: 'Please enter a valid email address',
password_empty_error: 'Please enter password', password_empty_error: 'Please enter your password',
password_min_error: 'Password must be at least 6 characters', password_min_error: 'Password must be at least 6 characters',
login_success_message: 'Login successful!', login_success_message: 'Login successful!',
login_error_message: 'Login failed', login_error_message: 'Login failed',
google_login_success: 'Google login successful!', google_login_success: 'Google sign-in successful!',
google_login_error: 'Google login failed', google_login_error: 'Google sign-in failed',
login_processing_error: 'An error occurred during login', login_processing_error: 'An error occurred during login',
google_login_processing_error: 'An error occurred during Google login', google_login_processing_error: 'An error occurred during Google sign-in',
email_login_notice: 'Email login feature is under development, stay tuned', email_login_notice: 'Email sign-in feature coming soon',
theme_toggle_light: 'Switch to light theme', theme_toggle_light: 'Switch to light theme',
theme_toggle_dark: 'Switch to dark theme', theme_toggle_dark: 'Switch to dark theme',
forgot_password: 'Forgot Password?', forgot_password: 'Forgot password?',
register_account: 'Register Account', register_account: 'Register account',
invite_code_label: 'Invite Code', invite_code_label: 'Invitation Code',
invite_code_placeholder: 'Please enter invite code', invite_code_placeholder: 'Enter invitation code',
invite_code_empty_error: 'Please enter invite code', invite_code_empty_error: 'Please enter invitation code',
join_waitlist: 'Join Waitlist', join_waitlist: 'Join waitlist',
join_waitlist_success: 'Successfully joined the waitlist, we will contact you soon', join_waitlist_success: 'Successfully joined waitlist, we will contact you soon',
// Phone login related // Phone login related
phone_login_title: 'Phone Login', phone_login_title: 'Phone Login',
phone_login_subtitle: 'Login with phone number and verification code', phone_login_subtitle: 'Sign in with phone number and verification code',
phone_label: 'Phone Number', phone_label: 'Phone Number',
phone_placeholder: 'Please enter your phone number', phone_placeholder: 'Enter your phone number',
phone_empty_error: 'Please enter phone number', phone_empty_error: 'Please enter phone number',
phone_invalid_error: 'Please enter a valid phone number', phone_invalid_error: 'Please enter a valid phone number',
code_label: 'Verification Code', code_label: 'Verification Code',
code_placeholder: 'Please enter verification code', code_placeholder: 'Enter verification code',
code_empty_error: 'Please enter verification code', code_empty_error: 'Please enter verification code',
code_invalid_error: 'Please enter a valid verification code', code_invalid_error: 'Please enter a valid verification code',
send_code: 'Send Code', send_code: 'Send Code',
resend_code_after: 'Resend after {seconds}s', resend_code_after: 'Resend after {} seconds',
phone_login_mode: 'Phone Login', phone_password_login_mode: 'Sign',
phone_register_mode: 'Phone Register', phone_register_mode: 'Register',
phone_login_button: 'Login', phone_reset_password_mode: 'Reset',
phone_login_button: 'Sign in',
phone_password_login_button: 'Sign in',
phone_register_button: 'Register', phone_register_button: 'Register',
phone_logging: 'Logging in...', phone_reset_password_button: 'Reset Password',
phone_logging: 'Signing in...',
phone_registering: 'Registering...', phone_registering: 'Registering...',
phone_resetting: 'Resetting...',
phone_login_link: 'Phone', phone_login_link: 'Phone',
email_login_link: 'Login with Email', email_login_link: 'Email Sign-in',
}, new_password_label: 'New Password',
new_password_placeholder: 'Enter new password',
phone_code_login_method: 'Code Login',
phone_password_login_method: 'Password Login',
phone_code_login_button: 'Sign in',
},
payment: { payment: {
methods: 'Payment Methods', methods: 'Payment Methods',
methods: { methods: {

View File

@ -11,6 +11,7 @@ import 'element-plus/theme-chalk/dark/css-vars.css'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css' import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
// Plugins // Plugins
import ElementPlus from 'element-plus' import ElementPlus from 'element-plus'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import { createI18n } from 'vue-i18n' import { createI18n } from 'vue-i18n'
import i18nConfig from './locales/index.js' import i18nConfig from './locales/index.js'
import router from './router' import router from './router'
@ -50,6 +51,10 @@ app.use(i18n)
// Router & UI // Router & UI
app.use(router) app.use(router)
app.use(ElementPlus) app.use(ElementPlus)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
// Lazyload // Lazyload
// app.use(VueLazyload, { // app.use(VueLazyload, {
// loading: '/logo.png', // loading: '/logo.png',

View File

@ -16,7 +16,6 @@ const AgentManagement = () => import('../views/AgentManagement.vue')
const AddAgent = () => import('../views/AddAgent.vue') const AddAgent = () => import('../views/AddAgent.vue')
const DeviceList = () => import('../views/DeviceList.vue') const DeviceList = () => import('../views/DeviceList.vue')
const UiTest = () => import('../views/UiTest.vue') const UiTest = () => import('../views/UiTest.vue')
const home = () => import('../views/home/index.vue')
const PointsRecharge = () => import('../views/PointsRecharge/PointsRecharge.vue') const PointsRecharge = () => import('../views/PointsRecharge/PointsRecharge.vue')
const UserCenter = () => import('../views/user/index.vue') const UserCenter = () => import('../views/user/index.vue')
const NotFound = () => import('../views/NotFound.vue') const NotFound = () => import('../views/NotFound.vue')
@ -25,12 +24,22 @@ const KefuReduce = () => import('../views/kefuReduce.vue')
const isPortraitMobile = () => { const isPortraitMobile = () => {
return window.innerWidth < 768 && window.innerHeight > window.innerWidth return window.innerWidth < 768 && window.innerHeight > window.innerWidth
} }
const isWeChatBrowser = () => {
const ua = navigator.userAgent.toLowerCase()
return ua.indexOf('micromessenger') !== -1
}
const CreateProject = () => { const CreateProject = () => {
if (isPortraitMobile()) { if (isPortraitMobile()) {
return import('../views/Project/CreateProjectShu/CreateProjectShu.vue') return import('../views/Project/CreateProjectShu/CreateProjectShu.vue')
} }
return import('../views/Project/CreateProject.vue') return import('../views/Project/CreateProject.vue')
} }
const home = () => {
return import('../views/home/index.vue')
// if (isWeChatBrowser()) {
// return import('../views/ModernHome/ModernHome.vue')
// }
}
NProgress.configure({ NProgress.configure({
showSpinner: false, showSpinner: false,
})// 开启轻量模式(顶部细线) })// 开启轻量模式(顶部细线)
@ -90,6 +99,7 @@ const routes = [
meta: { requiresAuth: false, keepAlive: false, fullScreen: true } meta: { requiresAuth: false, keepAlive: false, fullScreen: true }
} }
] ]
//免费会员/达人会员动态路由 //免费会员/达人会员动态路由
export const freeRoutes = [ export const freeRoutes = [
{ {
@ -195,6 +205,11 @@ router.beforeEach(async (to, from, next) => {
// window.localStorage.setItem('token','123') // window.localStorage.setItem('token','123')
// return next() // return next()
// } // }
if(to.path=='/'){
if(isWeChatBrowser()){//如果是微信浏览器,跳转到现代首页
return next('/czhome')
}
}
if (to.path == '/login' || to.path == '/login/phone' || to.path == '/register' || to.path == '/forgot-password') { if (to.path == '/login' || to.path == '/login/phone' || to.path == '/register' || to.path == '/forgot-password') {
const token = localStorage.getItem('token') const token = localStorage.getItem('token')
// 如果有 token跳转到首页 // 如果有 token跳转到首页

View File

@ -22,42 +22,9 @@ export const useAuthStore = defineStore('auth', () => {
} finally { } finally {
} }
} }
// 手机号+验证码登录方法
const phoneLogin = async (data,callback=null) => {
try {
const res = await requestUtils.common(clientApi.default.PHONE_LOGIN, data)
if(res.code === 0){
let data = res.data;
// 登录成功保存token和用户信息
loginSuccess(data,callback)
return res
}
} catch (error) {
console.error('手机号登录失败:', error)
throw error
} finally {
}
}
// 手机号+验证码+密码注册方法
const phoneRegister = async (data,callback=null) => {
try {
const res = await requestUtils.common(clientApi.default.PHONE_REGISTER, data)
if(res.code === 0){
let data = res.data;
// 注册成功保存token和用户信息
loginSuccess(data,callback)
return res
}
} catch (error) {
console.error('手机号注册失败:', error)
throw error
} finally {
}
}
//登录成功方法 //登录成功方法
const loginSuccess = (data,callback=null) => { const loginSuccess = (data,callback=null) => {
token.value = data.accessToken token.value = data.accessToken
// user.value = data
localStorage.setItem('token', token.value); localStorage.setItem('token', token.value);
updateUserInfo().then(res => { updateUserInfo().then(res => {
callback&&callback(data); callback&&callback(data);
@ -100,13 +67,44 @@ export const useAuthStore = defineStore('auth', () => {
} }
}) })
} }
const phoneLogin = async (data,callback=null) => {
try {
const res = await requestUtils.common(clientApi.default.LOGIN_PHONE, data)
if(res.code === 0){
let data = res.data;
// 登录成功保存token和用户信息
loginSuccess(data,callback)
return res
}
} catch (error) {
throw error
} finally {
}
}
//使用手机号和验证码登录
const phoneLoginCode = async (data,callback=null) => {
try {
const res = await requestUtils.common(clientApi.default.LOGIN_PHONE_CODE, data)
if(res.code === 0){
let data = res.data;
// 登录成功保存token和用户信息
loginSuccess(data,callback)
return res
}
} catch (error) {
throw error
} finally {
}
}
return { return {
phoneLoginCode,
phoneLogin,
updateUserInfo, updateUserInfo,
user, user,
token, token,
login, login,
phoneLogin,
phoneRegister,
logout, logout,
loginSuccess, loginSuccess,
} }

View File

@ -5,6 +5,10 @@
--accent-color: #A78BFA; --accent-color: #A78BFA;
--text-color: #1F2937; --text-color: #1F2937;
--bg-color: #F3F4F6; --bg-color: #F3F4F6;
--border-color: #E5E7EB;
--card-bg: #FFFFFF;
--text-secondary: #6B7280;
--primary-light: rgba(107, 70, 193, 0.1);
/* Explicitly set light color scheme by default */ /* Explicitly set light color scheme by default */
color-scheme: light; color-scheme: light;
@ -114,6 +118,10 @@ html.dark * {
html.dark { html.dark {
--text-color: #F3F4F6; --text-color: #F3F4F6;
--bg-color: #0b0d12; --bg-color: #0b0d12;
--border-color: #374151;
--card-bg: #1F2937;
--text-secondary: #9CA3AF;
--primary-light: rgba(139, 92, 246, 0.15);
/* Element Plus dark theme variables */ /* Element Plus dark theme variables */
--el-color-primary: #8B5CF6; --el-color-primary: #8B5CF6;

View File

@ -49,6 +49,7 @@
<div class="auth-links"> <div class="auth-links">
<div class="auth-links-row"> <div class="auth-links-row">
<button <button
v-if="showPhoneLogin"
type="button" type="button"
class="auth-link phone-login-link" class="auth-link phone-login-link"
@click="goToPhoneLogin" @click="goToPhoneLogin"
@ -56,7 +57,6 @@
<el-icon class="link-icon"><Phone /></el-icon> <el-icon class="link-icon"><Phone /></el-icon>
<span>{{ t('login.phone_login_link') }}</span> <span>{{ t('login.phone_login_link') }}</span>
</button> </button>
<button <button
type="button" type="button"
class="auth-link forgot-password-link" class="auth-link forgot-password-link"
@ -85,6 +85,7 @@
import { onMounted, reactive, ref, computed } from 'vue' import { onMounted, reactive, ref, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { environmentUtils } from '@deotaland/utils'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { WarningFilled, InfoFilled, QuestionFilled, UserFilled, Phone } from '@element-plus/icons-vue' import { WarningFilled, InfoFilled, QuestionFilled, UserFilled, Phone } from '@element-plus/icons-vue'
// //
@ -102,11 +103,45 @@ const inviteCode = ref('')
const isInviteCodeValid = computed(() => { const isInviteCodeValid = computed(() => {
return inviteCode.value.trim() !== '' return inviteCode.value.trim() !== ''
}) })
//
const environmentInfo = ref(null)
const isDetectingEnvironment = ref(true)
//
const showPhoneLogin = computed(() => {
//
if (isDetectingEnvironment.value) {
return true
}
//
if (!environmentInfo.value) {
return true
}
//
return environmentInfo.value.isDomestic
})
// //
const updateInviteCode = (value) => { const updateInviteCode = (value) => {
inviteCode.value = value inviteCode.value = value
} }
//
const detectEnvironment = async () => {
try {
const result = await environmentUtils.detectEnvironment({
useCache: true,
ipDetection: true,
timeout: 5000
})
environmentInfo.value = result
} catch (error) {
environmentInfo.value = null
isDetectingEnvironment.value = true
} finally {
isDetectingEnvironment.value = false
}
}
const handleLogin = async (data) => { const handleLogin = async (data) => {
plugin.login(data) plugin.login(data)
// if (data.inviteCode) { // if (data.inviteCode) {
@ -136,8 +171,11 @@ const goToRegister = () => {
const goToPhoneLogin = () => { const goToPhoneLogin = () => {
router.push('/login/phone') router.push('/login/phone')
} }
//
// //
onMounted(() => { onMounted(() => {
detectEnvironment()
}) })
</script> </script>

View File

@ -28,8 +28,13 @@
<!-- 手机号登录表单 --> <!-- 手机号登录表单 -->
<div class="phone-login-section"> <div class="phone-login-section">
<PhoneLoginForm <PhoneLoginForm
ref="PhoneLoginFormRef"
@login="handleLogin" @login="handleLogin"
@codeLogin="handleCodeLogin"
@register="handleRegister" @register="handleRegister"
@resetPassword="handleResetPassword"
@resetSuccess="handleResetSuccess"
@sendCode="handleSendCode"
/> />
</div> </div>
@ -50,15 +55,6 @@
<el-icon class="link-icon"><Message /></el-icon> <el-icon class="link-icon"><Message /></el-icon>
<span>{{ t('login.email_login_link') }}</span> <span>{{ t('login.email_login_link') }}</span>
</button> </button>
<button
type="button"
class="auth-link forgot-password-link"
@click="goToForgotPassword"
>
<el-icon class="link-icon"><QuestionFilled /></el-icon>
<span>{{ t('login.forgot_password') }}</span>
</button>
</div> </div>
</div> </div>
</div> </div>
@ -81,17 +77,35 @@ const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
const { t } = useI18n() const { t } = useI18n()
const plugin = reactive(new LOGIN()); const plugin = reactive(new LOGIN());
const PhoneLoginFormRef = ref(null)
// //
const handleLogin = async (data) => { const handleLogin = async (data) => {
plugin.phoneLogin(data) plugin.phoneLogin(data)
} }
//
const handleCodeLogin = async (data) => {
plugin.phoneLoginCode(data)
}
// //
const handleRegister = async (data) => { const handleRegister = async (data) => {
plugin.phoneRegister(data) plugin.phoneRegister(data)
} }
//
const handleResetPassword = async (data) => {
plugin.phoneResetPassword(data,()=>{
//
PhoneLoginFormRef.value.toggleLoginMode('password', 'password')
})
}
//
const handleSendCode = async (data) => {
plugin.sendPhoneCode(data)
}
// //
const goToEmailLogin = () => { const goToEmailLogin = () => {
router.push('/login') router.push('/login')
@ -236,7 +250,7 @@ onMounted(() => {
.auth-links-row { .auth-links-row {
display: flex; display: flex;
justify-content: space-between; justify-content: center;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
} }

View File

@ -1,6 +1,6 @@
import { requestUtils,clientApi } from '@deotaland/utils'; import { requestUtils, clientApi } from '@deotaland/utils';
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import {ElMessage} from 'element-plus'; import { ElMessage } from 'element-plus';
import { useAuthStore } from '@/stores/auth.js'; import { useAuthStore } from '@/stores/auth.js';
export default class Login { export default class Login {
loading = false;//登录loading loading = false;//登录loading
@ -10,105 +10,144 @@ export default class Login {
} }
async login(data) { async login(data) {
this.loading = true; this.loading = true;
this.authStore.login(data,(userData)=>{ this.authStore.login(data, (userData) => {
this.loading = false; this.loading = false;
this.handleLoginSuccess(userData); this.handleLoginSuccess(userData);
// this.router.push({ name: 'czhome' }) // this.router.push({ name: 'czhome' })
// this.refreshGoogleRefreshToken() // this.refreshGoogleRefreshToken()
}); });
} }
//手机号登录
async phoneLogin(data) {
this.loading = true;
this.authStore.phoneLogin(data,(userData)=>{
this.loading = false;
this.handleLoginSuccess(userData);
});
}
//手机号注册
async phoneRegister(data) {
this.loading = true;
this.authStore.phoneRegister(data,(userData)=>{
this.loading = false;
this.handleLoginSuccess(userData);
});
}
//登录成功处理根据不同角色标识 //登录成功处理根据不同角色标识
handleLoginSuccess(userData){//0候补1免费2达人 handleLoginSuccess(userData) {//0候补1免费2达人
// userData.user_role=1 // userData.user_role=1
this.router.push({ name: 'czhome' }); this.router.replace({ name: 'czhome' });
// if(userData.user_role != '0'){
// this.router.push({ name: 'czhome' });
// }else{
// ElMessage.success('You have been added to the waitlist');
// window.localStorage.removeItem('token')
// }
} }
//发送邮箱验证码 //发送邮箱验证码
sendEmailCode(item,callback){ sendEmailCode(item, callback) {
requestUtils.common(clientApi.default.SEND_EMAIL_CODE,{ requestUtils.common(clientApi.default.SEND_EMAIL_CODE, {
email:item.email, email: item.email,
purpose:item.purpose||'register' //forgot-password purpose: item.purpose || 'register' //forgot-password
}).then(res=>{ }).then(res => {
ElMessage.success('验证码发送成功'); ElMessage.success('验证码发送成功');
callback&&callback(); callback && callback();
}) })
} }
//发送手机验证码 //发送手机验证码
sendPhoneCode(item,callback){ async sendPhoneCode(data) {
requestUtils.common(clientApi.default.SEND_PHONE_CODE,{ let parmas = {
phone:item.phone, "phone": data.phone,
purpose:item.purpose||'login' //register "purpose": data.purpose//register注册 //reset-password重置密码//login登录
}).then(res=>{ }
ElMessage.success('验证码发送成功'); requestUtils.common(clientApi.default.SEND_SMS_CODE, parmas).then(res => {
callback&&callback(); ElMessage.success('验证码发送成功');
}) })
} }
//确认注册功能 //手机号密码登录
confirmRegister(data,callback){ async phoneLogin(data) {
let params = { let params = {
"email": data.email, "phone": data.phone,
"emailCode": data.emailCode, "password": data.password
"password": data.password }
this.loading = true;
this.authStore.phoneLogin(params, (userData) => {
this.loading = false;
this.handleLoginSuccess(userData);
});
} }
requestUtils.common(clientApi.default.REGISTER,params).then(res=>{ //使用手机号和验证码登录
let data = res.data; async phoneLoginCode(data) {
// this.authStore.loginSuccess(data,()=>{ let params = {
// this.router.push({ name: 'home' }) "phone": data.phone,
// }) "smsCode": data.code
ElMessage.success('注册成功'); }
callback&&callback(); this.loading = true;
this.authStore.phoneLoginCode(params, (userData) => {
this.loading = false;
this.handleLoginSuccess(userData);
});
}
//手机号重置密码
async phoneResetPassword(data, callback) {
let params = {
"phone": data.phone,
"smsCode": data.code,
"newPassword": data.password
}
this.loading = true;
await requestUtils.common(clientApi.default.RESET_PASSWORD_PHONE, params).then(res => {
this.loading = false;
ElMessage.success('密码重置成功');
callback && callback();
})
}
//手机号注册
async phoneRegister(data) {
let params = {
phone: data.phone,
smsCode: data.code,
password: data.password
}
this.loading = true;
requestUtils.common(clientApi.default.REGISTER_PHONE, params).then(res => {
this.loading = false;
ElMessage.success('注册成功');
let data = res.data;
this.authStore.loginSuccess(data,()=>{
this.handleLoginSuccess(data);
});
}).catch(err => {
this.loading = false;
ElMessage.error('注册失败');
})
}
//确认注册功能
confirmRegister(data, callback) {
let params = {
"email": data.email,
"emailCode": data.emailCode,
"password": data.password
}
requestUtils.common(clientApi.default.REGISTER, params).then(res => {
let data = res.data;
// this.authStore.loginSuccess(data,()=>{
// this.router.push({ name: 'home' })
// })
ElMessage.success('注册成功');
callback && callback();
}) })
} }
//刷新googleRefreshToken //刷新googleRefreshToken
refreshGoogleRefreshToken(callback){ refreshGoogleRefreshToken(callback) {
requestUtils.common(clientApi.default.REFRESH_TOKEN).then(res=>{ requestUtils.common(clientApi.default.REFRESH_TOKEN).then(res => {
ElMessage.success('刷新成功'); ElMessage.success('刷新成功');
callback&&callback(); callback && callback();
}) })
} }
//登出 //登出
logout(){ logout() {
this.authStore.logout(()=>{ this.authStore.logout(() => {
this.router.replace({ name: 'login' }) this.router.replace({ name: 'login' })
}) })
} }
//确认修改密码 //确认修改密码
confirmForgotPassword(data,callback){ confirmForgotPassword(data, callback) {
let params = { let params = {
"email": data.email, "email": data.email,
"emailCode": data.emailCode, "emailCode": data.emailCode,
"newPassword": data.password "newPassword": data.password
} }
requestUtils.common(clientApi.default.FORGOT_PASSWORD,params).then(res=>{ requestUtils.common(clientApi.default.FORGOT_PASSWORD, params).then(res => {
ElMessage.success('密码修改成功'); ElMessage.success('密码修改成功');
callback&&callback(); callback && callback();
}) })
} }
//加入候补队列 //加入候补队列
joinWaitlist(data,callback){ joinWaitlist(data, callback) {
// 这里可以根据实际情况实现加入候补队列的API调用 // 这里可以根据实际情况实现加入候补队列的API调用
ElMessage.success('已成功加入候补队列,我们将尽快与您联系'); ElMessage.success('已成功加入候补队列,我们将尽快与您联系');
callback&&callback(); callback && callback();
} }
} }

View File

@ -151,7 +151,8 @@ const iconComponents = {
Tickets, Tickets,
Setting, Setting,
Picture, Picture,
Cpu Cpu,
User
} }
// - 使 // - 使

View File

@ -67,7 +67,6 @@
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
</button> --> </button> -->
<!-- 根据卡片类型显示不同组件 --> <!-- 根据卡片类型显示不同组件 -->
<IPCard <IPCard
@delete="handleDeleteCard(index)" @delete="handleDeleteCard(index)"

View File

@ -3,7 +3,7 @@
<!-- 头部导航栏 --> <!-- 头部导航栏 -->
<div class="header-nav"> <div class="header-nav">
<div class="header-left"> <div class="header-left">
<button class="back-button" @click="handleBack"> <button class="back-button" @click="handleBack" :title="$t('header.back')">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 12H5M12 19l-7-7 7-7"/> <path d="M19 12H5M12 19l-7-7 7-7"/>
</svg> </svg>
@ -28,11 +28,72 @@
</div> </div>
<!-- 卡片列表 --> <!-- 卡片列表 -->
<div class="cards-container"> <div class="cards-container">
<!-- 空状态页面 -->
<div v-if="cards.length === 0" class="empty-state">
<div class="empty-state-content">
<div class="empty-state-icon">
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="60" cy="60" r="50" fill="url(#gradient1)" fill-opacity="0.1"/>
<circle cx="60" cy="60" r="40" fill="url(#gradient1)" fill-opacity="0.15"/>
<circle cx="60" cy="60" r="30" fill="url(#gradient1)" fill-opacity="0.2"/>
<path d="M60 35V85M35 60H85" stroke="url(#gradient2)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="60" cy="60" r="8" fill="url(#gradient2)"/>
<defs>
<linearGradient id="gradient1" x1="10" y1="10" x2="110" y2="110" gradientUnits="userSpaceOnUse">
<stop stop-color="#6B46C1"/>
<stop offset="1" stop-color="#A78BFA"/>
</linearGradient>
<linearGradient id="gradient2" x1="35" y1="35" x2="85" y2="85" gradientUnits="userSpaceOnUse">
<stop stop-color="#6B46C1"/>
<stop offset="1" stop-color="#A78BFA"/>
</linearGradient>
</defs>
</svg>
</div>
<h2 class="empty-state-title">{{ $t('emptyState.title') }}</h2>
<p class="empty-state-description">{{ $t('emptyState.description') }}</p>
<button class="empty-state-button" @click="handleAdd">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
{{ $t('emptyState.createButton') }}
</button>
<div class="empty-state-tips">
<div class="tip-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M12 16v-4M12 8h.01"/>
</svg>
<span>{{ $t('emptyState.tip1') }}</span>
</div>
<div class="tip-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
<span>{{ $t('emptyState.tip2') }}</span>
</div>
<div class="tip-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
<span>{{ $t('emptyState.tip3') }}</span>
</div>
</div>
</div>
</div>
<!-- 卡片列表 -->
<shu-card <shu-card
v-for="(card, index) in cards" v-for="(card, index) in cards"
:key="card.id" :key="card.id"
:image-url="card.imageUrl" :image-url="card.imageUrl"
:card-data="card" :card-data="card"
:combinedPromptJson="combinedPromptJson"
@save-project="(item)=>{handleSaveProject(index,item,'image')}"
@customize-to-home="handleCustomizeToHome(index)"
@preview="handlePreviewImage" @preview="handlePreviewImage"
@modify="handleModify" @modify="handleModify"
@edit="handlePartialEdit" @edit="handlePartialEdit"
@ -40,23 +101,91 @@
@delete="handleDeleteCard(index)" @delete="handleDeleteCard(index)"
/> />
</div> </div>
<!-- 固定添加按钮 -->
<button class="add-button" @click="handleAdd" :title="$t('header.add')">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
<!-- 添加弹窗 -->
<AddModal
:series="series"
:show="showAddModal"
@close="showAddModal = false"
@generate="handleAddGenerate"
@handleHookSelected="handleHookSelected"
/>
<PurchaseModal
v-if="showPurchaseModal"
:series="series"
:show="showPurchaseModal"
:modelData="CustomizeModalData"
@close="showPurchaseModal=false" />
<!-- 定制到家弹窗 -->
<OrderProcessModal
:show="showOrderProcessModal"
:modelData="CustomizeModalData"
@close="showOrderProcessModal=false"
@acknowledge="handleBuyFromCustomize" />
<DtCanvasEditor
:language="locale"
v-model:visible="canvasEditorVisible"
:image-url="canvasEditorImageUrl"
@add-prompt-card="handleCanvasSave"
/>
<!-- 文本输入弹窗 -->
<div v-if="showTextInput" class="text-input-overlay" role="dialog" aria-modal="true" aria-label="文本输入">
<div class="text-input-container">
<div class="text-input-header">
<div class="text-input-title">{{ t('modelModal.textInputTitle') }}</div>
<button class="text-input-close" @click="handleTextInputCancel" aria-label="关闭">
<el-icon class="close-icon"><CloseBold /></el-icon>
</button>
</div>
<textarea
ref="textInputRef"
v-model="textInputValue"
class="text-input-area"
:placeholder="t('modelModal.textInputPlaceholder')"
rows="4"
@keydown.enter.ctrl="handleTextInputConfirm"
@keydown.esc="handleTextInputCancel"
@click.stop
@mousedown.stop
></textarea>
<div class="text-input-actions">
<button class="text-input-btn cancel-btn" @click="handleTextInputCancel">
{{ t('common.cancel') }}
</button>
<button class="text-input-btn confirm-btn" @click="handleTextInputConfirm">
{{ t('common.confirm') }}
</button>
</div>
</div>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
// //
import { ref, computed, onMounted, onUnmounted,watch } from 'vue'; import { ref, computed, onMounted, onUnmounted,watch,nextTick } from 'vue';
import {useRoute,useRouter} from 'vue-router'; import {useRoute,useRouter} from 'vue-router';
import {MeshyServer,GiminiServer,FileServer} from '@deotaland/utils'; import {MeshyServer,GiminiServer,FileServer} from '@deotaland/utils';
import {Project} from '../index'; import {Project} from '../index';
import {ModernHome} from '../../ModernHome/index.js' import {ModernHome} from '../../ModernHome/index.js'
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { ElIcon } from 'element-plus'; import { ElIcon } from 'element-plus';
import { MagicStick } from '@element-plus/icons-vue'; import { MagicStick, CloseBold } from '@element-plus/icons-vue';
import LanguageToggle from '@/components/ui/LanguageToggle.vue'; import LanguageToggle from '@/components/ui/LanguageToggle.vue';
import ThemeToggle from '@/components/ui/ThemeToggle.vue'; import ThemeToggle from '@/components/ui/ThemeToggle.vue';
import AddModal from '@/components/AddModal/index.vue';
import PurchaseModal from '@/components/PurchaseModal/index.vue';
import OrderProcessModal from '@/components/OrderProcessModal/index.vue';
// //
import ShuCard from '@/components/IPCard/shu.vue'; import ShuCard from '@/components/IPCard/shu.vue';
const { t } = useI18n();
const { locale } = useI18n() const { locale } = useI18n()
const headerRef = ref(null); const headerRef = ref(null);
const iPandCardLeftRef = ref(null); const iPandCardLeftRef = ref(null);
@ -68,14 +197,41 @@ const modernHome = new ModernHome();
const router = useRouter(); const router = useRouter();
const PluginProject = new Project(); const PluginProject = new Project();
//
const showTextInput = ref(false);
const textInputValue = ref('');
const currentModifyCardData = ref(null);
const textInputRef = ref(null);
// //
const handleRecharge = () => { const handleRecharge = () => {
router.push('/points-recharge'); router.push('/points-recharge');
} }
//
const handleAdd = () => {
showAddModal.value = true;
}
//
const handleAddGenerate = (data) => {
//
// handleGenerateRequested
handleGenerateRequested({
count: 1,
profile: {
appearance: data.prompt
},
inspirationImage: data.inspirationImage,
ipType: data.ipType,
ipTypeImg: null
});
}
// //
const showModelModal = ref(false); const showModelModal = ref(false);
const selectedModel = ref(null); const selectedModel = ref(null);
const showAddModal = ref(false);
const showImportModal = ref(false); const showImportModal = ref(false);
const importUrl = ref('https://xiaozhi.me/console/agents'); const importUrl = ref('https://xiaozhi.me/console/agents');
const showGuideModal = ref(false); const showGuideModal = ref(false);
@ -151,18 +307,40 @@ const handlePartialEdit = (imageUrl, index) => {
// //
const handleModify = (cardData) => { const handleModify = (cardData) => {
console.log('修改按钮点击', cardData); console.log('修改按钮点击', cardData);
// //
//
if (cardData) { if (cardData) {
// currentModifyCardData.value = cardData;
handleCreatePromptCard(null, { showTextInput.value = true;
img: cardData.imageUrl, textInputValue.value = '';
diyPromptText: '', nextTick(() => {
cardData: cardData if (textInputRef.value) {
textInputRef.value.focus();
}
}); });
} }
}; };
//
const handleTextInputConfirm = () => {
if (textInputValue.value.trim() && currentModifyCardData.value) {
handleCreatePromptCard(null, {
img: currentModifyCardData.value.imageUrl,
diyPromptText: textInputValue.value,
cardData: currentModifyCardData.value
});
showTextInput.value = false;
textInputValue.value = '';
currentModifyCardData.value = null;
}
};
//
const handleTextInputCancel = () => {
showTextInput.value = false;
textInputValue.value = '';
currentModifyCardData.value = null;
};
// //
const handleScene = (cardData) => { const handleScene = (cardData) => {
console.log('场景图按钮点击', cardData); console.log('场景图按钮点击', cardData);
@ -223,7 +401,6 @@ const getCombinedPrompt = async (config={})=>{
try { try {
const data = await PluginProject.getCombinedPrompt(series.value,config); const data = await PluginProject.getCombinedPrompt(series.value,config);
combinedPromptJson.value = data; combinedPromptJson.value = data;
console.log(data,'combinedPromptJson.value');
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
@ -544,8 +721,7 @@ const handleGenerateRequested = async (params) => {
}); });
// //
cards.value.push(newCard); cards.value.push(newCard);
// 200 console.log(cards.value,'cardcardcard');
await new Promise(resolve => setTimeout(resolve, 200));
} }
}; };
@ -1066,7 +1242,7 @@ onUnmounted(() => {// 禁用轮询
width: 100%; width: 100%;
min-height: 100vh; min-height: 100vh;
overflow: hidden; overflow: hidden;
background: #F3F4F6; background: var(--bg-color);
position: relative; position: relative;
} }
@ -1077,7 +1253,7 @@ onUnmounted(() => {// 禁用轮询
left: 0; left: 0;
right: 0; right: 0;
height: 64px; height: 64px;
background: #FFFFFF; background: #ffffff;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@ -1086,6 +1262,11 @@ onUnmounted(() => {// 禁用轮询
z-index: 1000; z-index: 1000;
} }
html.dark .header-nav {
background: #1f2937;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.header-left { .header-left {
display: flex; display: flex;
align-items: center; align-items: center;
@ -1099,10 +1280,10 @@ onUnmounted(() => {// 禁用轮询
width: 40px; width: 40px;
height: 40px; height: 40px;
border: none; border: none;
background: #F3F4F6; background: var(--bg-color);
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: pointer;
color: #1F2937; color: var(--text-color);
transition: all 0.2s ease; transition: all 0.2s ease;
} }
@ -1111,6 +1292,10 @@ onUnmounted(() => {// 禁用轮询
transform: translateX(-2px); transform: translateX(-2px);
} }
html.dark .back-button:hover {
background: #374151;
}
.back-button:active { .back-button:active {
transform: translateX(0); transform: translateX(0);
} }
@ -1118,14 +1303,14 @@ onUnmounted(() => {// 禁用轮询
.header-title { .header-title {
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
color: #1F2937; color: var(--text-color);
margin: 0; margin: 0;
} }
.header-right { .header-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 8px;
} }
/* 剩余积分显示样式 */ /* 剩余积分显示样式 */
@ -1139,23 +1324,28 @@ onUnmounted(() => {// 禁用轮询
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 8px 12px; padding: 8px 0px;
background-color: #F3F4F6; background-color: var(--bg-color);
border-radius: 8px; border-radius: 8px;
transition: all 0.3s ease; transition: all 0.3s ease;
font-size: 12px; font-size: 12px;
color: #6B7280; color: #6B7280;
} }
html.dark .model-count {
/* background-color: #374151; */
color: #9ca3af;
}
.count-icon { .count-icon {
color: #6B46C1; color: var(--el-color-primary);
font-size: 16px; font-size: 16px;
} }
.count-text { .count-text {
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
color: #1F2937; color: var(--text-color);
white-space: nowrap; white-space: nowrap;
} }
@ -1165,7 +1355,7 @@ onUnmounted(() => {// 禁用轮询
justify-content: center; justify-content: center;
padding: 4px 10px; padding: 4px 10px;
border: none; border: none;
background-color: #6B46C1; background: linear-gradient(135deg, #6B46C1, #A78BFA);
color: white; color: white;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
@ -1173,16 +1363,56 @@ onUnmounted(() => {// 禁用轮询
font-size: 11px; font-size: 11px;
font-weight: 500; font-weight: 500;
margin-left: 4px; margin-left: 4px;
box-shadow: 0 2px 6px rgba(107, 70, 193, 0.25);
} }
.recharge-btn:hover { .recharge-btn:hover {
background-color: #7C3AED; background: linear-gradient(135deg, #5B3C9F, #9775E8);
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(107, 70, 193, 0.3); box-shadow: 0 4px 12px rgba(107, 70, 193, 0.4);
} }
.recharge-btn:active { .recharge-btn:active {
transform: translateY(0); transform: translateY(0);
box-shadow: 0 2px 6px rgba(107, 70, 193, 0.3);
}
.add-button {
position: fixed;
right: 24px;
bottom:140px !important;
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border: none;
background: linear-gradient(135deg, #6B46C1 0%, #A78BFA 100%);
border-radius: 50%;
cursor: pointer;
color: white;
transition: all 0.2s ease;
box-shadow: 0 4px 12px rgba(107, 70, 193, 0.4);
z-index: 1000;
}
.add-button:hover {
transform: scale(1.1);
box-shadow: 0 6px 16px rgba(107, 70, 193, 0.5);
}
html.dark .add-button {
background: linear-gradient(135deg, #8B5CF6 0%, #C4B5FD 100%);
box-shadow: 0 4px 12px rgba(139, 92, 246, 0.4);
}
html.dark .add-button:hover {
background: linear-gradient(135deg, #9B6CF6 0%, #D4C5FD 100%);
box-shadow: 0 6px 16px rgba(139, 92, 246, 0.5);
}
.add-button:active {
transform: scale(0.95);
} }
.recharge-text { .recharge-text {
@ -1211,17 +1441,6 @@ onUnmounted(() => {// 禁用轮询
box-sizing: border-box; box-sizing: border-box;
} }
/* 卡片样式 */
.cards-container :deep(.shu-card-container) {
width: 100%;
min-width: 0;
max-width: none;
overflow: visible !important;
height: auto !important;
display: flex !important;
flex-direction: column !important;
max-height: none !important;
}
/* 确保图片区域正常显示 */ /* 确保图片区域正常显示 */
.cards-container :deep(.image-wrapper) { .cards-container :deep(.image-wrapper) {
@ -1247,6 +1466,283 @@ onUnmounted(() => {// 禁用轮询
.header-title { .header-title {
font-size: 16px; font-size: 16px;
} }
}
/* 文本输入弹窗样式 */
.text-input-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
padding: 20px;
animation: fadeIn 0.3s ease;
}
html.dark .text-input-overlay {
background: rgba(0, 0, 0, 0.8);
}
.text-input-container {
width: 100%;
max-width: 400px;
background: #ffffff;
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
overflow: hidden;
animation: slideUp 0.3s ease;
}
html.dark .text-input-container {
background: #1f2937;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.text-input-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #e5e7eb;
}
html.dark .text-input-header {
border-bottom: 1px solid #374151;
}
.text-input-title {
font-size: 16px;
font-weight: 600;
color: #1f2937;
}
html.dark .text-input-title {
color: #f3f4f6;
}
.text-input-close {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
color: #6b7280;
transition: all 0.2s ease;
}
.text-input-close:hover {
background: rgba(107, 70, 193, 0.1);
color: #6B46C1;
}
html.dark .text-input-close:hover {
background: rgba(139, 92, 246, 0.2);
color: #A78BFA;
}
.close-icon {
font-size: 20px;
}
.text-input-area {
width: 100%;
padding: 16px 20px;
border: none;
background: #f9fafb;
color: #1f2937;
font-size: 14px;
line-height: 1.6;
resize: none;
outline: none;
font-family: inherit;
min-height: 120px;
}
html.dark .text-input-area {
background: #111827;
color: #f3f4f6;
}
.text-input-area::placeholder {
color: #9ca3af;
}
html.dark .text-input-area::placeholder {
color: #6b7280;
}
.text-input-area:focus {
background: #f3f4f6;
}
html.dark .text-input-area:focus {
background: #1f2937;
}
.text-input-actions {
display: flex;
gap: 12px;
padding: 16px 20px;
border-top: 1px solid #e5e7eb;
}
html.dark .text-input-actions {
border-top: 1px solid #374151;
}
.text-input-btn {
flex: 1;
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
}
.cancel-btn {
background: #f3f4f6;
color: #6b7280;
}
.cancel-btn:hover {
background: #e5e7eb;
color: #4b5563;
}
html.dark .cancel-btn {
background: #374151;
color: #9ca3af;
}
html.dark .cancel-btn:hover {
background: #4b5563;
color: #d1d5db;
}
.confirm-btn {
background: linear-gradient(135deg, #6B46C1 0%, #A78BFA 100%);
color: white;
}
.confirm-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(107, 70, 193, 0.3);
}
html.dark .confirm-btn {
background: linear-gradient(135deg, #8B5CF6 0%, #C4B5FD 100%);
}
html.dark .confirm-btn:hover {
box-shadow: 0 4px 12px rgba(139, 92, 246, 0.4);
}
.text-input-btn:active {
transform: translateY(0);
}
/* 动画效果 */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 移动端适配 */
@media (max-width: 768px) {
.text-input-overlay {
padding: 16px;
}
.text-input-container {
max-width: 100%;
border-radius: 12px;
}
.text-input-header {
padding: 14px 16px;
}
.text-input-title {
font-size: 15px;
}
.text-input-area {
padding: 14px 16px;
font-size: 14px;
min-height: 100px;
}
.text-input-actions {
padding: 14px 16px;
gap: 10px;
}
.text-input-btn {
padding: 10px 20px;
font-size: 13px;
}
}
@media (max-width: 480px) {
.text-input-overlay {
padding: 12px;
}
.text-input-container {
border-radius: 10px;
}
.text-input-header {
padding: 12px 14px;
}
.text-input-title {
font-size: 14px;
}
.text-input-area {
padding: 12px 14px;
font-size: 13px;
min-height: 80px;
}
.text-input-actions {
padding: 12px 14px;
gap: 8px;
}
.text-input-btn {
padding: 10px 16px;
font-size: 12px;
}
.filter-nav { .filter-nav {
gap: 4px; gap: 4px;
@ -1262,6 +1758,13 @@ onUnmounted(() => {// 禁用轮询
height: 36px; height: 36px;
} }
.add-button {
width: 48px;
height: 48px;
right: 16px;
bottom: 16px;
}
.cards-container { .cards-container {
top: 56px; top: 56px;
padding: 12px; padding: 12px;
@ -1280,6 +1783,13 @@ onUnmounted(() => {// 禁用轮询
font-size: 14px; font-size: 14px;
} }
.add-button {
width: 52px;
height: 52px;
right: 20px;
bottom: 20px;
}
.cards-container { .cards-container {
top: 60px; top: 60px;
padding: 16px; padding: 16px;
@ -1294,4 +1804,231 @@ onUnmounted(() => {// 禁用轮询
gap: 20px; gap: 20px;
} }
} }
/* 空状态页面样式 */
.empty-state {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-color, #F3F4F6);
}
.empty-state-content {
text-align: center;
padding: 40px;
max-width: 500px;
animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.empty-state-icon {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 24px;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
.empty-state-title {
font-size: 24px;
font-weight: 600;
color: var(--text-color, #1F2937);
margin-bottom: 12px;
}
.empty-state-description {
font-size: 16px;
color: var(--text-muted, #6B7280);
margin-bottom: 32px;
line-height: 1.6;
}
.empty-state-button {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 14px 28px;
background: linear-gradient(135deg, #6B46C1, #A78BFA);
color: white;
border: none;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 12px rgba(107, 70, 193, 0.3);
margin-bottom: 40px;
}
.empty-state-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(107, 70, 193, 0.4);
}
.empty-state-button:active {
transform: translateY(0);
}
.empty-state-tips {
display: flex;
flex-direction: column;
gap: 16px;
align-items: center;
}
.tip-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
background: var(--card-bg, #FFFFFF);
border-radius: 8px;
border: 1px solid var(--border-color, #E5E7EB);
transition: all 0.2s ease;
max-width: 300px;
}
.tip-item:hover {
border-color: var(--primary-color, #6B46C1);
transform: translateX(4px);
}
.tip-item svg {
color: var(--primary-color, #6B46C1);
flex-shrink: 0;
}
.tip-item span {
font-size: 14px;
color: var(--text-color, #1F2937);
text-align: left;
}
/* 暗色主题适配 */
html.dark .empty-state {
background: var(--bg-color-dark, #111827);
}
html.dark .empty-state-title {
color: var(--text-color-dark, #F3F4F6);
}
html.dark .empty-state-description {
color: var(--text-muted-dark, #9CA3AF);
}
html.dark .tip-item {
background: var(--card-bg-dark, #1F2937);
border-color: var(--border-color-dark, #374151);
}
html.dark .tip-item span {
color: var(--text-color-dark, #F3F4F6);
}
/* 移动端适配 */
@media (max-width: 768px) {
.empty-state-content {
padding: 24px;
}
.empty-state-icon {
margin-bottom: 20px;
}
.empty-state-icon svg {
width: 100px;
height: 100px;
}
.empty-state-title {
font-size: 20px;
}
.empty-state-description {
font-size: 14px;
margin-bottom: 24px;
}
.empty-state-button {
padding: 12px 24px;
font-size: 14px;
margin-bottom: 32px;
}
.tip-item {
padding: 10px 16px;
max-width: 100%;
}
.tip-item span {
font-size: 13px;
}
}
@media (max-width: 480px) {
.empty-state-content {
padding: 20px;
}
.empty-state-icon svg {
width: 80px;
height: 80px;
}
.empty-state-title {
font-size: 18px;
}
.empty-state-description {
font-size: 13px;
}
.empty-state-button {
padding: 10px 20px;
font-size: 13px;
}
.tip-item {
padding: 8px 14px;
}
}
/* 平板端适配 */
@media (min-width: 768px) and (max-width: 1024px) {
.empty-state-tips {
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
}
.tip-item {
max-width: 200px;
}
}
</style> </style>

View File

@ -0,0 +1,820 @@
<template>
<Bg>
<!-- <div :style="getResponsiveWidthStyle()">
<div style="position: sticky;top: 0;">
<spline :scene="'https://prod.spline.design/kZDDjO5HuC9GJUM2/scene.splinecode'"/>
</div>
</div> -->
<div style="position: relative;pointer-events:none;" class="min-h-screen flex flex-col w-full selection:bg-purple-500 selection:text-white">
<!-- Navbar -->
<header
:class="[
'fixed top-0 left-0 right-0 z-900 transition-all duration-300 pointer-events-auto',
isScrolled ? 'bg-black/90 py-4' : 'bg-transparent py-6'
]"
:style="{ backdropFilter: isScrolled && supportsBackdropFilter ? 'blur(12px)' : 'none' }"
>
<div class="container mx-auto px-6 flex items-center justify-between">
<!-- Logo -->
<a href="#" class="text-2xl font-bold tracking-tighter text-white">
Deotaland
</a>
<!-- Desktop Nav -->
<nav class="hidden md:flex items-center gap-8">
<!-- <a
href="#"
class="text-sm font-medium text-gray-300 hover:text-white transition-colors"
>
{{ t('nav.creator') }}
</a>
<a
href="#"
class="text-sm font-medium text-gray-300 hover:text-white transition-colors"
>
{{ t('nav.land') }}
</a> -->
<a
href="#"
class="text-sm font-medium text-gray-300 hover:text-white transition-colors"
>
{{ t('nav.creator') }}
</a>
<a
href="#"
class="text-sm font-medium text-gray-300 hover:text-white transition-colors"
>
{{ t('nav.done') }}
</a>
<a
href="#"
class="text-sm font-medium text-gray-300 hover:text-white transition-colors"
>
{{ t('nav.about') }}
</a>
<router-link
to="/points-recharge"
class="text-sm font-medium text-gray-300 hover:text-white transition-colors"
>
pricing
</router-link>
</nav>
<!-- Right Action & Mobile Toggle -->
<div class="flex items-center gap-4">
<button
@click="$router.push('czhome')"
class="hidden md:inline-flex items-center justify-center px-5 py-2 text-sm font-semibold text-black bg-white rounded-full hover:bg-gray-200 transition-colors cursor-pointer"
>
{{ t('hero.start') }}
</button>
<button
class="md:hidden text-white"
@click="isMobileMenuOpen = !isMobileMenuOpen"
>
<svg v-if="isMobileMenuOpen" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/></svg>
</button>
</div>
</div>
<!-- Mobile Menu -->
<div v-if="isMobileMenuOpen" class="absolute top-full left-0 right-0 border-t border-gray-800 p-6 flex flex-col gap-4 md:hidden">
<!-- <a
href="#"
class="text-lg font-medium text-gray-300 hover:text-white"
>
{{ t('nav.creator') }}
</a>
<a
href="#"
class="text-lg font-medium text-gray-300 hover:text-white"
>
{{ t('nav.land') }}
</a>
<router-link
to="/points-recharge"
class="text-lg font-medium text-gray-300 hover:text-white"
>
{{ t('nav.pricing') }}
</router-link> -->
<a
href="#"
class="text-lg font-medium text-gray-300 hover:text-white"
>
{{ t('nav.creator') }}
</a>
<a
href="#"
class="text-lg font-medium text-gray-300 hover:text-white"
>
{{ t('nav.done') }}
</a>
<a
href="#"
class="text-lg font-medium text-gray-300 hover:text-white"
>
{{ t('nav.about') }}
</a>
<button
@click="$router.push('czhome')"
class="w-full text-center py-3 text-black bg-white rounded-full font-bold cursor-pointer"
>
{{ t('hero.start') }}
</button>
</div>
</header>
<!-- Main Content -->
<main>
<!-- Hero Section -->
<div ref="containerRef" class="relative h-[300vh] w-full ">
<!-- Sticky Container -->
<div class="sticky top-0 h-[100vh] w-full overflow-hidden" :style="{ height: viewportHeight + 'px', position: supportsSticky ? 'sticky' : 'relative' }">
<!-- Layer 1: Background Animation (Grid) -->
<div class="absolute inset-0 flex items-center justify-center overflow-hidden">
<div
class="origin-center flex items-center justify-center"
>
<!-- Grid Layout -->
<div v-if="!isMobile" class="grid grid-cols-3 md:grid-cols-5 gap-2 md:gap-3 w-full h-auto p-4" >
<!-- --- ROW 1 --- -->
<div class="aspect-[9/16] md:aspect-[1/1] rounded-xl overflow-hidden bg-gray-900/50"><img :src="center1" class="w-full h-full object-cover" alt="Robot Companion" /></div>
<div class="aspect-[9/16] md:aspect-[1/1] rounded-xl overflow-hidden bg-gray-900/50"><img :src="center3" class="w-full h-full object-cover" alt="Electronics" /></div>
<div :style="{ scale: scale*1.5<=1?1:scale*1.5, zIndex:20 }" class="aspect-[9/16] md:aspect-[1/1] rounded-xl overflow-hidden bg-gray-900/50">
<img :src="center" class="w-full h-full object-cover" alt="Main Hero Robot" />
<!-- <div style="position: absolute;bottom: 0%;width: 100vw;left: 50%;transform: translateX(-50%);">
</div> -->
</div>
<div class="aspect-[9/16] md:aspect-[1/1] rounded-xl overflow-hidden bg-gray-900/50"><img :src="center4" class="w-full h-full object-cover" alt="Retro Bot" /></div>
<div class=" md:block aspect-[9/16] md:aspect-[1/1] rounded-xl overflow-hidden bg-gray-900/50"><img :src="center2" class="w-full h-full object-cover" alt="Toy Bot" /></div>
<div class=" md:block aspect-[9/16] md:aspect-[1/1] rounded-xl overflow-hidden bg-gray-900/50"><img :src="center7" class="w-full h-full object-cover" alt="Cyberpunk" /></div>
<!-- --- ROW 2 (Middle) --- -->
<div class="aspect-[9/16] md:aspect-[1/1] rounded-xl overflow-hidden bg-gray-900/50"><img :src="center5" class="w-full h-full object-cover" alt="Interactive" /></div>
<div class=" md:block aspect-[9/16] md:aspect-[1/1] rounded-xl overflow-hidden bg-gray-900/50"><img :src="center6" class="w-full h-full object-cover" alt="Small Bot" /></div>
<!-- --- CENTER HERO IMAGE (Always Visible) --- -->
<!-- :style="{ scale: scale*1.2 }" -->
<div class="aspect-[9/16] md:aspect-[1/1] rounded-xl overflow-hidden bg-gray-900/50"><img :src="center9" class="w-full h-full object-cover" alt="Glowing Eye" /></div>
<div class="hidden md:block aspect-[9/16] md:aspect-[1/1] rounded-xl overflow-hidden bg-gray-900/50"><img :src="center8" class="w-full h-full object-cover" alt="3D Print" /></div>
<!-- --- ROW 3 --- -->
<!-- <div class="hidden md:block aspect-[9/16] md:aspect-[1/1] rounded-xl overflow-hidden bg-gray-900/50"><img :src="center1" class="w-full h-full object-cover" alt="Tech Texture" /></div>
<div class="aspect-[9/16] md:aspect-[1/1] rounded-xl overflow-hidden bg-gray-900/50"><img :src="center2" class="w-full h-full object-cover" alt="Robot Hand" /></div>
<div class="aspect-[9/16] md:aspect-[1/1] rounded-xl overflow-hidden bg-gray-900/50"><img :src="center6" class="w-full h-full object-cover" alt="Circuit" /></div>
<div class="hidden md:block aspect-[9/16] md:aspect-[1/1] rounded-xl overflow-hidden bg-gray-900/50"><img :src="center4" class="w-full h-full object-cover" alt="Display" /></div>
<div class=" aspect-[9/16] md:aspect-[1/1] rounded-xl overflow-hidden bg-gray-900/50"><img :src="center5" class="w-full h-full object-cover" alt="Robotics" /></div> -->
</div>
<div v-else class="grid grid-cols-3 md:grid-cols-5 gap-2 md:gap-3 w-full h-auto p-4" >
<!-- --- ROW 1 --- -->
<div class="aspect-[9/16] md:aspect-[1/1] rounded-xl overflow-hidden bg-gray-900/50"><img :src="center1" class="w-full h-full object-cover" alt="Robot Companion" /></div>
<div class="aspect-[9/16] md:aspect-[1/1] rounded-xl overflow-hidden bg-gray-900/50"><img :src="center3" class="w-full h-full object-cover" alt="Electronics" /></div>
<div class="aspect-[9/16] md:aspect-[1/1] rounded-xl overflow-hidden bg-gray-900/50"><img :src="center2" class="w-full h-full object-cover" alt="Retro Bot" /></div>
<div class=" md:block aspect-[9/16] md:aspect-[1/1] rounded-xl overflow-hidden bg-gray-900/50"><img :src="center4" class="w-full h-full object-cover" alt="Toy Bot" /></div>
<div :style="{ scale: scale*1.5<=1?1:scale*1.5, zIndex:20 }" class="aspect-[9/16] md:aspect-[1/1] rounded-xl overflow-hidden bg-gray-900/50">
<img :src="center" class="w-full h-full object-cover" alt="Main Hero Robot" />
<!-- <div style="position: absolute;bottom: 0%;width: 100vw;left: 50%;transform: translateX(-50%);">
</div> -->
</div>
<div class=" md:block aspect-[9/16] md:aspect-[1/1] rounded-xl overflow-hidden bg-gray-900/50"><img :src="center5" class="w-full h-full object-cover" alt="Cyberpunk" /></div>
<!-- --- ROW 2 (Middle) --- -->
<div class="aspect-[9/16] md:aspect-[1/1] rounded-xl overflow-hidden bg-gray-900/50"><img :src="center7" class="w-full h-full object-cover" alt="Interactive" /></div>
<div class=" md:block aspect-[9/16] md:aspect-[1/1] rounded-xl overflow-hidden bg-gray-900/50"><img :src="center9" class="w-full h-full object-cover" alt="Small Bot" /></div>
<!-- --- CENTER HERO IMAGE (Always Visible) --- -->
<!-- :style="{ scale: scale*1.2 }" -->
<div class="aspect-[9/16] md:aspect-[1/1] rounded-xl overflow-hidden bg-gray-900/50"><img :src="center8" class="w-full h-full object-cover" alt="Glowing Eye" /></div>
<!-- --- ROW 3 --- -->
<!-- <div class="hidden md:block aspect-[9/16] md:aspect-[1/1] rounded-xl overflow-hidden bg-gray-900/50"><img :src="center1" class="w-full h-full object-cover" alt="Tech Texture" /></div>
<div class="aspect-[9/16] md:aspect-[1/1] rounded-xl overflow-hidden bg-gray-900/50"><img :src="center2" class="w-full h-full object-cover" alt="Robot Hand" /></div>
<div class="aspect-[9/16] md:aspect-[1/1] rounded-xl overflow-hidden bg-gray-900/50"><img :src="center6" class="w-full h-full object-cover" alt="Circuit" /></div>
<div class="hidden md:block aspect-[9/16] md:aspect-[1/1] rounded-xl overflow-hidden bg-gray-900/50"><img :src="center4" class="w-full h-full object-cover" alt="Display" /></div>
<div class=" aspect-[9/16] md:aspect-[1/1] rounded-xl overflow-hidden bg-gray-900/50"><img :src="center5" class="w-full h-full object-cover" alt="Robotics" /></div> -->
</div>
</div>
</div>
<!-- Layer 2: Static Dark Overlay -->
<div class="absolute inset-0 z-10 pointer-events-none" />
<!-- Layer 3: Static Content Layer -->
<div class="absolute inset-0 z-20 flex flex-col items-center justify-center pointer-events-none">
<MotionCom>
<div class="pointer-events-auto flex flex-col items-center justify-center text-center px-4 w-full max-w-5xl mx-auto">
<h1 class="text-5xl md:text-7xl lg:text-8xl font-bold text-white mb-6 tracking-tighter drop-shadow-2xl">
{{ t('hero.title') }}
</h1>
<p class="text-xl md:text-2xl text-gray-200 mb-10 drop-shadow-lg max-w-2xl font-light">
{{ t('hero.subtitle') }}
</p>
<div class="flex flex-col sm:flex-row gap-5">
<a
href="https://deotaland.com"
class="px-9 py-4 rounded-full border border-white/50 bg-black/20 text-white font-semibold hover:bg-white hover:text-black transition-all text-lg"
:style="{ backdropFilter: supportsBackdropFilter ? 'blur(4px)' : 'none' }"
>
{{ t('hero.explore') }}
</a>
<button
@click="$router.push('czhome')"
class="px-9 py-4 rounded-full bg-white text-black font-semibold hover:bg-gray-200 transition-all text-lg shadow-[0_0_20px_rgba(255,255,255,0.3)] cursor-pointer"
>
{{ t('hero.start') }}
</button>
</div>
</div>
</MotionCom>
</div>
</div>
</div>
<!-- Creation Canvas Section -->
<section class="py-0 md:py-24 relative overflow-hidden">
<!-- Background gradient hint -->
<div class="absolute top-0 left-1/2 -translate-x-1/2 w-[800px] h-[500px] bg-gray-900/50 blur-[120px] rounded-full pointer-events-none" />
<div class="container mx-auto px-6 relative z-10">
<MotionCom>
<div class="text-center mb-16">
<h2 class="text-4xl md:text-5xl font-bold mb-4 text-white">
{{ t('canvas.title') }}
</h2>
<p class="text-gray-400 text-lg md:text-xl">
{{ t('canvas.subtitle') }}
</p>
</div>
</MotionCom>
<MotionCom>
<!-- Workflow Visualization Container -->
<div class="relative w-full max-w-6xl mx-auto bg-gray-900/40 border border-gray-800 rounded-3xl p-8 md:p-12"
:style="{ backdropFilter: supportsBackdropFilter ? 'blur(4px)' : 'none' }"
>
<!-- Responsive Flex Layout: Stack on mobile, Row on desktop -->
<div class="flex flex-col md:flex-row items-center justify-between gap-8 md:gap-4">
<!-- LEVEL 1: INPUTS (Stacked Vertically) -->
<div class="flex flex-col gap-6 w-full md:w-auto">
<!-- Input A: Prompt -->
<div class="flex items-center gap-4 bg-black/80 p-4 rounded-xl border border-gray-800 hover:border-gray-600 transition-colors w-full md:w-64 shadow-lg">
<div class="w-12 h-12 rounded-full bg-gray-800 flex items-center justify-center text-purple-400 shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 7 4 4 20 4 20 7"></polyline><line x1="9" y1="20" x2="15" y2="20"></line><line x1="12" y1="4" x2="12" y2="20"></line></svg>
</div>
<div>
<span class="block text-sm font-bold text-gray-200">{{ t('canvas.prompt') }}</span>
<span class="text-xs text-gray-500">"A youthful and lovely girl"</span>
</div>
</div>
<!-- Input B: Reference -->
<div class="flex items-center gap-4 bg-black/80 p-4 rounded-xl border border-gray-800 hover:border-gray-600 transition-colors w-full md:w-64 shadow-lg">
<div class="w-12 h-12 rounded-full bg-gray-800 flex items-center justify-center text-blue-400 shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>
</div>
<div class="flex-1">
<span class="block text-sm font-bold text-gray-200 mb-1">{{ t('canvas.reference') }}</span>
<div class="w-full h-34 rounded-lg overflow-hidden border border-gray-700 relative group">
<img :src="refImage" alt="Dog Reference" class="w-full h-full object-cover opacity-80 group-hover:scale-110 transition-transform duration-500" />
<div class="absolute bottom-1 right-1 bg-black/60 px-1 rounded text-[10px] text-white">{{ t('canvas.referenceText') }}</div>
</div>
</div>
</div>
</div>
<!-- Connector Arrow 1 -->
<div class="flex flex-col items-center justify-center text-gray-600">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="rotate-90 md:rotate-0"><path d="M5 12h14"></path><path d="m12 5 7 7-7 7"></path></svg>
</div>
<!-- LEVEL 2: 3D MODEL -->
<div class="flex flex-col gap-4 bg-black/80 p-5 rounded-2xl border border-gray-800 w-full md:w-64 shadow-lg hover:border-gray-600 transition-colors">
<div class="flex items-center gap-3 mb-2">
<div class="w-10 h-10 rounded-full bg-gray-800 flex items-center justify-center text-green-400">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"></rect><line x1="3" y1="9" x2="21" y2="9"></line><line x1="9" y1="21" x2="9" y2="9"></line></svg>
</div>
<span class="text-sm font-bold text-gray-200">{{ t('canvas.model3d') }}</span>
</div>
<div class="w-full aspect-square rounded-xl overflow-hidden border border-gray-700 relative bg-gray-900 group">
<img :src="model3dImage" alt="3D Pumpkin Dog" class="w-full h-full object-cover opacity-90 group-hover:scale-110 transition-transform duration-500" />
<div class="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/grid-me.png')] opacity-30 pointer-events-none"></div>
<div class="absolute bottom-2 left-2 bg-green-900/80 px-2 py-0.5 rounded text-[10px] text-green-100 font-mono">.OBJ Generated</div>
</div>
<p class="text-xs text-gray-500 text-center">{{ t('canvas.autoRigged') }}</p>
</div>
<!-- Connector Arrow 2 -->
<div class="flex flex-col items-center justify-center text-gray-600">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="rotate-90 md:rotate-0"><path d="M5 12h14"></path><path d="m12 5 7 7-7 7"></path></svg>
</div>
<!-- LEVEL 3: REAL ROBOT -->
<div class="flex flex-col gap-4 bg-gradient-to-b from-purple-900/20 to-blue-900/20 p-5 rounded-2xl border border-purple-500/40 w-full md:w-72 shadow-[0_0_30px_rgba(147,51,234,0.1)]">
<div class="flex items-center gap-3 mb-2">
<div class="w-10 h-10 rounded-full bg-purple-600 flex items-center justify-center text-white shadow-lg shadow-purple-600/50">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"></rect><circle cx="12" cy="12" r="2"></circle><path d="M7 7h10v10"></path></svg>
</div>
<span class="text-sm font-bold text-white">{{ t('canvas.realRobot') }}</span>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-yellow-400 fill-yellow-400 ml-auto animate-pulse"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
</div>
<div class="w-full aspect-square rounded-xl overflow-hidden border border-purple-500/30 relative group bg-gray-900">
<img :src="realRobotImage" alt="Real Robot Toy" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700" />
<div class="absolute inset-0 bg-gradient-to-t from-purple-900/40 to-transparent"></div>
</div>
<p class="text-xs text-purple-200 text-center font-medium">{{ t('canvas.alive') }}</p>
</div>
</div>
</div>
</MotionCom>
</div>
</section>
<MotionCom>
<!-- Companionship Section -->
<section class="py-24 border-t border-gray-900 ">
<div class="container mx-auto px-6 flex flex-col items-center text-center">
<h2 class="text-4xl md:text-6xl font-bold mb-6 tracking-tight">
{{ t('companionship.title') }}
</h2>
<h3 class="text-2xl md:text-3xl text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-pink-400 font-semibold mb-8">
{{ t('companionship.powered') }}
</h3>
<p class="text-xl text-gray-400 max-w-2xl mb-12 leading-relaxed">
{{ t('companionship.subtitle') }}
</p>
<a
href="https://deotaland.com"
class="pointer-events-auto inline-flex items-center justify-center px-8 py-4 text-base font-bold text-white bg-purple-600 rounded-full hover:scale-105 transition-transform duration-200"
>
{{ t('companionship.examples') }}
</a>
<!-- Optional Visual Element below -->
<div class="mt-16 w-full max-w-4xl h-64 md:h-96 rounded-3xl overflow-hidden relative">
<img
src="https://draft-user.s3.us-east-2.amazonaws.com/images/41d42aa6-9ada-49b8-8a54-0d2595c0816a.webp"
alt="Robot Companion Context"
class="w-full h-full object-cover opacity-90 hover:opacity-100 transition-opacity duration-500"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/10 via-transparent to-transparent"></div>
</div>
</div>
</section>
</MotionCom>
<MotionCom>
<!-- Middle Text Section -->
<section class="py-12 text-center">
<div class="container mx-auto px-6 max-w-3xl">
<p class="text-lg md:text-xl text-gray-400 leading-relaxed">
Explore our community creations and get inspired to build your own unique AI robot companion.
Each robot has its own personality and story, waiting to be discovered and loved.
</p>
</div>
</section>
</MotionCom>
<MotionCom>
<!-- Robot Cards Section -->
<section class="py-20 overflow-hidden">
<div class="container mx-auto px-6">
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-6xl mx-auto">
<div
v-for="card in cards"
:key="card.id"
class="group relative rounded-3xl overflow-hidden aspect-[3/4] cursor-pointer"
>
<img
:src="card.img"
:alt="card.title"
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-90" />
<div class="absolute bottom-0 left-0 p-6">
<h3 class="text-xl font-bold text-white mb-1">{{ card.title }}</h3>
<p class="text-sm font-medium text-gray-300">{{ card.user }}</p>
</div>
</div>
</div>
</div>
</section>
</MotionCom>
<!-- Features Section -->
<section class="py-24 bg-gray-900/30">
<div class="container mx-auto px-6">
<div class="mb-16 text-center md:text-left">
<h2 class="text-4xl md:text-5xl font-bold text-white mb-4">
{{ t('features.title') }}
</h2>
<p class="text-xl text-gray-400">
{{ t('features.subtitle') }}
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-16">
<div
v-for="(item, idx) in t('features.list')"
:key="idx"
class="flex flex-col items-start gap-4"
>
<div class="p-3 bg-gray-800 rounded-xl mb-2">
<svg v-if="idx === 0" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-purple-400"><circle cx="12" cy="12" r="10"></circle><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path><path d="M2 12h20"></path></svg>
<svg v-else-if="idx === 1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-blue-400"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"></rect><line x1="3" y1="9" x2="21" y2="9"></line><line x1="9" y1="21" x2="9" y2="9"></line></svg>
<svg v-else-if="idx === 2" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-yellow-400"><circle cx="12" cy="12" r="10"></circle><path d="M8 14s1.5 2 4 2 4-2 4-2"></path><line x1="9" y1="9" x2="9.01" y2="9"></line><line x1="15" y1="9" x2="15.01" y2="9"></line></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-red-400"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path></svg>
</div>
<h3 class="text-2xl font-bold text-white">{{ item.title }}</h3>
<p class="text-gray-400 leading-relaxed text-lg">
{{ item.desc }}
</p>
</div>
</div>
</div>
</section>
<!-- Strong Engine Section -->
<section class="py-24 ">
<div class="container mx-auto px-6 text-center">
<h2 class="text-3xl md:text-4xl font-bold text-white mb-4">
{{ t('engine.title') }}
</h2>
<p class="text-lg text-gray-500 mb-16 max-w-2xl mx-auto">
{{ t('engine.subtitle') }}
</p>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 max-w-4xl mx-auto">
<div
v-for="(name, i) in logos"
:key="i"
class="h-24 bg-gray-900 rounded-lg flex items-center justify-center border border-gray-800 hover:border-gray-600 transition-colors"
>
<span class="text-gray-500 font-semibold text-sm">{{ name }}</span>
</div>
</div>
</div>
</section>
<!-- Community Call Section -->
<section class="py-32 bg-gradient-to-b from-black to-gray-900 border-b border-gray-800">
<div class="container mx-auto px-6 text-center">
<h2 class="text-5xl md:text-7xl font-bold text-white mb-12 tracking-tighter">
{{ t('community.title') }}
</h2>
<a
href="#"
class="inline-block px-10 py-4 text-lg font-bold text-black bg-white rounded-full hover:bg-gray-200 transition-colors"
>
{{ t('community.join') }}
</a>
</div>
</section>
</main>
<!-- Footer -->
<footer class="pointer-events-auto text-white py-16 border-t border-gray-900">
<div class="container mx-auto px-6">
<div class="flex flex-col md:flex-row justify-between items-start gap-12">
<!-- Logo -->
<div class="mb-4 md:mb-0">
<span class="text-2xl font-bold tracking-tighter">Deotaland</span>
</div>
<!-- Links Columns -->
<div class="flex flex-wrap gap-12 md:gap-24">
<!-- Socials -->
<div class="flex flex-col gap-4">
<h4 class="font-semibold text-gray-500 text-sm uppercase tracking-wider">{{ t('footer.socials') }}</h4>
<div class="flex gap-4">
<a href="https://x.com/deotaland" class="text-gray-400 hover:text-white"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 4s-.7 2.1-2 3.4c1.6 10-9.4 17.3-18 11.6 2.2.1 4.4-.6 6-2C3 15.5.5 9.6 3 5c2.2 2.6 5.6 4.1 9 4-.9-4.2 4-6.6 7-3.8 1.1 0 3-1.2 3-1.2z"></path></svg></a>
<a href="https://www.youtube.com/@Deotaland" class="text-gray-400 hover:text-white"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 17a24.12 24.12 0 0 1 0-10 2 2 0 0 1 1.4-1.4 49.56 49.56 0 0 1 16.2 0A2 2 0 0 1 21.5 7a24.12 24.12 0 0 1 0 10 2 2 0 0 1-1.4 1.4 49.55 49.55 0 0 1-16.2 0A2 2 0 0 1 2.5 17"></path><path d="M9.75 15.02v-4.04l5.5 2.02-5.5 2.02Z"></path></svg></a>
<a href="https://www.instagram.com/deotaland/" class="text-gray-400 hover:text-white"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="20" x="2" y="2" rx="5" ry="5"></rect><path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"></path><line x1="17.5" x2="17.51" y1="6.5" y2="6.5"></line></svg></a>
<a href="https://www.tiktok.com/@deotalandofficial" class="text-gray-400 hover:text-white font-bold text-sm flex items-center h-[20px]">TikTok</a>
<a href="https://discord.gg/feuFGPpY" class="text-gray-400 hover:text-white font-bold text-sm flex items-center h-[20px]">Discord</a>
</div>
</div>
<!-- Language -->
<div class="flex flex-col gap-4">
<h4 class="font-semibold text-gray-500 text-sm uppercase tracking-wider">{{ t('footer.language') }}</h4>
<div class="flex flex-col gap-2">
<a href="#" @click.prevent="switchLanguage('en')" class="text-gray-300 hover:text-white text-sm">English</a>
<a href="#" @click.prevent="switchLanguage('zh')" class="text-gray-300 hover:text-white text-sm">中文</a>
</div>
</div>
<!-- Company -->
<div class="flex flex-col gap-4">
<h4 class="font-semibold text-gray-500 text-sm uppercase tracking-wider">{{ t('footer.company') }}</h4>
<div class="flex flex-col gap-2">
<a href="#" class="text-gray-300 hover:text-white text-sm">{{ t('footer.about') }}</a>
</div>
</div>
<!-- Action -->
<div class="flex flex-col gap-4">
<button
@click="scrollToTop"
class="text-gray-300 hover:text-white text-sm text-left"
>
{{ t('footer.backTop') }}
</button>
</div>
</div>
</div>
<div >
</div>
<div class="mt-16 pt-8 border-t border-gray-900 text-center md:text-left text-sm text-gray-600">
{{ t('footer.copyright') }}
</div>
</div>
</footer>
</div>
</Bg>
</template>
<script setup>
import MotionCom from './motion.vue'
import spline from './spline.vue';
import { ref, onMounted, onUnmounted, computed } from 'vue';
import Bg from './bg.vue'
const center = 'https://draft-user.s3.us-east-2.amazonaws.com/images/c175585a-20c2-48b3-8939-32bbdb25814b.webp'
const center1 = 'https://draft-user.s3.us-east-2.amazonaws.com/images/ecf39871-52c5-45ad-9f9e-6eafd838ce54.webp'
const center2 = 'https://draft-user.s3.us-east-2.amazonaws.com/images/f7c4454e-1781-448e-9c70-b087b64f380e.webp'
const center3 = 'https://draft-user.s3.us-east-2.amazonaws.com/images/1e74bf93-6be3-46ae-a6d5-5ad02d0b7712.webp'
const center4 = 'https://draft-user.s3.us-east-2.amazonaws.com/images/64672da8-a034-4168-b9e7-cda985558f7e.webp'
const center5 = 'https://draft-user.s3.us-east-2.amazonaws.com/images/a77f66c4-e7c0-43a6-8e75-0eac49402c06.webp'
const center6 = 'https://draft-user.s3.us-east-2.amazonaws.com/images/de3cc66c-909b-4b03-9e73-5180df2bc374.webp'
const center7 = 'https://draft-user.s3.us-east-2.amazonaws.com/images/bc62a209-9a54-4d1e-926a-b3ef66fdbd29.webp'
const center8 = 'https://draft-user.s3.us-east-2.amazonaws.com/images/9e012193-5576-4a9e-9f38-eecb8705d8a4.webp'
const center9 = 'https://draft-user.s3.us-east-2.amazonaws.com/images/65bb1613-0ff9-43c4-a5b9-978c2507ca91.webp'
const viewportHeight = ref(window.innerHeight);
const isMobile = ref(window.innerWidth < 768);
const supportsSticky = ref(true);
const supportsBackdropFilter = ref(true);
const checkStickySupport = () => {
const testEl = document.createElement('div');
testEl.style.position = 'sticky';
testEl.style.top = '0';
supportsSticky.value = testEl.style.position === 'sticky';
};
const checkBackdropFilterSupport = () => {
const testEl = document.createElement('div');
testEl.style.backdropFilter = 'blur(10px)';
supportsBackdropFilter.value = testEl.style.backdropFilter !== '';
};
//
const i18n = {
en: {
nav: {
// creator: 'Creator',
// land: 'Land',
// pricing: 'Pricing',
creator: 'Creator',
done: 'D one',
about: 'About us'
},
hero: {
title: 'Create with Deotaland',
subtitle: 'Bring your own AI robot companion to life',
explore: 'Explore More',
start: 'Start Now'
},
canvas: {
title: 'Your Creation canvas DeotaBoard',
subtitle: 'Build your robot in an infinite canvas, together with AI.',
prompt: 'Prompt / Idea',
reference: 'Reference',
model3d: '3D Model',
realRobot: 'Real Robot',
autoRigged: 'Auto-rigged mesh ready for core',
alive: 'Alive on your desktop',
referenceText: 'Reference'
},
companionship: {
title: 'Born for Personal Companionship',
powered: 'Powered by AI',
subtitle: 'Cute companions, emotional partners, desktop friends... All in one Deotaland DIY robot.',
examples: 'See Robot Examples'
},
features: {
title: 'From Zero to Your Own Robot Friend',
subtitle: 'Simple enough for beginners, powerful enough for makers.',
list: [
{ title: 'Stunning Custom Looks', desc: 'Generate unique robot designs from your ideas or images.' },
{ title: 'From Idea to 3D Model', desc: 'Turn concepts into printable 3D shells ready for your robot core.' },
{ title: 'One-Click Expression Packs', desc: 'Create and apply DIY eye styles and animated expressions.' },
{ title: 'Always-On AI Companion', desc: 'Long-term memory, emotional responses, and daily interactions.' }
]
},
engine: {
title: 'Strong Engine Behind',
subtitle: 'We combine leading AI and 3D technologies to power your DIY robots.'
},
community: {
title: 'Deotaland is for All Creators',
join: 'Join the Creator Community'
},
footer: {
socials: 'Socials',
language: 'Language',
company: 'Company',
about: 'About Us',
backTop: 'Back to top',
copyright: '©2025 Deotaland limited. All rights reserved.'
}
},
zh: {
nav: {
// creator: '',
// land: '',
// pricing: '',
creator: 'Creator',
done: 'D1',
about: 'About'
},
hero: {
title: '使用 Deotaland 创作',
subtitle: '让你的 AI 机器人伴侣成为现实',
explore: '探索更多',
start: '立即开始'
},
canvas: {
title: '你的创作画布 DeotaBoard',
subtitle: '在无限画布中,与 AI 一起打造你的机器人。',
prompt: '提示 / 创意',
reference: '参考',
model3d: '3D 模型',
realRobot: '真实机器人',
autoRigged: '自动绑定网格,随时准备安装核心',
alive: '活跃在你的桌面上',
referenceText: '参考'
},
companionship: {
title: '为个人陪伴而生',
powered: '由 AI 驱动',
subtitle: '可爱的伴侣、情感伙伴、桌面朋友... 尽在 Deotaland DIY 机器人中。',
examples: '查看机器人示例'
},
features: {
title: '从零到属于你的机器人朋友',
subtitle: '对初学者足够简单,对创客足够强大。',
list: [
{ title: '惊艳的自定义外观', desc: '从你的想法或图像生成独特的机器人设计。' },
{ title: '从想法到 3D 模型', desc: '将概念转化为可打印的 3D 外壳,随时准备安装你的机器人核心。' },
{ title: '一键表情包', desc: '创建并应用 DIY 眼睛样式和动画表情。' },
{ title: '全天候 AI 伴侣', desc: '长期记忆、情感反应和日常互动。' }
]
},
engine: {
title: '强大的引擎支持',
subtitle: '我们结合领先的 AI 和 3D 技术,为你的 DIY 机器人提供动力。'
},
community: {
title: 'Deotaland 面向所有创作者',
join: '加入创作者社区'
},
footer: {
socials: '社交',
language: '语言',
company: '公司',
about: '关于我们',
backTop: '返回顶部',
copyright: '©2025 Deotaland 有限公司。保留所有权利。'
}
}
};
// Navbar state
const isScrolled = ref(false);
const isMobileMenuOpen = ref(false);
//
const currentLang = ref(localStorage.getItem('lang') || 'en');
//
const switchLanguage = (lang) => {
currentLang.value = lang;
localStorage.setItem('lang', lang);
};
//
const t = (key) => {
const keys = key.split('.');
let result = i18n[currentLang.value];
for (const k of keys) {
if (result && result[k] !== undefined) {
result = result[k];
} else {
return key; //
}
}
return result;
};
// Hero section state
const containerRef = ref(null);
const scrollYProgress = ref(0);
// Scroll event handler
const handleScroll = () => {
isScrolled.value = window.scrollY > 50;
if (containerRef.value) {
const viewportHeight = window.innerHeight;
const scrollPosition = window.scrollY;
const containerTop = containerRef.value.offsetTop;
const containerHeight = containerRef.value.offsetHeight;
const progress = Math.max(0, Math.min(1,
(scrollPosition - containerTop + viewportHeight) /
(containerHeight + viewportHeight)
));
scrollYProgress.value = progress;
}
};
// Handle window resize
const handleResize = () => {
viewportHeight.value = window.innerHeight;
isMobile.value = window.innerWidth < 768;
};
// Scroll to top function
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
};
// Scale transformation based on scroll progress
const scale = computed(() => {
// Zoom out more aggressively to ensure all 15 images are visible
// Final scale is 0.2 for mobile and 0.3 for desktop
const mappedProgress = Math.min(scrollYProgress.value / 0.8, 1);
let initNum = window.innerWidth < 768 ? 3.5 : 5.5;
let outNum = window.innerWidth < 768 ? 3.2 : 5.0;
return initNum - (mappedProgress * outNum);
});
// Nav links
const navLinks = [
{ name: 'Creator', href: '#' },
{ name: 'Land', href: '#' },
{ name: 'Pricing', href: '#' },
];
// Creation Canvas Images
const refImage = 'https://draft-user.s3.us-east-2.amazonaws.com/images/0185a1f7-563a-4af9-9569-65d81f710c52.webp';
const model3dImage = 'https://draft-user.s3.us-east-2.amazonaws.com/images/e5a1408b-b695-431f-b03e-0b9b06f1b82f.webp';
const realRobotImage = 'https://draft-user.s3.us-east-2.amazonaws.com/images/ce2f6979-4b3c-499f-b179-80abbf4d7431.webp';
// Robot Cards
const cards = [
{ id: 1, title: 'Custom Robot', user: '@Wownny wolf', img:'https://draft-user.s3.us-east-2.amazonaws.com/images/8301d540-a4b2-4346-ac3d-1f9ad8b34bad.webp' },
{ id: 2, title: 'Custom Robot', user: '@Lil Moods', img: 'https://draft-user.s3.us-east-2.amazonaws.com/images/c6253d79-47dd-4ced-8806-ded34b7ee184.webp' },
{ id: 3, title: 'Custom Robot', user: '@Deo Monkey', img:'https://draft-user.s3.us-east-2.amazonaws.com/images/eb61f9e9-94dd-4920-813d-aa635eb73e24.webp' },
];
// Strong Engine Logos
const logos = [
"AI Engine 1", "3D Engine 2", "Voice Engine 3", "Memory Engine 4",
"Physics Core", "Render Tech", "Motion API", "Vision OS"
];
// Mount and unmount scroll event
onMounted(() => {
checkStickySupport();
checkBackdropFilterSupport();
window.addEventListener('scroll', handleScroll);
window.addEventListener('resize', handleResize);
handleScroll();
});
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleResize);
});
</script>
<style scoped>
/* Additional custom styles can be added here */
</style>

File diff suppressed because it is too large Load Diff

View File

@ -10,8 +10,9 @@
<header <header
:class="[ :class="[
'fixed top-0 left-0 right-0 z-900 transition-all duration-300 pointer-events-auto', 'fixed top-0 left-0 right-0 z-900 transition-all duration-300 pointer-events-auto',
isScrolled ? 'bg-black/90 backdrop-blur-md py-4' : 'bg-transparent py-6' isScrolled ? 'bg-black/90 py-4' : 'bg-transparent py-6'
]" ]"
:style="{ backdropFilter: isScrolled && supportsBackdropFilter ? 'blur(12px)' : 'none' }"
> >
<div class="container mx-auto px-6 flex items-center justify-between"> <div class="container mx-auto px-6 flex items-center justify-between">
<!-- Logo --> <!-- Logo -->
@ -131,7 +132,7 @@
<!-- Hero Section --> <!-- Hero Section -->
<div ref="containerRef" class="relative h-[300vh] w-full "> <div ref="containerRef" class="relative h-[300vh] w-full ">
<!-- Sticky Container --> <!-- Sticky Container -->
<div class="sticky top-0 h-[100dvh] w-full overflow-hidden"> <div class="sticky top-0 h-[100vh] w-full overflow-hidden" :style="{ height: viewportHeight + 'px', position: supportsSticky ? 'sticky' : 'relative' }">
<!-- Layer 1: Background Animation (Grid) --> <!-- Layer 1: Background Animation (Grid) -->
<div class="absolute inset-0 flex items-center justify-center overflow-hidden"> <div class="absolute inset-0 flex items-center justify-center overflow-hidden">
@ -218,7 +219,8 @@
<div class="flex flex-col sm:flex-row gap-5"> <div class="flex flex-col sm:flex-row gap-5">
<a <a
href="https://deotaland.com" href="https://deotaland.com"
class="px-9 py-4 rounded-full border border-white/50 bg-black/20 backdrop-blur-sm text-white font-semibold hover:bg-white hover:text-black transition-all text-lg" class="px-9 py-4 rounded-full border border-white/50 bg-black/20 text-white font-semibold hover:bg-white hover:text-black transition-all text-lg"
:style="{ backdropFilter: supportsBackdropFilter ? 'blur(4px)' : 'none' }"
> >
{{ t('hero.explore') }} {{ t('hero.explore') }}
</a> </a>
@ -255,7 +257,9 @@
</MotionCom> </MotionCom>
<MotionCom> <MotionCom>
<!-- Workflow Visualization Container --> <!-- Workflow Visualization Container -->
<div class="relative w-full max-w-6xl mx-auto bg-gray-900/40 border border-gray-800 rounded-3xl p-8 md:p-12 backdrop-blur-sm"> <div class="relative w-full max-w-6xl mx-auto bg-gray-900/40 border border-gray-800 rounded-3xl p-8 md:p-12"
:style="{ backdropFilter: supportsBackdropFilter ? 'blur(4px)' : 'none' }"
>
<!-- Responsive Flex Layout: Stack on mobile, Row on desktop --> <!-- Responsive Flex Layout: Stack on mobile, Row on desktop -->
<div class="flex flex-col md:flex-row items-center justify-between gap-8 md:gap-4"> <div class="flex flex-col md:flex-row items-center justify-between gap-8 md:gap-4">
@ -551,8 +555,7 @@ import MotionCom from './motion.vue'
import spline from './spline.vue'; import spline from './spline.vue';
import { ref, onMounted, onUnmounted, computed } from 'vue'; import { ref, onMounted, onUnmounted, computed } from 'vue';
import Bg from './bg.vue' import Bg from './bg.vue'
// import dog from '@/assets/home/dog.webp'
// import qdog from '@/assets/home/qdog.webp'
const center = 'https://draft-user.s3.us-east-2.amazonaws.com/images/c175585a-20c2-48b3-8939-32bbdb25814b.webp' const center = 'https://draft-user.s3.us-east-2.amazonaws.com/images/c175585a-20c2-48b3-8939-32bbdb25814b.webp'
const center1 = 'https://draft-user.s3.us-east-2.amazonaws.com/images/ecf39871-52c5-45ad-9f9e-6eafd838ce54.webp' const center1 = 'https://draft-user.s3.us-east-2.amazonaws.com/images/ecf39871-52c5-45ad-9f9e-6eafd838ce54.webp'
const center2 = 'https://draft-user.s3.us-east-2.amazonaws.com/images/f7c4454e-1781-448e-9c70-b087b64f380e.webp' const center2 = 'https://draft-user.s3.us-east-2.amazonaws.com/images/f7c4454e-1781-448e-9c70-b087b64f380e.webp'
@ -563,18 +566,25 @@ const center6 = 'https://draft-user.s3.us-east-2.amazonaws.com/images/de3cc66c-9
const center7 = 'https://draft-user.s3.us-east-2.amazonaws.com/images/bc62a209-9a54-4d1e-926a-b3ef66fdbd29.webp' const center7 = 'https://draft-user.s3.us-east-2.amazonaws.com/images/bc62a209-9a54-4d1e-926a-b3ef66fdbd29.webp'
const center8 = 'https://draft-user.s3.us-east-2.amazonaws.com/images/9e012193-5576-4a9e-9f38-eecb8705d8a4.webp' const center8 = 'https://draft-user.s3.us-east-2.amazonaws.com/images/9e012193-5576-4a9e-9f38-eecb8705d8a4.webp'
const center9 = 'https://draft-user.s3.us-east-2.amazonaws.com/images/65bb1613-0ff9-43c4-a5b9-978c2507ca91.webp' const center9 = 'https://draft-user.s3.us-east-2.amazonaws.com/images/65bb1613-0ff9-43c4-a5b9-978c2507ca91.webp'
// Window size reactive state
const getResponsiveWidthStyle = () => { const viewportHeight = ref(window.innerHeight);
const isMobile = window.innerWidth < 768;
return {
position: 'fixed',
width: isMobile ? '300vw' : '100vw',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)'
};
};
const isMobile = ref(window.innerWidth < 768); const isMobile = ref(window.innerWidth < 768);
const supportsSticky = ref(true);
const supportsBackdropFilter = ref(true);
const checkStickySupport = () => {
const testEl = document.createElement('div');
testEl.style.position = 'sticky';
testEl.style.top = '0';
supportsSticky.value = testEl.style.position === 'sticky';
};
const checkBackdropFilterSupport = () => {
const testEl = document.createElement('div');
testEl.style.backdropFilter = 'blur(10px)';
supportsBackdropFilter.value = testEl.style.backdropFilter !== '';
};
// //
const i18n = { const i18n = {
en: { en: {
@ -729,17 +739,14 @@ const scrollYProgress = ref(0);
// Scroll event handler // Scroll event handler
const handleScroll = () => { const handleScroll = () => {
// isScrolled.value = window.scrollY > 50; isScrolled.value = window.scrollY > 50;
isScrolled.value = false;
// Calculate scroll progress for hero section
if (containerRef.value) { if (containerRef.value) {
const viewportHeight = window.innerHeight; const viewportHeight = window.innerHeight;
const scrollPosition = window.scrollY; const scrollPosition = window.scrollY;
const containerTop = containerRef.value.offsetTop; const containerTop = containerRef.value.offsetTop;
const containerHeight = containerRef.value.offsetHeight; const containerHeight = containerRef.value.offsetHeight;
// Calculate progress from 0 to 1 as we scroll through the container
const progress = Math.max(0, Math.min(1, const progress = Math.max(0, Math.min(1,
(scrollPosition - containerTop + viewportHeight) / (scrollPosition - containerTop + viewportHeight) /
(containerHeight + viewportHeight) (containerHeight + viewportHeight)
@ -749,6 +756,12 @@ const handleScroll = () => {
} }
}; };
// Handle window resize
const handleResize = () => {
viewportHeight.value = window.innerHeight;
isMobile.value = window.innerWidth < 768;
};
// Scroll to top function // Scroll to top function
const scrollToTop = () => { const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
@ -789,13 +802,16 @@ const logos = [
// Mount and unmount scroll event // Mount and unmount scroll event
onMounted(() => { onMounted(() => {
checkStickySupport();
checkBackdropFilterSupport();
window.addEventListener('scroll', handleScroll); window.addEventListener('scroll', handleScroll);
// Initial call to set initial state window.addEventListener('resize', handleResize);
handleScroll(); handleScroll();
}); });
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('scroll', handleScroll); window.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleResize);
}); });
</script> </script>

View File

@ -54,7 +54,7 @@ const detectEnvironment = async () => {
}) })
environmentInfo.value = result environmentInfo.value = result
isDetecting.value = false isDetecting.value = false
if (result.isDomestic) { if (result.isDomestic) {//
handleDomesticRedirect() handleDomesticRedirect()
} else { } else {
handleInternationalRedirect() handleInternationalRedirect()

View File

@ -50,7 +50,6 @@
<div class="points-section" > <div class="points-section" >
<h2>{{ $t('userCenter.points.title') }}</h2> <h2>{{ $t('userCenter.points.title') }}</h2>
<!-- 积分明细和规则并排容器 --> <!-- 积分明细和规则并排容器 -->
<div class="points-content-container"> <div class="points-content-container">
<div class="points-list"> <div class="points-list">
<div class="total-points"> <div class="total-points">
@ -121,33 +120,10 @@
</div> </div>
</div> </div>
<!-- 优惠券区域 --> <!-- 优惠券区域 -->
<div class="voucher-section"> <div class="voucher-section" v-if="userData.voucherList.length > 0">
<h2>{{ $t('userCenter.voucher.title') }}</h2> <h2>{{ $t('userCenter.voucher.title') }}</h2>
<!-- 优惠券数量统计 -->
<!-- <div class="voucher-count-container">
<div class="voucher-count-stats">
<div class="count-item">
<span class="count-label">{{ $t('userCenter.voucher.availableCount') }}</span>
<span class="count-value available">{{ userData.voucherCount.availableCount }}</span>
</div>
<div class="count-item">
<span class="count-label">{{ $t('userCenter.voucher.usedCount') }}</span>
<span class="count-value used">{{ userData.voucherCount.usedCount }}</span>
</div>
<div class="count-item">
<span class="count-label">{{ $t('userCenter.voucher.expiredCount') }}</span>
<span class="count-value expired">{{ userData.voucherCount.expiredCount }}</span>
</div>
<div class="count-item">
<span class="count-label">{{ $t('userCenter.voucher.totalCount') }}</span>
<span class="count-value total">{{ userData.voucherCount.totalCount }}</span>
</div>
</div>
</div> -->
<!-- 优惠券列表 --> <!-- 优惠券列表 -->
<div class="voucher-list-container"> <div class="voucher-list-container" >
<div v-if="loading" class="loading-container"> <div v-if="loading" class="loading-container">
<el-skeleton :rows="3" animated /> <el-skeleton :rows="3" animated />
</div> </div>

10
dockerfile Normal file
View File

@ -0,0 +1,10 @@
# Dockerfile.simple
FROM node:20-alpine
WORKDIR /dist
COPY . .
RUN npm install && npm run build
# 挂载点提示
CMD ["echo", "Dist files are ready in /build/dist"]

26
hosts Normal file
View File

@ -0,0 +1,26 @@
# Copyright (c) 1993-2009 Microsoft Corp.
#
# This is a sample HOSTS file used by Microsoft TCP/IP for Windows.
#
# This file contains the mappings of IP addresses to host names. Each
# entry should be kept on an individual line. The IP address should
# be placed in the first column followed by the corresponding host name.
# The IP address and the host name should be separated by at least one
# space.
#
# Additionally, comments (such as these) may be inserted on individual
# lines or following the machine name denoted by a '#' symbol.
#
# For example:
#
# 102.54.94.97 rhino.acme.com # source server
# 38.25.63.10 x.acme.com # x client host
# localhost name resolution is handled within DNS itself.
# 127.0.0.1 localhost
# ::1 localhost
192.168.0.190 admin.deotaland.local
192.168.0.190 www.deotaland.local
192.168.0.190 api.deotaland.local
192.168.0.190 git.deotaland.local

View File

@ -6,5 +6,11 @@ const login = {
OAUTH_GOOGLE:{url:'/api-base/user/oauth/google',method:'POST',isLoading:true},// google弹窗授权 OAUTH_GOOGLE:{url:'/api-base/user/oauth/google',method:'POST',isLoading:true},// google弹窗授权
FORGOT_PASSWORD:{url:'/api-base/user/forgot-password',method:'POST',isLoading:true},// 修改密码 FORGOT_PASSWORD:{url:'/api-base/user/forgot-password',method:'POST',isLoading:true},// 修改密码
REFRESH_TOKEN:{url:'/api-base/user/oauth/google/refresh',method:'POST'},// googleRefreshToken刷新 REFRESH_TOKEN:{url:'/api-base/user/oauth/google/refresh',method:'POST'},// googleRefreshToken刷新
SEND_SMS_CODE:{url:'/api-base/user/send-sms-code',method:'POST'},// 发送短信验证码
REGISTER_PHONE:{url:'/api-base/user/register-phone',method:'POST'},// 使用手机号和短信验证码注册新账号
LOGIN_PHONE:{url:'/api-base/user/login-phone',method:'POST'},// 使用手机号和密码登录
RESET_PASSWORD_PHONE:{url:'/api-base/user/reset-password-phone',method:'POST'},// 使用手机号和短信验证码重置密码
LOGIN_PHONE_CODE:{url:'/api-base/user/login-phone-code',method:'POST'},// 使用手机号和短信验证码登录
} }
export default login; export default login;