优化移动端即梦页面

This commit is contained in:
RockYang
2025-08-07 22:27:09 +08:00
parent e456210944
commit 4e237c9560
14 changed files with 1906 additions and 1239 deletions

View File

@@ -0,0 +1,787 @@
/* JimengCreate Mobile Styles */
/* 自定义动画 */
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-out {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-10px);
}
}
@keyframes scale-up {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-fade-in {
animation: fade-in 0.3s ease-out;
}
.animate-fade-out {
animation: fade-out 0.3s ease-out;
}
.animate-scale-up {
animation: scale-up 0.3s ease-out;
}
/* 文本截断 */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 深色模式适配 */
@media (prefers-color-scheme: dark) {
.bg-gray-50 {
background-color: #1f2937;
}
.bg-white {
background-color: #374151;
}
.text-gray-900 {
color: #f9fafb;
}
.text-gray-700 {
color: #d1d5db;
}
.text-gray-600 {
color: #9ca3af;
}
.text-gray-500 {
color: #6b7280;
}
.border-gray-200 {
border-color: #4b5563;
}
.bg-gray-100:hover {
background-color: #4b5563;
}
}
/* 即梦创作页面专用样式 */
.jimeng-create {
min-height: 100vh;
background-color: #f9fafb;
/* 页面头部样式 */
&__header {
position: sticky;
top: 0;
z-index: 40;
background-color: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
&-content {
display: flex;
align-items: center;
padding: 0 1rem;
height: 3.5rem;
}
&-back-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
transition: background-color 0.2s;
&:hover {
background-color: #f3f4f6;
}
.iconfont {
color: #6b7280;
}
}
&-title {
flex: 1;
text-align: center;
font-size: 1.125rem;
font-weight: 600;
color: #111827;
}
&-spacer {
width: 2rem;
}
}
/* 主要内容区域 */
&__content {
padding: 1rem;
.space-y-6 > * + * {
margin-top: 1.5rem;
}
}
/* 功能分类选择 */
&__category-section {
background-color: white;
border-radius: 0.75rem;
padding: 1rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
&-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
&-button {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.75rem;
border-radius: 0.5rem;
border: 2px solid;
transition: all 0.2s;
&--active {
border-color: #3b82f6;
background-color: #eff6ff;
color: #1d4ed8;
}
&--inactive {
border-color: #e5e7eb;
background-color: #f9fafb;
color: #6b7280;
&:hover {
border-color: #d1d5db;
background-color: #f3f4f6;
}
}
.iconfont {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
span {
font-size: 0.875rem;
font-weight: 500;
}
}
}
/* 生成模式切换 */
&__mode-section {
background-color: white;
border-radius: 0.75rem;
padding: 1rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
&-content {
display: flex;
align-items: center;
justify-content: space-between;
}
&-title {
color: #111827;
font-weight: 500;
}
&-description {
font-size: 0.875rem;
color: #6b7280;
margin-top: 0.25rem;
}
}
/* 表单组件样式 */
&__form-section {
background-color: white;
border-radius: 0.75rem;
padding: 1rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
&-label {
display: block;
color: #374151;
font-weight: 500;
margin-bottom: 0.75rem;
}
&-textarea {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
resize: none;
transition: all 0.2s;
&:focus {
outline: none;
ring: 2px solid #3b82f6;
border-color: transparent;
}
}
&-counter {
text-align: right;
margin-top: 0.5rem;
font-size: 0.875rem;
color: #6b7280;
}
}
/* 图片上传样式 */
&__upload {
border: 2px dashed #d1d5db;
border-radius: 0.5rem;
padding: 1.5rem;
text-align: center;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: #60a5fa;
background-color: #eff6ff;
}
&-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
&-icon {
color: #3b82f6;
font-size: 1.5rem;
}
&-text {
color: #374151;
font-weight: 500;
}
&-preview {
position: relative;
.el-image {
width: 8rem;
height: 8rem;
border-radius: 0.5rem;
}
}
&-remove-btn {
position: absolute;
top: -0.5rem;
right: -0.5rem;
width: 1.5rem;
height: 1.5rem;
background-color: #ef4444;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background-color: #dc2626;
}
}
/* 多图片上传样式 */
&-multiple {
display: flex;
gap: 0.75rem;
&-item {
position: relative;
.el-image {
width: 6rem;
height: 6rem;
border-radius: 0.5rem;
}
}
&-add {
width: 6rem;
height: 6rem;
border: 2px dashed #d1d5db;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: border-color 0.2s;
&:hover {
border-color: #60a5fa;
}
.iconfont {
color: #9ca3af;
font-size: 1.25rem;
}
}
}
}
/* 滑块样式 */
&__slider-section {
.space-y-4 > * + * {
margin-top: 1rem;
}
&-header {
display: flex;
align-items: center;
gap: 0.5rem;
label {
display: block;
color: #374151;
font-weight: 500;
}
.el-tooltip {
.iconfont {
color: #9ca3af;
cursor: pointer;
}
}
}
}
/* 开关样式 */
&__switch-section {
display: flex;
align-items: center;
justify-content: space-between;
span {
color: #111827;
font-weight: 500;
}
}
/* 生成按钮 */
&__submit-btn {
position: sticky;
bottom: 1rem;
background-color: white;
border-radius: 0.75rem;
padding: 1rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
button {
width: 100%;
padding: 1rem;
background: linear-gradient(to right, #3b82f6, #8b5cf6);
color: white;
font-weight: 600;
border-radius: 0.75rem;
border: none;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
&:hover:not(:disabled) {
background: linear-gradient(to right, #2563eb, #7c3aed);
}
&:disabled {
background: linear-gradient(to right, #9ca3af, #9ca3af);
cursor: not-allowed;
}
.iconfont {
&.animate-spin {
animation: spin 1s linear infinite;
}
}
}
}
/* 作品列表样式 */
&__works {
padding: 1rem;
&-title {
font-size: 1.125rem;
font-weight: 600;
color: #111827;
margin-bottom: 1rem;
}
&-list {
.space-y-4 > * + * {
margin-top: 1rem;
}
}
&-item {
background-color: white;
border-radius: 0.75rem;
padding: 1rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
&-content {
display: flex;
gap: 1rem;
}
&-thumb {
flex-shrink: 0;
&-container {
position: relative;
width: 4rem;
height: 4rem;
border-radius: 0.5rem;
overflow: hidden;
background-color: #f3f4f6;
}
.el-image {
width: 100%;
height: 100%;
}
&-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: #f3f4f6;
.iconfont {
color: #9ca3af;
font-size: 1.25rem;
}
}
&-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.5);
opacity: 0;
transition: opacity 0.2s;
&:hover {
opacity: 1;
}
.iconfont {
color: white;
font-size: 1.25rem;
}
}
&-status {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
&--loading {
background-color: rgba(59, 130, 246, 0.2);
.iconfont {
color: #3b82f6;
font-size: 1.25rem;
animation: spin 1s linear infinite;
}
}
&--failed {
background-color: rgba(239, 68, 68, 0.2);
.iconfont {
color: #ef4444;
font-size: 1.25rem;
}
}
}
}
&-info {
flex: 1;
min-width: 0;
&-header {
display: flex;
align-items: start;
justify-content: space-between;
}
&-title {
color: #111827;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&-prompt {
color: #6b7280;
font-size: 0.875rem;
margin-top: 0.25rem;
}
&-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
&--failed {
color: #dc2626;
}
&--processing {
color: #2563eb;
.loading-spinner {
width: 0.75rem;
height: 0.75rem;
border: 1px solid #2563eb;
border-top-color: transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
}
}
}
&-tags {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
&-item {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
border-radius: 9999px;
&--primary {
background-color: #dbeafe;
color: #2563eb;
}
&--warning {
background-color: #fef3c7;
color: #d97706;
}
&--power {
background-color: #fed7aa;
color: #ea580c;
}
}
}
}
&-actions {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 1rem;
&-left {
display: flex;
gap: 0.5rem;
}
&-btn {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
border-radius: 0.5rem;
border: none;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 0.25rem;
.iconfont {
font-size: 0.75rem !important;
}
&--primary {
background-color: #2563eb;
color: white;
&:hover {
background-color: #1d4ed8;
}
}
&--success {
background-color: #16a34a;
color: white;
&:hover {
background-color: #15803d;
}
}
&--warning {
background-color: #ea580c;
color: white;
&:hover {
background-color: #dc2626;
}
}
&--danger {
background-color: #fef2f2;
color: #dc2626;
&:hover {
background-color: #fecaca;
}
}
&:disabled {
background-color: #9ca3af;
cursor: not-allowed;
}
}
}
}
&-loading {
display: flex;
justify-content: center;
padding: 1rem;
.iconfont {
color: #3b82f6;
font-size: 1.25rem;
animation: spin 1s linear infinite;
}
}
&-finished {
text-align: center;
padding: 1rem;
color: #6b7280;
}
}
/* 媒体预览弹窗 */
&__media-dialog {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.5);
&-content {
background-color: white;
border-radius: 1rem;
width: 100%;
max-width: 56rem;
max-height: 80vh;
}
&-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
border-bottom: 1px solid #e5e7eb;
h3 {
font-size: 1.125rem;
font-weight: 600;
color: #111827;
}
button {
padding: 0.5rem;
border: none;
background: none;
border-radius: 50%;
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background-color: #f3f4f6;
}
.iconfont {
color: #6b7280;
}
}
}
&-body {
padding: 1.5rem;
img, video {
width: 100%;
max-height: 60vh;
object-fit: contain;
border-radius: 0.5rem;
}
}
}
}
/* 旋转动画 */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -1,103 +0,0 @@
<template>
<div class="black-dialog">
<el-dialog v-model="showDialog" :title="title" :width="width" :before-close="cancel">
<div class="dialog-body">
<slot></slot>
</div>
<template #footer v-if="!hideFooter">
<div class="dialog-footer">
<el-button @click="cancel" style="--el-border-radius-base: 8px">{{
cancelText
}}</el-button>
<el-button type="primary" @click="$emit('confirm')" v-if="!hideConfirm">{{
confirmText
}}</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({
show: Boolean,
title: {
type: String,
default: 'Tips',
},
width: {
type: String,
default: 'auto',
},
hideFooter: {
type: Boolean,
default: false,
},
hideConfirm: {
type: Boolean,
default: false,
},
confirmText: {
type: String,
default: '确定',
},
cancelText: {
type: String,
default: '取消',
},
})
const emits = defineEmits(['confirm', 'cancal'])
const showDialog = ref(props.show)
watch(
() => props.show,
(newValue) => {
showDialog.value = newValue
}
)
const cancel = () => {
showDialog.value = false
emits('cancal')
}
</script>
<style lang="scss">
.black-dialog {
.dialog-body {
.form {
.form-item {
display: flex;
flex-flow: column;
font-family: 'Neue Montreal';
padding: 10px 0;
.label {
margin-bottom: 0.6rem;
margin-inline-end: 0.75rem;
color: #ffffff;
font-size: 1rem;
font-weight: 500;
}
.input {
display: flex;
padding: 10px;
text-align: left;
font-size: 1rem;
background: none;
border-radius: 0.375rem;
border: 1px solid #8f8f8f;
outline: none;
transition: border-color 0.5s ease, box-shadow 0.5s ease;
&:focus {
border-color: #0f7a71;
box-shadow: 0 0 5px #0f7a71;
}
}
}
}
}
}
</style>

View File

@@ -1,42 +0,0 @@
<template>
<el-select
v-model="model"
:placeholder="placeholder"
:value="value"
@change="$emit('update:value', $event)"
style="--el-border-radius-base: 20px"
>
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
>
</el-option>
</el-select>
</template>
<script>
export default {
name: "BlackSelect",
props: {
value: {
type: String,
default: ""
},
placeholder: {
type: String,
default: "请选择"
},
options: {
type: Array,
default: []
}
},
data() {
return {
model: this.value
};
}
};
</script>

View File

@@ -1,26 +0,0 @@
<template>
<el-switch
v-model="model"
:size="size"
@change="$emit('update:value', $event)"
/>
</template>
<script setup>
import { ref, watch } from "vue";
const props = defineProps({
value: Boolean,
size: {
type: String,
default: "default"
}
});
const model = ref(props.value);
watch(
() => props.value,
(newValue) => {
model.value = newValue;
}
);
</script>

View File

@@ -361,19 +361,19 @@ const routes = [
meta: { title: 'Suno音乐创作' },
path: '/mobile/suno',
name: 'mobile-suno',
component: () => import('@/views/mobile/pages/SunoCreate.vue'),
component: () => import('@/views/mobile/SunoCreate.vue'),
},
{
meta: { title: '视频生成' },
path: '/mobile/video',
name: 'mobile-video',
component: () => import('@/views/mobile/pages/VideoCreate.vue'),
component: () => import('@/views/mobile/VideoCreate.vue'),
},
{
meta: { title: '即梦AI' },
path: '/mobile/jimeng',
name: 'mobile-jimeng',
component: () => import('@/views/mobile/pages/JimengCreate.vue'),
component: () => import('@/views/mobile/JimengCreate.vue'),
},
],
},

View File

@@ -0,0 +1,410 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { httpGet, httpPost } from '@/utils/http'
import { showMessageError, showMessageOK, showLoading, closeLoading } from '@/utils/dialog'
import { showConfirmDialog } from 'vant'
export const useJimengStore = defineStore('mobile-jimeng', () => {
// 响应式数据
const activeCategory = ref('image_generation')
const useImageInput = ref(false)
const submitting = ref(false)
const listLoading = ref(false)
const listFinished = ref(false)
const currentList = ref([])
const showMediaDialog = ref(false)
const currentMediaUrl = ref('')
const currentPrompt = ref('')
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const currentPowerCost = ref(0)
const taskPulling = ref(true)
const tastPullHandler = ref(null)
// 功能分类
const categories = ref([
{ key: 'image_generation', name: '图像生成' },
{ key: 'image_editing', name: '图像编辑' },
{ key: 'image_effects', name: '图像特效' },
{ key: 'video_generation', name: '视频生成' },
])
// 选项数据
const imageSizeOptions = [
{ label: '512x512', value: '512x512' },
{ label: '768x768', value: '768x768' },
{ label: '1024x1024', value: '1024x1024' },
{ label: '1024x1536', value: '1024x1536' },
{ label: '1536x1024', value: '1536x1024' },
]
const videoAspectRatioOptions = [
{ label: '16:9', value: '16:9' },
{ label: '9:16', value: '9:16' },
{ label: '1:1', value: '1:1' },
{ label: '4:3', value: '4:3' },
]
const imageEffectsTemplateOptions = [
{ label: '亚克力装饰', value: 'acrylic_ornaments' },
{ label: '天使小雕像', value: 'angel_figurine' },
{ label: '毛毫3D拍立得', value: 'felt_3d_polaroid' },
{ label: '水彩插图', value: 'watercolor_illustration' },
]
// 功能参数
const textToImageParams = ref({
size: '1024x1024',
scale: 7.5,
use_pre_llm: false,
})
const imageToImageParams = ref({
image_input: [],
size: '1024x1024',
})
const imageEditParams = ref({
image_urls: [],
scale: 0.5,
})
const imageEffectsParams = ref({
image_input1: [],
template_id: '',
size: '1024x1024',
})
const textToVideoParams = ref({
aspect_ratio: '16:9',
})
const imageToVideoParams = ref({
image_urls: [],
aspect_ratio: '16:9',
})
// 计算属性
const activeFunction = computed(() => {
if (activeCategory.value === 'image_generation') {
return useImageInput.value ? 'image_to_image' : 'text_to_image'
} else if (activeCategory.value === 'image_editing') {
return 'image_edit'
} else if (activeCategory.value === 'video_generation') {
return useImageInput.value ? 'image_to_video' : 'text_to_video'
}
return 'text_to_image'
})
// Actions
const getCategoryIcon = (category) => {
const iconMap = {
image_generation: 'iconfont icon-image',
image_editing: 'iconfont icon-edit',
image_effects: 'iconfont icon-chuangzuo',
video_generation: 'iconfont icon-video',
}
return iconMap[category] || 'iconfont icon-image'
}
const switchCategory = (key) => {
activeCategory.value = key
useImageInput.value = false
}
const switchInputMode = () => {
currentPrompt.value = ''
}
const handleMultipleImageUpload = (event) => {
const files = Array.from(event.target.files)
files.forEach((file) => {
if (imageToVideoParams.value.image_urls.length < 2) {
onImageUpload({ file, name: file.name })
}
})
}
const removeImage = (index) => {
imageToVideoParams.value.image_urls.splice(index, 1)
}
const onImageUpload = (file) => {
const formData = new FormData()
formData.append('file', file.file, file.name)
showLoading('正在上传图片...')
return httpPost('/api/upload', formData)
.then((res) => {
showMessageOK('图片上传成功')
const imageData = { url: res.data.url, content: res.data.url }
// 根据当前活动功能添加到相应的参数中
if (activeFunction.value === 'image_to_image') {
imageToImageParams.value.image_input = [imageData]
} else if (activeFunction.value === 'image_edit') {
imageEditParams.value.image_urls = [imageData]
} else if (activeFunction.value === 'image_effects') {
imageEffectsParams.value.image_input1 = [imageData]
} else if (activeFunction.value === 'image_to_video') {
imageToVideoParams.value.image_urls.push(imageData)
}
return res.data.url
})
.catch((e) => {
showMessageError('图片上传失败:' + e.message)
})
.finally(() => {
closeLoading()
})
}
const submitTask = () => {
if (!currentPrompt.value.trim()) {
showMessageError('请输入提示词')
return
}
submitting.value = true
const params = {
type: activeFunction.value,
prompt: currentPrompt.value,
}
// 根据功能类型添加相应参数
if (activeFunction.value === 'text_to_image') {
Object.assign(params, textToImageParams.value)
} else if (activeFunction.value === 'image_to_image') {
Object.assign(params, imageToImageParams.value)
} else if (activeFunction.value === 'image_edit') {
Object.assign(params, imageEditParams.value)
} else if (activeFunction.value === 'image_effects') {
Object.assign(params, imageEffectsParams.value)
} else if (activeFunction.value === 'text_to_video') {
Object.assign(params, textToVideoParams.value)
} else if (activeFunction.value === 'image_to_video') {
Object.assign(params, imageToVideoParams.value)
}
return httpPost('/api/jimeng/create', params)
.then(() => {
fetchData(1)
taskPulling.value = true
showMessageOK('创建任务成功')
currentPrompt.value = ''
})
.catch((e) => {
showMessageError('创建任务失败:' + e.message)
})
.finally(() => {
submitting.value = false
})
}
const fetchData = (_page) => {
if (_page) {
page.value = _page
}
listLoading.value = true
return httpGet('/api/jimeng/list', { page: page.value, page_size: pageSize.value })
.then((res) => {
total.value = res.data.total
let needPull = false
const items = []
for (let v of res.data.items) {
if (v.status === 'in_queue' || v.status === 'generating') {
needPull = true
}
items.push(v)
}
listLoading.value = false
taskPulling.value = needPull
if (page.value === 1) {
currentList.value = items
} else {
currentList.value.push(...items)
}
if (items.length < pageSize.value) {
listFinished.value = true
}
})
.catch((e) => {
listLoading.value = false
showMessageError('获取作品列表失败:' + e.message)
})
}
const loadMore = () => {
page.value++
fetchData()
}
const playMedia = (item) => {
currentMediaUrl.value = item.img_url || item.video_url
showMediaDialog.value = true
}
const downloadFile = (item) => {
item.downloading = true
const link = document.createElement('a')
link.href = item.img_url || item.video_url
link.download = item.title || 'file'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
item.downloading = false
showMessageSuccess('开始下载')
}
const retryTask = (id) => {
return httpPost('/api/jimeng/retry', { id })
.then(() => {
showMessageOK('重试任务成功')
fetchData(1)
})
.catch((e) => {
showMessageError('重试任务失败:' + e.message)
})
}
const removeJob = (item) => {
return showConfirmDialog({
title: '确认删除',
message: '此操作将会删除任务相关文件,继续操作吗?',
confirmButtonText: '确认删除',
cancelButtonText: '取消',
})
.then(() => {
return httpGet('/api/jimeng/remove', { id: item.id })
.then(() => {
showMessageOK('任务删除成功')
fetchData(1)
})
.catch((e) => {
showMessageError('任务删除失败:' + e.message)
})
})
.catch(() => {})
}
const getFunctionName = (type) => {
const nameMap = {
text_to_image: '文生图',
image_to_image: '图生图',
image_edit: '图像编辑',
image_effects: '图像特效',
text_to_video: '文生视频',
image_to_video: '图生视频',
}
return nameMap[type] || type
}
const getTaskType = (type) => {
return type.includes('video') ? 'warning' : 'primary'
}
const startTaskPolling = () => {
tastPullHandler.value = setInterval(() => {
if (taskPulling.value) {
fetchData(1)
}
}, 5000)
}
const stopTaskPolling = () => {
if (tastPullHandler.value) {
clearInterval(tastPullHandler.value)
}
}
const resetParams = () => {
textToImageParams.value = {
size: '1024x1024',
scale: 7.5,
use_pre_llm: false,
}
imageToImageParams.value = {
image_input: [],
size: '1024x1024',
}
imageEditParams.value = {
image_urls: [],
scale: 0.5,
}
imageEffectsParams.value = {
image_input1: [],
template_id: '',
size: '1024x1024',
}
textToVideoParams.value = {
aspect_ratio: '16:9',
}
imageToVideoParams.value = {
image_urls: [],
aspect_ratio: '16:9',
}
}
const closeMediaDialog = () => {
showMediaDialog.value = false
currentMediaUrl.value = ''
}
return {
// State
activeCategory,
useImageInput,
submitting,
listLoading,
listFinished,
currentList,
showMediaDialog,
currentMediaUrl,
currentPrompt,
page,
pageSize,
total,
currentPowerCost,
taskPulling,
tastPullHandler,
categories,
imageSizeOptions,
videoAspectRatioOptions,
imageEffectsTemplateOptions,
textToImageParams,
imageToImageParams,
imageEditParams,
imageEffectsParams,
textToVideoParams,
imageToVideoParams,
// Computed
activeFunction,
// Actions
getCategoryIcon,
switchCategory,
switchInputMode,
handleMultipleImageUpload,
removeImage,
onImageUpload,
submitTask,
fetchData,
loadMore,
playMedia,
downloadFile,
retryTask,
removeJob,
getFunctionName,
getTaskType,
startTaskPolling,
stopTaskPolling,
resetParams,
closeMediaDialog,
}
})

View File

@@ -0,0 +1,706 @@
<template>
<div class="jimeng-create">
<!-- 页面头部 -->
<div class="jimeng-create__header">
<div class="jimeng-create__header-content">
<button @click="goBack" class="jimeng-create__header-back-btn">
<i class="iconfont icon-back"></i>
</button>
<h1 class="jimeng-create__header-title">即梦AI</h1>
<div class="jimeng-create__header-spacer"></div>
</div>
</div>
<!-- 功能分类选择 -->
<div class="jimeng-create__content">
<div class="jimeng-create__category-section">
<CustomTabs
v-model="jimengStore.activeCategory"
@update:modelValue="jimengStore.switchCategory"
>
<CustomTabPane
v-for="category in jimengStore.categories"
:key="category.key"
:label="category.name"
:name="category.key"
>
<template #label>
<span>{{ category.name }}</span>
</template>
</CustomTabPane>
</CustomTabs>
</div>
<!-- 生成模式切换 -->
<div
v-if="
jimengStore.activeCategory === 'image_generation' ||
jimengStore.activeCategory === 'video_generation'
"
class="jimeng-create__mode-section"
>
<div class="jimeng-create__mode-section-content">
<div>
<span class="jimeng-create__mode-section-title">生成模式</span>
<p class="jimeng-create__mode-section-description">
{{
jimengStore.activeCategory === 'image_generation' ? '图生图人像写真' : '图生视频'
}}
</p>
</div>
<el-switch
v-model="jimengStore.useImageInput"
@change="jimengStore.switchInputMode"
size="default"
/>
</div>
</div>
<!-- 文生图 -->
<div v-if="jimengStore.activeFunction === 'text_to_image'" class="space-y-6">
<!-- 提示词输入 -->
<div class="jimeng-create__form-section">
<label class="jimeng-create__form-section-label">提示词</label>
<textarea
v-model="jimengStore.currentPrompt"
placeholder="请输入图片描述,越详细越好"
class="jimeng-create__form-section-textarea"
rows="4"
maxlength="2000"
/>
<div class="jimeng-create__form-section-counter">
<span>{{ jimengStore.currentPrompt.length }}/2000</span>
</div>
</div>
<!-- 图片尺寸 -->
<CustomSelect
v-model="jimengStore.textToImageParams.size"
:options="
jimengStore.imageSizeOptions.map((opt) => ({ label: opt.label, value: opt.value }))
"
label="图片尺寸"
title="选择尺寸"
/>
<!-- 创意度 -->
<div class="jimeng-create__form-section">
<div class="jimeng-create__slider-section">
<div class="jimeng-create__slider-section-header">
<label>创意度</label>
<el-tooltip content="创意度越高,影响文本描述的程度越高" placement="top">
<i class="iconfont icon-info"></i>
</el-tooltip>
</div>
<el-slider
v-model="jimengStore.textToImageParams.scale"
:min="1"
:max="10"
:step="0.5"
/>
</div>
</div>
<!-- 智能优化提示词 -->
<div class="jimeng-create__form-section">
<div class="jimeng-create__switch-section">
<span>智能优化提示词</span>
<el-switch v-model="jimengStore.textToImageParams.use_pre_llm" size="default" />
</div>
</div>
</div>
<!-- 图生图 -->
<div v-if="jimengStore.activeFunction === 'image_to_image'" class="space-y-6">
<!-- 上传图片 -->
<div class="jimeng-create__form-section">
<label class="jimeng-create__form-section-label">上传图片</label>
<div class="jimeng-create__upload">
<input
ref="imageToImageInput"
type="file"
accept=".jpg,.png,.jpeg"
@change="
(e) =>
jimengStore.onImageUpload({
file: e.target.files[0],
name: e.target.files[0]?.name,
})
"
class="hidden"
/>
<div @click="$refs.imageToImageInput?.click()" class="jimeng-create__upload-content">
<i
v-if="!jimengStore.imageToImageParams.image_input.length"
class="jimeng-create__upload-icon iconfont icon-upload"
></i>
<span
v-if="!jimengStore.imageToImageParams.image_input.length"
class="jimeng-create__upload-text"
>上传图片</span
>
<div v-else class="jimeng-create__upload-preview">
<el-image
:src="
jimengStore.imageToImageParams.image_input[0]?.url ||
jimengStore.imageToImageParams.image_input[0]?.content
"
fit="cover"
class="w-32 h-32 rounded"
/>
<button
@click.stop="jimengStore.imageToImageParams.image_input = []"
class="jimeng-create__upload-remove-btn"
>
<i class="iconfont icon-close"></i>
</button>
</div>
</div>
</div>
</div>
<!-- 提示词输入 -->
<div class="jimeng-create__form-section">
<label class="jimeng-create__form-section-label">提示词</label>
<textarea
v-model="jimengStore.currentPrompt"
placeholder="描述你想要的图片效果"
class="jimeng-create__form-section-textarea"
rows="4"
maxlength="2000"
/>
<div class="jimeng-create__form-section-counter">
<span>{{ jimengStore.currentPrompt.length }}/2000</span>
</div>
</div>
<!-- 图片尺寸 -->
<CustomSelect
v-model="jimengStore.imageToImageParams.size"
:options="
jimengStore.imageSizeOptions.map((opt) => ({ label: opt.label, value: opt.value }))
"
label="图片尺寸"
title="选择尺寸"
/>
</div>
<!-- 图像编辑 -->
<div v-if="jimengStore.activeFunction === 'image_edit'" class="space-y-6">
<!-- 上传图片 -->
<div class="jimeng-create__form-section">
<label class="jimeng-create__form-section-label">上传图片</label>
<div class="jimeng-create__upload">
<input
ref="imageEditInput"
type="file"
accept=".jpg,.png,.jpeg"
@change="
(e) =>
jimengStore.onImageUpload({
file: e.target.files[0],
name: e.target.files[0]?.name,
})
"
class="hidden"
/>
<div @click="$refs.imageEditInput?.click()" class="jimeng-create__upload-content">
<i
v-if="!jimengStore.imageEditParams.image_urls.length"
class="jimeng-create__upload-icon iconfont icon-upload"
></i>
<span
v-if="!jimengStore.imageEditParams.image_urls.length"
class="jimeng-create__upload-text"
>上传图片</span
>
<div v-else class="jimeng-create__upload-preview">
<el-image
:src="
jimengStore.imageEditParams.image_urls[0]?.url ||
jimengStore.imageEditParams.image_urls[0]?.content
"
fit="cover"
class="w-32 h-32 rounded"
/>
<button
@click.stop="jimengStore.imageEditParams.image_urls = []"
class="jimeng-create__upload-remove-btn"
>
<i class="iconfont icon-close"></i>
</button>
</div>
</div>
</div>
</div>
<!-- 编辑提示词 -->
<div class="jimeng-create__form-section">
<label class="jimeng-create__form-section-label">编辑提示词</label>
<textarea
v-model="jimengStore.currentPrompt"
placeholder="描述你想要的编辑效果"
class="jimeng-create__form-section-textarea"
rows="4"
maxlength="2000"
/>
<div class="jimeng-create__form-section-counter">
<span>{{ jimengStore.currentPrompt.length }}/2000</span>
</div>
</div>
<!-- 编辑强度 -->
<div class="jimeng-create__form-section">
<div class="jimeng-create__slider-section">
<label>编辑强度</label>
<el-slider v-model="jimengStore.imageEditParams.scale" :min="0" :max="1" :step="0.1" />
</div>
</div>
</div>
<!-- 图像特效 -->
<div v-if="jimengStore.activeFunction === 'image_effects'" class="space-y-6">
<!-- 上传图片 -->
<div class="jimeng-create__form-section">
<label class="jimeng-create__form-section-label">上传图片</label>
<div class="jimeng-create__upload">
<input
ref="imageEffectsInput"
type="file"
accept=".jpg,.png,.jpeg"
@change="
(e) =>
jimengStore.onImageUpload({
file: e.target.files[0],
name: e.target.files[0]?.name,
})
"
class="hidden"
/>
<div @click="$refs.imageEffectsInput?.click()" class="jimeng-create__upload-content">
<i
v-if="!jimengStore.imageEffectsParams.image_input1.length"
class="jimeng-create__upload-icon iconfont icon-upload"
></i>
<span
v-if="!jimengStore.imageEffectsParams.image_input1.length"
class="jimeng-create__upload-text"
>上传图片</span
>
<div v-else class="jimeng-create__upload-preview">
<el-image
:src="
jimengStore.imageEffectsParams.image_input1[0]?.url ||
jimengStore.imageEffectsParams.image_input1[0]?.content
"
fit="cover"
class="w-32 h-32 rounded"
/>
<button
@click.stop="jimengStore.imageEffectsParams.image_input1 = []"
class="jimeng-create__upload-remove-btn"
>
<i class="iconfont icon-close"></i>
</button>
</div>
</div>
</div>
</div>
<!-- 特效模板 -->
<CustomSelect
v-model="jimengStore.imageEffectsParams.template_id"
:options="
jimengStore.imageEffectsTemplateOptions.map((opt) => ({
label: opt.label,
value: opt.value,
}))
"
label="特效模板"
title="选择特效模板"
/>
<!-- 输出尺寸 -->
<CustomSelect
v-model="jimengStore.imageEffectsParams.size"
:options="
jimengStore.imageSizeOptions.map((opt) => ({ label: opt.label, value: opt.value }))
"
label="输出尺寸"
title="选择尺寸"
/>
</div>
<!-- 文生视频 -->
<div v-if="jimengStore.activeFunction === 'text_to_video'" class="space-y-6">
<!-- 提示词输入 -->
<div class="jimeng-create__form-section">
<label class="jimeng-create__form-section-label">提示词</label>
<textarea
v-model="jimengStore.currentPrompt"
placeholder="描述你想要的视频内容"
class="jimeng-create__form-section-textarea"
rows="4"
maxlength="2000"
/>
<div class="jimeng-create__form-section-counter">
<span>{{ jimengStore.currentPrompt.length }}/2000</span>
</div>
</div>
<!-- 视频比例 -->
<CustomSelect
v-model="jimengStore.textToVideoParams.aspect_ratio"
:options="
jimengStore.videoAspectRatioOptions.map((opt) => ({
label: opt.label,
value: opt.value,
}))
"
label="视频比例"
title="选择比例"
/>
</div>
<!-- 图生视频 -->
<div v-if="jimengStore.activeFunction === 'image_to_video'" class="space-y-6">
<!-- 上传图片 -->
<div class="jimeng-create__form-section">
<label class="jimeng-create__form-section-label">上传图片最多2张</label>
<div class="jimeng-create__upload">
<input
ref="imageToVideoInput"
type="file"
accept=".jpg,.png,.jpeg"
multiple
@change="(e) => jimengStore.handleMultipleImageUpload(e)"
class="hidden"
/>
<div @click="$refs.imageToVideoInput?.click()" class="jimeng-create__upload-content">
<i
v-if="!jimengStore.imageToVideoParams.image_urls.length"
class="jimeng-create__upload-icon iconfont icon-upload"
></i>
<span
v-if="!jimengStore.imageToVideoParams.image_urls.length"
class="jimeng-create__upload-text"
>上传图片</span
>
<div v-else class="jimeng-create__upload-multiple">
<div
v-for="(image, index) in jimengStore.imageToVideoParams.image_urls"
:key="index"
class="jimeng-create__upload-multiple-item"
>
<el-image
:src="image?.url || image?.content"
fit="cover"
class="w-24 h-24 rounded"
/>
<button
@click.stop="jimengStore.removeImage(index)"
class="jimeng-create__upload-remove-btn"
>
<i class="iconfont icon-close"></i>
</button>
</div>
<div
v-if="jimengStore.imageToVideoParams.image_urls.length < 2"
@click.stop="$refs.imageToVideoInput?.click()"
class="jimeng-create__upload-multiple-add"
>
<i class="iconfont icon-plus"></i>
</div>
</div>
</div>
</div>
</div>
<!-- 提示词输入 -->
<div class="jimeng-create__form-section">
<label class="jimeng-create__form-section-label">提示词</label>
<textarea
v-model="jimengStore.currentPrompt"
placeholder="描述你想要的视频效果"
class="jimeng-create__form-section-textarea"
rows="4"
maxlength="2000"
/>
<div class="jimeng-create__form-section-counter">
<span>{{ jimengStore.currentPrompt.length }}/2000</span>
</div>
</div>
<!-- 视频比例 -->
<CustomSelect
v-model="jimengStore.imageToVideoParams.aspect_ratio"
:options="
jimengStore.videoAspectRatioOptions.map((opt) => ({
label: opt.label,
value: opt.value,
}))
"
label="视频比例"
title="选择比例"
/>
</div>
<!-- 生成按钮 -->
<div class="jimeng-create__submit-btn">
<button @click="jimengStore.submitTask" :disabled="jimengStore.submitting">
<i v-if="jimengStore.submitting" class="iconfont icon-loading animate-spin"></i>
<span>{{
jimengStore.submitting ? '创作中...' : `立即生成 (${jimengStore.currentPowerCost}算力)`
}}</span>
</button>
</div>
</div>
<!-- 作品列表 -->
<div class="jimeng-create__works">
<h2 class="jimeng-create__works-title">我的作品</h2>
<div class="jimeng-create__works-list space-y-4">
<div
v-for="item in jimengStore.currentList"
:key="item.id"
class="jimeng-create__works-item"
>
<div class="jimeng-create__works-item-content">
<div class="jimeng-create__works-item-thumb">
<div class="jimeng-create__works-item-thumb-container">
<el-image
v-if="item.img_url"
:src="item.img_url"
fit="cover"
class="w-full h-full"
:preview-disabled="true"
>
<template #error>
<div class="jimeng-create__works-item-thumb-placeholder">
<i class="iconfont icon-image"></i>
</div>
</template>
</el-image>
<el-image
v-else-if="item.video_url"
:src="item.video_url"
fit="cover"
class="w-full h-full"
:preview-disabled="true"
>
<template #error>
<div class="jimeng-create__works-item-thumb-placeholder">
<i class="iconfont icon-video"></i>
</div>
</template>
</el-image>
<div v-else class="jimeng-create__works-item-thumb-placeholder">
<i
:class="
item.type.includes('video') ? 'iconfont icon-video' : 'iconfont icon-image'
"
></i>
</div>
<!-- 播放/查看按钮 -->
<button
v-if="item.status === 'completed'"
@click="jimengStore.playMedia(item)"
class="jimeng-create__works-item-thumb-overlay"
>
<i
:class="
item.type.includes('video') ? 'iconfont icon-play' : 'iconfont icon-eye'
"
></i>
</button>
<!-- 进度动画 -->
<div
v-if="item.status === 'in_queue' || item.status === 'generating'"
class="jimeng-create__works-item-thumb-status jimeng-create__works-item-thumb-status--loading"
>
<i class="iconfont icon-loading animate-spin"></i>
</div>
<!-- 失败状态 -->
<div
v-if="item.status === 'failed'"
class="jimeng-create__works-item-thumb-status jimeng-create__works-item-thumb-status--failed"
>
<i class="iconfont icon-warning"></i>
</div>
</div>
</div>
<div class="jimeng-create__works-item-info">
<div class="jimeng-create__works-item-info-header">
<div class="flex-1">
<h3 class="jimeng-create__works-item-info-title">
{{ jimengStore.getFunctionName(item.type) }}
</h3>
<p class="jimeng-create__works-item-info-prompt line-clamp-2">
{{ item.prompt }}
</p>
</div>
<!-- 任务状态 -->
<div
v-if="item.status !== 'completed'"
class="jimeng-create__works-item-info-status"
>
<div
v-if="item.status === 'failed'"
class="jimeng-create__works-item-info-status--failed"
>
<i class="iconfont icon-warning"></i>
<span>失败</span>
</div>
<div v-else class="jimeng-create__works-item-info-status--processing">
<div class="loading-spinner"></div>
<span>生成中</span>
</div>
</div>
</div>
<!-- 标签 -->
<div class="jimeng-create__works-item-info-tags">
<span
:class="[
'jimeng-create__works-item-info-tags-item',
jimengStore.getTaskType(item.type) === 'warning'
? 'jimeng-create__works-item-info-tags-item--warning'
: 'jimeng-create__works-item-info-tags-item--primary',
]"
>
{{ jimengStore.getFunctionName(item.type) }}
</span>
<span
v-if="item.power"
class="jimeng-create__works-item-info-tags-item jimeng-create__works-item-info-tags-item--power"
>
{{ item.power }}算力
</span>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="jimeng-create__works-item-actions">
<div class="jimeng-create__works-item-actions-left">
<button
v-if="item.status === 'completed'"
@click="jimengStore.playMedia(item)"
class="jimeng-create__works-item-actions-btn jimeng-create__works-item-actions-btn--primary"
>
<i
:class="item.type.includes('video') ? 'iconfont icon-play' : 'iconfont icon-eye'"
></i>
<span>{{ item.type.includes('video') ? '播放' : '查看' }}</span>
</button>
<button
v-if="item.status === 'completed'"
@click="jimengStore.downloadFile(item)"
:disabled="item.downloading"
class="jimeng-create__works-item-actions-btn jimeng-create__works-item-actions-btn--success"
>
<i v-if="item.downloading" class="iconfont icon-loading animate-spin"></i>
<i v-else class="iconfont icon-download"></i>
<span>{{ item.downloading ? '下载中...' : '下载' }}</span>
</button>
<button
v-if="item.status === 'failed'"
@click="jimengStore.retryTask(item.id)"
class="jimeng-create__works-item-actions-btn jimeng-create__works-item-actions-btn--warning"
>
<i class="iconfont icon-refresh"></i>
<span>重试</span>
</button>
</div>
<button
@click="jimengStore.removeJob(item)"
class="jimeng-create__works-item-actions-btn jimeng-create__works-item-actions-btn--danger"
>
<i class="iconfont icon-remove"></i>
<span>删除</span>
</button>
</div>
</div>
<!-- 加载更多 -->
<div v-if="jimengStore.listLoading" class="jimeng-create__works-loading">
<i class="iconfont icon-loading animate-spin"></i>
</div>
<!-- 没有更多了 -->
<div
v-if="jimengStore.listFinished && !jimengStore.listLoading"
class="jimeng-create__works-finished"
>
没有更多了
</div>
</div>
</div>
<!-- 媒体预览弹窗 -->
<div
v-if="jimengStore.showMediaDialog"
class="jimeng-create__media-dialog"
@click="jimengStore.closeMediaDialog"
>
<div @click.stop class="jimeng-create__media-dialog-content animate-scale-up">
<div class="jimeng-create__media-dialog-header">
<h3>媒体预览</h3>
<button @click="jimengStore.closeMediaDialog">
<i class="iconfont icon-close"></i>
</button>
</div>
<div class="jimeng-create__media-dialog-body">
<img
v-if="jimengStore.currentMediaUrl && !jimengStore.currentMediaUrl.includes('video')"
:src="jimengStore.currentMediaUrl"
class="w-full max-h-[60vh] object-contain rounded-lg"
/>
<video
v-else-if="jimengStore.currentMediaUrl"
:src="jimengStore.currentMediaUrl"
controls
autoplay
class="w-full max-h-[60vh] rounded-lg"
>
您的浏览器不支持视频播放
</video>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useJimengStore } from '@/store/mobile/jimeng'
import CustomSelect from '@/components/mobile/CustomSelect.vue'
import CustomTabs from '@/components/ui/CustomTabs.vue'
import CustomTabPane from '@/components/ui/CustomTabPane.vue'
import { checkSession } from '@/store/cache'
const router = useRouter()
const jimengStore = useJimengStore()
// 生命周期
onMounted(() => {
checkSession()
.then(() => {
jimengStore.fetchData(1)
jimengStore.startTaskPolling()
})
.catch(() => {})
})
onUnmounted(() => {
jimengStore.stopTaskPolling()
})
// 工具方法
const goBack = () => {
router.back()
}
</script>
<style lang="scss" scoped>
@import '@/assets/css/mobile/jimeng.scss';
</style>

File diff suppressed because it is too large Load Diff