mirror of
https://github.com/yangjian102621/geekai.git
synced 2026-08-12 18:50:58 +00:00
feat(release): migrate GeekAI v4.3.0 to open source
- Sync backend and frontend from GeekAI Plus v4.3.0 - Remove commercial License flows and update open-source deployment defaults - Preserve Docker Compose deployment and bump image tags to v4.3.0 BREAKING CHANGE: commercial License configuration and related endpoints are removed
This commit is contained in:
@@ -6,7 +6,7 @@ VITE_ADMIN_USER=admin
|
||||
VITE_ADMIN_PASS=admin123
|
||||
VITE_KEY_PREFIX=GeekAI_DEV_
|
||||
VITE_TITLE="Geek-AI 创作系统"
|
||||
VITE_VERSION=v4.2.8
|
||||
VITE_VERSION=v4.3.0
|
||||
VITE_DOCS_URL=https://docs.geekai.me
|
||||
VITE_GITHUB_URL=https://github.com/yangjian102621/geekai
|
||||
VITE_GITEE_URL=https://gitee.com/blackfox/geekai
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
VITE_API_HOST=
|
||||
VITE_WS_HOST=
|
||||
VITE_KEY_PREFIX=GeekAI_
|
||||
VITE_VERSION=v4.2.8
|
||||
VITE_VERSION=v4.3.0
|
||||
VITE_TITLE="Geek-AI 创作系统"
|
||||
VITE_DOCS_URL=https://docs.geekai.me
|
||||
VITE_GITHUB_URL=https://github.com/yangjian102621/geekai
|
||||
|
||||
Generated
+7446
File diff suppressed because it is too large
Load Diff
+16
-14
@@ -8,7 +8,7 @@
|
||||
import { checkSession, getSystemInfo } from '@/store/cache'
|
||||
import { useSharedStore } from '@/store/sharedata'
|
||||
import { showMessageInfo } from '@/utils/dialog'
|
||||
import { isChrome, isMobile } from '@/utils/libs'
|
||||
import { initThumbTemplate, isChrome, isMobile } from '@/utils/libs'
|
||||
import { ElConfigProvider } from 'element-plus'
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
@@ -34,6 +34,9 @@ window.ResizeObserver = class ResizeObserver extends _ResizeObserver {
|
||||
|
||||
const store = useSharedStore()
|
||||
onMounted(() => {
|
||||
// 初始化缩略图模板(从后端加载并缓存到localStorage)
|
||||
initThumbTemplate()
|
||||
|
||||
// 获取系统参数
|
||||
getSystemInfo().then((res) => {
|
||||
const link = document.createElement('link')
|
||||
@@ -53,10 +56,8 @@ onMounted(() => {
|
||||
|
||||
// 设置主题
|
||||
document.documentElement.setAttribute('data-theme', store.theme)
|
||||
})
|
||||
|
||||
// 打印 banner
|
||||
const banner = `
|
||||
const banner = `
|
||||
.oooooo. oooo .o. ooooo
|
||||
d8P' 'Y8b 888 .888. 888
|
||||
888 .ooooo. .ooooo. 888 oooo .8"888. 888
|
||||
@@ -65,17 +66,18 @@ const banner = `
|
||||
'88. .88' 888 .o 888 .o 888 88b. .8' 888. 888
|
||||
Y8bood8P' Y8bod8P' Y8bod8P' o888o o888o o88o o8888o o888o
|
||||
`
|
||||
console.log('%c' + banner + '', 'color: purple;font-size: 18px;')
|
||||
console.log('%c' + banner + '', 'color: purple;font-size: 18px;')
|
||||
|
||||
console.log(
|
||||
'%c感谢大家为 GeekAI 做出的卓越贡献!',
|
||||
"color: green;font-size: 40px;font-family: '微软雅黑';"
|
||||
)
|
||||
console.log(
|
||||
'%c项目源码:https://github.com/yangjian102621/geekai %c 您的 star 对我们非常重要!',
|
||||
"color: green;font-size: 20px;font-family: '微软雅黑';",
|
||||
"color: red;font-size: 20px;font-family: '微软雅黑';"
|
||||
)
|
||||
console.log(
|
||||
'%c感谢大家为 GeekAI 做出的卓越贡献!',
|
||||
"color: green;font-size: 40px;font-family: '微软雅黑';"
|
||||
)
|
||||
console.log(
|
||||
'%c项目源码:https://github.com/yangjian102621/geekai %c 您的 star 对我们非常重要!',
|
||||
"color: green;font-size: 20px;font-family: '微软雅黑';",
|
||||
"color: red;font-size: 20px;font-family: '微软雅黑';"
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
+329
-198
@@ -14,98 +14,210 @@
|
||||
height: 100%;
|
||||
}
|
||||
.el-aside {
|
||||
padding: 10px;
|
||||
width: var(--el-aside-width, 320px);
|
||||
padding: 0;
|
||||
width: var(--el-aside-width, 280px);
|
||||
background: #f2f2f2;
|
||||
|
||||
.chat-list-container {
|
||||
&.chat-aside .sidebar-inner {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
border-radius: 10px;
|
||||
padding: 10px 0;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 12px 0 16px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
.sidebar-header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 12px 16px 8px;
|
||||
|
||||
.sidebar-logo {
|
||||
max-height: 32px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
overflow-y: scroll;
|
||||
.sidebar-title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-list-item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
padding: 8px 12px;
|
||||
// border: 1px solid #3c3c3c;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--theme-bg-color);
|
||||
margin-bottom: 6px;
|
||||
border-radius: 5px;
|
||||
.sidebar-actions {
|
||||
flex-shrink: 0;
|
||||
padding: 4px 12px 12px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border: 1px solid var(--border-active);
|
||||
}
|
||||
.sidebar-action-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 2px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #374151;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
color 0.2s ease;
|
||||
|
||||
.avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
&:hover {
|
||||
background: #e5e7eb;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.chat-title-input {
|
||||
font-size: 14px;
|
||||
margin-top: 4px;
|
||||
margin-left: 10px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
width: 190px;
|
||||
}
|
||||
.sidebar-action-icon {
|
||||
margin-right: 10px;
|
||||
font-size: 18px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.chat-title {
|
||||
color: var(--el-text-color-regular);
|
||||
padding: 5px 10px;
|
||||
max-width: 220px;
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.sidebar-action-extra {
|
||||
margin-left: auto;
|
||||
font-size: 16px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-opt {
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
top: 16px;
|
||||
color: var(--text-fb);
|
||||
.sidebar-body-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.el-dropdown-link {
|
||||
color: var(--text-fb);
|
||||
}
|
||||
.sidebar-section {
|
||||
padding: 0 12px 16px;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.sidebar-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0 6px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.chat-list-item.active {
|
||||
background-color: var(--theme-bg);
|
||||
box-shadow: 0 3px 9px rgba(112, 144, 176, 0.12);
|
||||
border: 1px solid var(--border-active);
|
||||
.sidebar-section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.sidebar-section-arrow {
|
||||
font-size: 14px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.sidebar-section-head-clickable {
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
.sidebar-section-title,
|
||||
.sidebar-section-arrow {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tool-box {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 12px;
|
||||
// border-top: 0.5px solid var(--el-border-color);
|
||||
.search-box {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 0;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.iconfont {
|
||||
margin-right: 5px;
|
||||
.search-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.el-input__wrapper {
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 2px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
margin-right: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-title-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sidebar-item-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-item-pin {
|
||||
flex-shrink: 0;
|
||||
margin-left: 4px;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
|
||||
.pin-trigger {
|
||||
display: inline-flex;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #e0e7ff;
|
||||
color: #3730a3;
|
||||
|
||||
.sidebar-item-text {
|
||||
color: #3730a3;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 12px 0;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.clear-chats-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,7 +287,7 @@
|
||||
background: var(--chat-bg);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0 20px;
|
||||
padding: 0 20px 20px 20px;
|
||||
max-width: 960px;
|
||||
|
||||
.chat-box {
|
||||
@@ -195,136 +307,154 @@
|
||||
}
|
||||
}
|
||||
|
||||
.input-box {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
.agent-manage-view {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.input-box-inner {
|
||||
display: flex;
|
||||
background-color: var(--chat-bg);
|
||||
.agent-manage-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
// box-shadow: 0 2px 15px rgba(0, 0, 0, 0.1);
|
||||
padding: 0 15px;
|
||||
.agent-manage-title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-fb, #111827);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.tool-item {
|
||||
margin-right: 15px;
|
||||
border-radius: 6px;
|
||||
color: #19c37d;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
justify-items: center;
|
||||
padding: 6px;
|
||||
cursor: pointer;
|
||||
// background: #f2f2f2;
|
||||
|
||||
&:hover {
|
||||
background: #d5fad3;
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.input-body {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
border: none;
|
||||
padding: 10px 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
|
||||
.hide-div {
|
||||
white-space: pre-wrap; /* 保持文本换行 */
|
||||
visibility: hidden; /* 隐藏 div */
|
||||
position: absolute; /* 脱离文档流 */
|
||||
line-height: 24px;
|
||||
font-size: 14px;
|
||||
word-wrap: break-word; /* 允许单词换行 */
|
||||
overflow-wrap: break-word; /* 允许长单词换行,适用于现代浏览器 */
|
||||
}
|
||||
|
||||
.input-border {
|
||||
// display: flex;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border: 2px solid var(--theme-border-primary);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
// background-color: #f4f4f4;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--theme-border-hover);
|
||||
}
|
||||
|
||||
.input-inner {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
width: 100%;
|
||||
|
||||
.file-list {
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.prompt-input::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.prompt-input {
|
||||
min-height: 58px;
|
||||
width: 100%;
|
||||
line-height: 24px;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
background: none;
|
||||
resize: none;
|
||||
white-space: pre-wrap; /* 保持文本换行 */
|
||||
word-wrap: break-word; /* 允许单词换行 */
|
||||
overflow-wrap: break-word; /* 允许长单词换行,适用于现代浏览器 */
|
||||
}
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 32px;
|
||||
margin-left: 10px;
|
||||
|
||||
.el-button {
|
||||
padding: 8px 5px;
|
||||
border-radius: 6px;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
.little-btns {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.iconfont {
|
||||
font-size: 19px;
|
||||
cursor: pointer;
|
||||
background-color: var(--chat-content-bg);
|
||||
padding: 5px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.add-new {
|
||||
.el-icon {
|
||||
font-size: 20px;
|
||||
color: #754ff6;
|
||||
}
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
.agent-manage-info {
|
||||
font-size: 1rem;
|
||||
color: #9ca3af;
|
||||
cursor: help;
|
||||
}
|
||||
}
|
||||
|
||||
.agent-manage-list {
|
||||
background: var(--chat-bg);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.welcome-wrap {
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.welcome-home {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px 16px 48px;
|
||||
}
|
||||
|
||||
/* 欢迎页智能体名片:仅当 hello_msg 不为空时显示 */
|
||||
.agent-welcome-card {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
margin: 0 0 28px;
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(249, 250, 251, 0.98) 100%);
|
||||
border: 1px solid rgba(107, 85, 255, 0.12);
|
||||
border-radius: 16px;
|
||||
box-shadow:
|
||||
0 4px 24px rgba(107, 85, 255, 0.06),
|
||||
0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
transition: box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(107, 85, 255, 0.2);
|
||||
box-shadow:
|
||||
0 8px 32px rgba(107, 85, 255, 0.1),
|
||||
0 2px 4px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
}
|
||||
|
||||
.agent-welcome-card__avatar-wrap {
|
||||
flex-shrink: 0;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #a78bfa 0%, #7c3aed 100%);
|
||||
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.25);
|
||||
}
|
||||
|
||||
.agent-welcome-card__avatar {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.agent-welcome-card__avatar-ph {
|
||||
display: block;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
background: linear-gradient(135deg, #a78bfa 0%, #7c3aed 100%);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.agent-welcome-card__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.agent-welcome-card__name {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-fb, #111827);
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.agent-welcome-card__hello {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.55;
|
||||
color: var(--el-text-color-regular, #606266);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.welcome-greeting {
|
||||
margin: 0 0 28px;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-regular, #606266);
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 欢迎页输入区:仅保留布局,输入框样式由 ChatInputBox 组件内置 */
|
||||
.welcome-input-wrap {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
/* 对话页底部输入区:仅保留布局,输入框样式由 ChatInputBox 组件内置 */
|
||||
.input-box {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -384,7 +514,8 @@
|
||||
.vip-info {
|
||||
padding: 0 10px 0 10px;
|
||||
|
||||
h4, p {
|
||||
h4,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -435,4 +566,4 @@
|
||||
.el-icon {
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,8 +79,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.menu-list-item:hover,
|
||||
.active {
|
||||
.menu-list-item:hover {
|
||||
background: rgba(79, 89, 102, 0.122);
|
||||
border-radius: 8px;
|
||||
|
||||
@@ -89,6 +88,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
.menu-list-item.active {
|
||||
background: var(--menu-active-bg, rgba(107, 85, 255, 0.15));
|
||||
color: var(--menu-active-color, var(--theme-border-hover));
|
||||
|
||||
.el-icon {
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-title {
|
||||
font-size: 15px !important;
|
||||
margin-bottom: 0 !important;
|
||||
@@ -111,6 +119,7 @@
|
||||
cursor: pointer;
|
||||
font-weight: 550;
|
||||
color: var(--text-theme-color);
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
.el-icon {
|
||||
@@ -128,8 +137,14 @@
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--text-theme-color);
|
||||
background: var(--menu-active-bg, rgba(107, 85, 255, 0.15));
|
||||
border-radius: 8px;
|
||||
color: var(--menu-active-color, var(--theme-border-hover));
|
||||
font-weight: 700;
|
||||
|
||||
.iconfont {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,12 +229,16 @@
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(183, 176, 255, 0.5);
|
||||
background: var(--menu-active-bg, rgba(183, 176, 255, 0.5));
|
||||
}
|
||||
}
|
||||
|
||||
li.active {
|
||||
background: rgba(183, 176, 255, 0.5);
|
||||
background: var(--menu-active-bg, rgba(183, 176, 255, 0.5));
|
||||
|
||||
.title {
|
||||
color: var(--menu-active-color, var(--theme-border-hover));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,372 +0,0 @@
|
||||
.page-sd {
|
||||
background-color: #282c34;
|
||||
}
|
||||
.page-sd .inner {
|
||||
display: flex;
|
||||
}
|
||||
.page-sd .inner .sd-box {
|
||||
margin: 10px;
|
||||
background-color: #262626;
|
||||
border: 1px solid #454545;
|
||||
min-width: 300px;
|
||||
max-width: 300px;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
}
|
||||
.page-sd .inner .sd-box h2 {
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
text-align: center;
|
||||
color: #47fff1;
|
||||
}
|
||||
.page-sd .inner .sd-box ::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
.page-sd .inner .sd-box .sd-params {
|
||||
margin-top: 10px;
|
||||
overflow: auto;
|
||||
}
|
||||
.page-sd .inner .sd-box .sd-params .param-line {
|
||||
padding: 0 10px;
|
||||
}
|
||||
.page-sd .inner .sd-box .sd-params .param-line .el-icon {
|
||||
position: relative;
|
||||
top: 3px;
|
||||
}
|
||||
.page-sd .inner .sd-box .sd-params .param-line .el-input__suffix-inner .el-icon {
|
||||
top: 0;
|
||||
}
|
||||
.page-sd .inner .sd-box .sd-params .param-line .grid-content,
|
||||
.page-sd .inner .sd-box .sd-params .param-line .form-item-inner {
|
||||
display: flex;
|
||||
}
|
||||
.page-sd .inner .sd-box .sd-params .param-line .grid-content .el-icon,
|
||||
.page-sd .inner .sd-box .sd-params .param-line .form-item-inner .el-icon {
|
||||
margin-left: 10px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.page-sd .inner .sd-box .sd-params .param-line.pt {
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
.page-sd .inner .sd-box .sd-params .text-info {
|
||||
padding: 10px;
|
||||
}
|
||||
.page-sd .inner .sd-box .sd-params .text-info .el-tag {
|
||||
margin-right: 10px;
|
||||
}
|
||||
.page-sd .inner .sd-box .submit-btn {
|
||||
padding: 10px 15px 0 15px;
|
||||
text-align: center;
|
||||
}
|
||||
.page-sd .inner .sd-box .submit-btn .el-button {
|
||||
width: 100%;
|
||||
}
|
||||
.page-sd .inner .sd-box .submit-btn .el-button span {
|
||||
color: #2d3a4b;
|
||||
}
|
||||
.page-sd .inner .el-form .el-form-item__label {
|
||||
color: #fff;
|
||||
}
|
||||
.page-sd .inner .task-list-box {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
color: #fff;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .el-tabs {
|
||||
--el-tabs-header-height: 55px;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .el-tabs__item {
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .title-tabs .el-tabs__item.is-active {
|
||||
color: #47fff1;
|
||||
font-size: 18px;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .title-tabs .el-tabs__active-bar {
|
||||
background-color: #47fff1;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .el-textarea {
|
||||
--el-input-focus-border-color: #47fff1;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .el-textarea__inner {
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .el-input__wrapper {
|
||||
background: transparent;
|
||||
padding: 5px;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .text {
|
||||
margin-bottom: 10px;
|
||||
color: #6b778c;
|
||||
font-size: 15px;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .param-line.pt {
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .form-item-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .form-item-inner .el-icon {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .el-form-item__label {
|
||||
color: #fff;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .img-inline {
|
||||
display: flex;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .img-inline .img-uploader .el-upload {
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 120px;
|
||||
transition: var(--el-transition-duration-fast);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .img-inline .img-uploader .el-upload:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .img-inline .img-uploader .el-upload .el-icon.uploader-icon {
|
||||
font-size: 28px;
|
||||
color: #8c939d;
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
text-align: center;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .img-inline .img-list-box {
|
||||
display: flex;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .img-inline .img-list-box .img-item {
|
||||
width: 120px;
|
||||
position: relative;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .img-inline .img-list-box .img-item .el-image {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .img-inline .img-list-box .img-item .el-button {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 5px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .el-row.text-info {
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .el-row.text-info .el-tag {
|
||||
margin-right: 10px;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .submit-btn {
|
||||
display: flex;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .submit-btn .el-button {
|
||||
width: 200px;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .job-list-box .running-job-list .job-item {
|
||||
width: 100%;
|
||||
padding: 2px;
|
||||
background-color: #555;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .job-list-box .running-job-list .job-item .job-item-inner {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .job-list-box .running-job-list .job-item .job-item-inner .progress {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .job-list-box .running-job-list .job-item .job-item-inner .progress span {
|
||||
font-size: 20px;
|
||||
color: #fff;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .job-list-box .finish-job-list .job-item {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid #666;
|
||||
padding: 6px;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s ease; /* 添加过渡效果 */
|
||||
position: relative;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .job-list-box .finish-job-list .job-item .opt .opt-line {
|
||||
margin: 6px 0;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .job-list-box .finish-job-list .job-item .opt .opt-line ul {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .job-list-box .finish-job-list .job-item .opt .opt-line ul li {
|
||||
margin-right: 6px;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .job-list-box .finish-job-list .job-item .opt .opt-line ul li a {
|
||||
padding: 3px 0;
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
border-radius: 5px;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
background-color: #4e5058;
|
||||
color: #fff;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .job-list-box .finish-job-list .job-item .opt .opt-line ul li a:hover {
|
||||
background-color: #6d6f78;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .job-list-box .finish-job-list .job-item .opt .opt-line ul .show-prompt {
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .job-list-box .finish-job-list .job-item .remove {
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 10px;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .job-list-box .finish-job-list .job-item:hover .remove {
|
||||
display: block;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .job-list-box .finish-job-list .animate:hover {
|
||||
box-shadow: 0 0 10px rgba(223,71,255,0.6); /* 添加阴影效果 */
|
||||
transform: translateY(-10px); /* 向上移动10像素 */
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .job-list-box .el-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .job-list-box .el-image img {
|
||||
height: 240px;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .job-list-box .el-image .el-image-viewer__wrapper img {
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .job-list-box .el-image .image-slot {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
color: #fff;
|
||||
height: 240px;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .job-list-box .el-image .image-slot .iconfont {
|
||||
font-size: 50px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .job-list-box .el-image.upscale img {
|
||||
height: 310px;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .job-list-box .el-image.upscale .image-slot {
|
||||
height: 310px;
|
||||
}
|
||||
.page-sd .inner .task-list-box .task-list-inner .job-list-box .el-image.upscale .el-image-viewer__wrapper img {
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
.page-sd .inner .task-list-box .no-more-data {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.page-sd .el-overlay-dialog .el-dialog {
|
||||
background-color: #1a1b1e;
|
||||
}
|
||||
.page-sd .el-overlay-dialog .el-dialog .el-dialog__header .el-dialog__title {
|
||||
color: #f5f5f5;
|
||||
}
|
||||
.page-sd .el-overlay-dialog .el-dialog .el-dialog__body {
|
||||
padding: 0 0 0 15px !important;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
.page-sd .el-overlay-dialog .el-dialog .el-dialog__body .el-row {
|
||||
width: 100%;
|
||||
}
|
||||
.page-sd .el-overlay-dialog .el-dialog .el-dialog__body .el-row .img-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.page-sd .el-overlay-dialog .el-dialog .el-dialog__body .el-row .img-container .image-slot {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.page-sd .el-overlay-dialog .el-dialog .el-dialog__body .el-row .img-container .image-slot .el-icon {
|
||||
font-size: 60px;
|
||||
}
|
||||
.page-sd .el-overlay-dialog .el-dialog .el-dialog__body .el-row .task-info {
|
||||
background-color: #25262b;
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
.page-sd .el-overlay-dialog .el-dialog .el-dialog__body .el-row .task-info .info-line {
|
||||
width: 100%;
|
||||
}
|
||||
.page-sd .el-overlay-dialog .el-dialog .el-dialog__body .el-row .task-info .info-line .prompt {
|
||||
background-color: #35363b;
|
||||
padding: 10px;
|
||||
color: #999;
|
||||
overflow: auto;
|
||||
max-height: 100px;
|
||||
min-height: 50px;
|
||||
position: relative;
|
||||
}
|
||||
.page-sd .el-overlay-dialog .el-dialog .el-dialog__body .el-row .task-info .info-line .prompt .el-icon {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.page-sd .el-overlay-dialog .el-dialog .el-dialog__body .el-row .task-info .info-line .wrapper {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
}
|
||||
.page-sd .el-overlay-dialog .el-dialog .el-dialog__body .el-row .task-info .info-line .wrapper label {
|
||||
display: flex;
|
||||
width: 100px;
|
||||
color: #a5a5a5;
|
||||
}
|
||||
.page-sd .el-overlay-dialog .el-dialog .el-dialog__body .el-row .task-info .info-line .wrapper .item-value {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
background-color: #35363b;
|
||||
padding: 2px 5px;
|
||||
border-radius: 5px;
|
||||
color: #f5f5f5;
|
||||
}
|
||||
.page-sd .el-overlay-dialog .el-dialog .el-dialog__body .el-row .task-info .copy-params {
|
||||
padding: 20px 0 10px 0;
|
||||
}
|
||||
.page-sd .el-overlay-dialog .el-dialog .el-dialog__body .el-row .task-info .copy-params .el-button {
|
||||
width: 100%;
|
||||
}
|
||||
.page-sd .mj-list-item-prompt .el-icon {
|
||||
margin-left: 10px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
top: 2px;
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
@use 'waterfall-list.scss' as *;
|
||||
@use 'sd-task-dialog.scss' as *;
|
||||
|
||||
.page-sd {
|
||||
// background-color: #282c34;
|
||||
|
||||
.inner {
|
||||
display: flex;
|
||||
|
||||
.sd-box {
|
||||
margin: 10px;
|
||||
// background-color: #262626;
|
||||
// border: 1px solid #454545;
|
||||
min-width: 300px;
|
||||
max-width: 300px;
|
||||
padding: 10px 10px 20px 10px;
|
||||
border-radius: 10px;
|
||||
color: var(--text-theme-color);
|
||||
font-size: 14px;
|
||||
overflow: auto;
|
||||
|
||||
h2 {
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
text-align: center;
|
||||
color: var(--theme-textcolor-normal);
|
||||
}
|
||||
|
||||
// 隐藏滚动条
|
||||
::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.sd-params {
|
||||
margin-top: 10px;
|
||||
overflow: auto;
|
||||
|
||||
.param-line {
|
||||
padding: 0 10px;
|
||||
|
||||
.grid-content,
|
||||
.form-item-inner {
|
||||
display: flex;
|
||||
|
||||
.info-icon {
|
||||
margin-left: 10px;
|
||||
position: relative;
|
||||
top: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.param-line.pt {
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.text-info {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 10px 15px 0 15px;
|
||||
text-align: center;
|
||||
|
||||
.el-button {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.el-form {
|
||||
.el-form-item__label {
|
||||
color: var(--text-theme-color);
|
||||
}
|
||||
}
|
||||
|
||||
.task-list-box {
|
||||
background: var(--chat-bg);
|
||||
width: 100%;
|
||||
color: var(--text-theme-color);
|
||||
overflow-x: hidden;
|
||||
|
||||
.task-list-inner {
|
||||
.el-tabs {
|
||||
--el-tabs-header-height: 55px;
|
||||
}
|
||||
|
||||
.el-tabs__item {
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.title-tabs .el-tabs__item.is-active {
|
||||
color: #47fff1;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.title-tabs .el-tabs__active-bar {
|
||||
background-color: #47fff1;
|
||||
}
|
||||
|
||||
.el-textarea {
|
||||
// --el-input-focus-border-color: #47FFF1;
|
||||
}
|
||||
|
||||
.el-textarea__inner {
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.el-input__wrapper {
|
||||
background: transparent;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.text {
|
||||
margin-bottom: 10px;
|
||||
color: #6b778c;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.param-line.pt {
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.form-item-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.el-icon {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.el-form-item__label {
|
||||
color: var(--text-theme-color);
|
||||
}
|
||||
|
||||
// 图片上传样式
|
||||
.img-inline {
|
||||
display: flex;
|
||||
|
||||
.img-uploader {
|
||||
.el-upload {
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 120px;
|
||||
transition: var(--el-transition-duration-fast);
|
||||
margin-bottom: 20px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.el-icon.uploader-icon {
|
||||
font-size: 28px;
|
||||
color: #8c939d;
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.img-list-box {
|
||||
display: flex;
|
||||
|
||||
.img-item {
|
||||
width: 120px;
|
||||
position: relative;
|
||||
margin-right: 10px;
|
||||
|
||||
.el-image {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.el-button {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 5px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.el-row.text-info {
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
|
||||
.el-tag {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
// 提交按钮
|
||||
.submit-btn {
|
||||
display: flex;
|
||||
margin: 20px 0;
|
||||
|
||||
.el-button {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
// 任务列表
|
||||
}
|
||||
|
||||
.no-more-data {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mj-list-item-prompt {
|
||||
.el-icon {
|
||||
margin-left: 10px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
top: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
@use 'waterfall-list.scss' as *;
|
||||
|
||||
.page-dall {
|
||||
.page-image {
|
||||
// background-color: #282c34;
|
||||
|
||||
.inner {
|
||||
display: flex;
|
||||
|
||||
.sd-box {
|
||||
.image-box {
|
||||
margin: 10px;
|
||||
// background-color: #262626;
|
||||
// border: 1px solid #454545;
|
||||
min-width: 300px;
|
||||
max-width: 300px;
|
||||
max-width: 360px;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
color: var(--text-theme-color);
|
||||
@@ -1,5 +1,3 @@
|
||||
@use 'sd-task-dialog.scss' as *;
|
||||
|
||||
@keyframes expandUp {
|
||||
0% {
|
||||
transform: scaleY(0);
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
.mobile-sd .text-line {
|
||||
padding: 0 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.mobile-sd .text-line .van-row {
|
||||
width: 100%;
|
||||
}
|
||||
.mobile-sd .text-line .van-row .van-col .rate {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background-color: #f5f5f5;
|
||||
padding: 5px 10px;
|
||||
margin: 5px 0;
|
||||
border-radius: 5px;
|
||||
flex-flow: column;
|
||||
}
|
||||
.mobile-sd .text-line .van-row .van-col .rate .icon {
|
||||
text-align: center;
|
||||
}
|
||||
.mobile-sd .text-line .van-row .van-col .rate .icon .van-image {
|
||||
max-width: 20px;
|
||||
}
|
||||
.mobile-sd .text-line .van-row .van-col .rate .text {
|
||||
text-align: center;
|
||||
color: #555;
|
||||
}
|
||||
.mobile-sd .text-line .van-row .van-col .el-input__inner {
|
||||
text-align: center;
|
||||
}
|
||||
.mobile-sd .text-line .van-row .van-col .model {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background-color: #f5f5f5;
|
||||
padding: 6px;
|
||||
margin: 5px 0;
|
||||
border-radius: 5px;
|
||||
flex-flow: column;
|
||||
}
|
||||
.mobile-sd .text-line .van-row .van-col .model .icon {
|
||||
text-align: center;
|
||||
}
|
||||
.mobile-sd .text-line .van-row .van-col .model .icon .van-image {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
}
|
||||
.mobile-sd .text-line .van-row .van-col .model .text {
|
||||
text-align: center;
|
||||
color: #555;
|
||||
}
|
||||
.mobile-sd .text-line .van-row .van-col .active {
|
||||
background-color: #e5e5e5;
|
||||
}
|
||||
.mobile-sd .text-line .van-button {
|
||||
position: relative;
|
||||
}
|
||||
.mobile-sd .text-line .van-button .van-tag {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
}
|
||||
.mobile-sd .text-line .align-right {
|
||||
display: flex;
|
||||
justify-content: right;
|
||||
}
|
||||
.mobile-sd .pt-6 {
|
||||
padding: 10px;
|
||||
}
|
||||
.mobile-sd .tip-text {
|
||||
padding: 10px;
|
||||
line-height: 1.5;
|
||||
color: #c1c1c1;
|
||||
}
|
||||
.mobile-sd .running-job-list .van-grid .van-grid-item .van-grid-item__content {
|
||||
padding: 0;
|
||||
position: relative;
|
||||
}
|
||||
.mobile-sd .running-job-list .van-grid .van-grid-item .van-grid-item__content .van-image,
|
||||
.mobile-sd .running-job-list .van-grid .van-grid-item .van-grid-item__content .task-in-queue {
|
||||
min-height: 100px;
|
||||
}
|
||||
.mobile-sd .running-job-list .van-grid .van-grid-item .van-grid-item__content .progress {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(50,50,50,0.5);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
.mobile-sd .running-job-list .van-grid .van-grid-item .van-grid-item__content .progress .van-circle__text {
|
||||
color: #fff;
|
||||
}
|
||||
.mobile-sd .running-job-list .van-grid .van-grid-item .van-grid-item__content .task-in-queue {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
justify-content: center;
|
||||
color: #c1c1c1;
|
||||
}
|
||||
.mobile-sd .running-job-list .van-grid .van-grid-item .van-grid-item__content .task-in-queue .icon {
|
||||
text-align: center;
|
||||
}
|
||||
.mobile-sd .running-job-list .van-grid .van-grid-item .van-grid-item__content .task-in-queue .icon .iconfont {
|
||||
font-size: 24px;
|
||||
}
|
||||
.mobile-sd .running-job-list .van-grid .van-grid-item .van-grid-item__content .task-in-queue .text {
|
||||
font-size: 14px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.mobile-sd .finish-job-list .van-grid .van-grid-item .van-grid-item__content {
|
||||
padding: 0;
|
||||
}
|
||||
.mobile-sd .finish-job-list .van-grid .van-grid-item .van-grid-item__content .job-item {
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.mobile-sd .finish-job-list .van-grid .van-grid-item .van-grid-item__content .job-item .van-image {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
}
|
||||
.mobile-sd .finish-job-list .van-grid .van-grid-item .van-grid-item__content .job-item .remove {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 5px;
|
||||
}
|
||||
.mobile-sd .finish-job-list .van-grid .van-grid-item .van-grid-item__content .job-item .remove .el-button {
|
||||
margin-left: 5px;
|
||||
height: auto;
|
||||
padding: 5px;
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
.mobile-sd {
|
||||
.text-line {
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
|
||||
.van-row {
|
||||
width: 100%;
|
||||
|
||||
.van-col {
|
||||
.rate {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background-color: #f5f5f5;
|
||||
padding: 5px 10px;
|
||||
margin: 5px 0;
|
||||
border-radius: 5px;
|
||||
flex-flow: column;
|
||||
|
||||
.icon {
|
||||
text-align: center;
|
||||
|
||||
.van-image {
|
||||
max-width: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
text-align: center;
|
||||
color: #555555;
|
||||
}
|
||||
}
|
||||
|
||||
.el-input__inner {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.model {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background-color: #f5f5f5;
|
||||
padding: 6px;
|
||||
margin: 5px 0;
|
||||
border-radius: 5px;
|
||||
flex-flow: column;
|
||||
|
||||
.icon {
|
||||
text-align: center;
|
||||
|
||||
.van-image {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
text-align: center;
|
||||
color: #555555;
|
||||
}
|
||||
}
|
||||
|
||||
.active {
|
||||
background-color: #e5e5e5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.van-button {
|
||||
position: relative;
|
||||
|
||||
.van-tag {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.align-right {
|
||||
display: flex;
|
||||
justify-content: right;
|
||||
}
|
||||
}
|
||||
|
||||
color: var(--van-text-color);
|
||||
|
||||
.pt-6 {
|
||||
padding: 15px 10px;
|
||||
}
|
||||
|
||||
.tip-text {
|
||||
padding: 10px;
|
||||
line-height: 1.5;
|
||||
color: #c1c1c1;
|
||||
}
|
||||
|
||||
.running-job-list {
|
||||
.van-grid {
|
||||
.van-grid-item {
|
||||
.van-grid-item__content {
|
||||
padding: 0;
|
||||
position: relative;
|
||||
|
||||
.van-image,
|
||||
.task-in-queue {
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.progress {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(50, 50, 50, 0.5);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
|
||||
.van-circle__text {
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
// end progress
|
||||
|
||||
.task-in-queue {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
justify-content: center;
|
||||
color: #c1c1c1;
|
||||
|
||||
.icon {
|
||||
text-align: center;
|
||||
|
||||
.iconfont {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 14px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// end running jobs
|
||||
|
||||
.finish-job-list {
|
||||
.van-grid {
|
||||
.van-grid-item {
|
||||
.van-grid-item__content {
|
||||
padding: 0;
|
||||
|
||||
.job-item {
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
.van-image {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.remove {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 5px;
|
||||
|
||||
.el-button {
|
||||
margin-left: 5px;
|
||||
height: auto;
|
||||
padding: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.failed {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
|
||||
.title {
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
color: #ee0a24;
|
||||
font-size: 18px;
|
||||
}
|
||||
.opt {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
.van-button {
|
||||
margin: 0 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
.el-overlay-dialog .el-dialog {
|
||||
background-color: #1a1b1e;
|
||||
}
|
||||
.el-overlay-dialog .el-dialog .el-dialog__header .el-dialog__title {
|
||||
color: #f5f5f5;
|
||||
}
|
||||
.el-overlay-dialog .el-dialog .el-dialog__body {
|
||||
padding: 0 0 0 15px !important;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
.el-overlay-dialog .el-dialog .el-dialog__body .el-row {
|
||||
width: 100%;
|
||||
}
|
||||
.el-overlay-dialog .el-dialog .el-dialog__body .el-row .img-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.el-overlay-dialog .el-dialog .el-dialog__body .el-row .task-info {
|
||||
background-color: #25262b;
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
.el-overlay-dialog .el-dialog .el-dialog__body .el-row .task-info .info-line {
|
||||
width: 100%;
|
||||
}
|
||||
.el-overlay-dialog .el-dialog .el-dialog__body .el-row .task-info .info-line .prompt {
|
||||
background-color: #35363b;
|
||||
padding: 10px;
|
||||
color: #999;
|
||||
overflow: auto;
|
||||
max-height: 100px;
|
||||
min-height: 50px;
|
||||
position: relative;
|
||||
}
|
||||
.el-overlay-dialog .el-dialog .el-dialog__body .el-row .task-info .info-line .prompt .el-icon {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.el-overlay-dialog .el-dialog .el-dialog__body .el-row .task-info .info-line .wrapper {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
}
|
||||
.el-overlay-dialog .el-dialog .el-dialog__body .el-row .task-info .info-line .wrapper label {
|
||||
display: flex;
|
||||
width: 100px;
|
||||
color: #a5a5a5;
|
||||
}
|
||||
.el-overlay-dialog .el-dialog .el-dialog__body .el-row .task-info .info-line .wrapper .item-value {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
background-color: #35363b;
|
||||
padding: 2px 5px;
|
||||
border-radius: 5px;
|
||||
color: #f5f5f5;
|
||||
}
|
||||
.el-overlay-dialog .el-dialog .el-dialog__body .el-row .task-info .copy-params {
|
||||
padding: 20px 0 10px 0;
|
||||
}
|
||||
.el-overlay-dialog .el-dialog .el-dialog__body .el-row .task-info .copy-params .el-button {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
.el-overlay-dialog {
|
||||
.el-dialog {
|
||||
// background-color: #1a1b1e;
|
||||
|
||||
.el-dialog__header {
|
||||
.el-dialog__title {
|
||||
color: var(--text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
padding: 0 0 0 15px !important;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
|
||||
.el-row {
|
||||
width: 100%;
|
||||
|
||||
.img-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
.image-slot {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.el-icon {
|
||||
font-size: 60px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.task-info {
|
||||
// background-color: #25262b;
|
||||
padding: 1rem 1.5rem;
|
||||
|
||||
.info-line {
|
||||
width: 100%;
|
||||
|
||||
.prompt {
|
||||
// background-color: #35363b;
|
||||
padding: 10px;
|
||||
color: #999999;
|
||||
overflow: auto;
|
||||
max-height: 100px;
|
||||
min-height: 50px;
|
||||
|
||||
position: relative;
|
||||
|
||||
.el-icon {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
width: 100px;
|
||||
color: var(--text-fb);
|
||||
}
|
||||
|
||||
.item-value {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
// background-color: #35363b;
|
||||
padding: 2px 5px;
|
||||
border-radius: 5px;
|
||||
color: var(--text-theme-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.copy-params {
|
||||
padding: 20px 0 10px 0;
|
||||
|
||||
.el-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// end el-row
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,8 @@
|
||||
--theme-text-color-primary: #fff;
|
||||
--theme-text-primary: #f3f3f3;
|
||||
--line-box: rgba(255, 255, 255, 0.1);
|
||||
--menu-active-bg: rgba(107, 85, 255, 0.3);
|
||||
--menu-active-color: #d1c7ff;
|
||||
--el-bg-color: #141a36;
|
||||
--el-fill-color-blank: rgba(17, 28, 68, 1);
|
||||
--el-fill-color-light: rgba(86, 86, 95, 0.2);
|
||||
@@ -115,4 +117,19 @@
|
||||
--login-text-color: #a3aed0;
|
||||
--login-link-color: #d1c7ff;
|
||||
--login-link-hover-bg: rgba(209, 199, 255, 0.1);
|
||||
|
||||
// 管理后台 玻璃拟态(dark)
|
||||
--admin-sidebar-bg: rgba(20, 26, 54, 0.92);
|
||||
--admin-sidebar-border: rgba(255, 255, 255, 0.12);
|
||||
--admin-sidebar-active-bg: rgba(107, 85, 255, 0.35);
|
||||
--admin-sidebar-active-border: #8b7cff;
|
||||
--admin-sidebar-text: #e5e7eb;
|
||||
--admin-sidebar-text-muted: #9ca3af;
|
||||
--admin-header-bg: rgba(20, 26, 54, 0.88);
|
||||
--admin-header-border: rgba(255, 255, 255, 0.1);
|
||||
--admin-header-hover: rgba(255, 255, 255, 0.08);
|
||||
--admin-tags-bg: rgba(20, 26, 54, 0.85);
|
||||
--admin-tags-item-bg: rgba(255, 255, 255, 0.06);
|
||||
--admin-tags-active-bg: rgba(107, 85, 255, 0.3);
|
||||
--admin-tags-border: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
--text-theme-color: rgba(43, 54, 116, 1);
|
||||
--text-color-primary: rgba(67, 24, 255, 1);
|
||||
--line-box: rgba(79, 89, 102, 0.122);
|
||||
--menu-active-bg: rgba(107, 85, 255, 0.3);
|
||||
--menu-active-color: rgba(67, 24, 255, 1);
|
||||
--theme-text-color-primary: #000;
|
||||
--theme-text-primary: #000;
|
||||
--theme-text-color-secondary: #666;
|
||||
@@ -73,4 +75,19 @@
|
||||
--login-text-color: #666;
|
||||
--login-link-color: #5b62ce;
|
||||
--login-link-hover-bg: rgba(91, 98, 206, 0.1);
|
||||
|
||||
// 管理后台 玻璃拟态(light)
|
||||
--admin-sidebar-bg: rgba(255, 255, 255, 0.85);
|
||||
--admin-sidebar-border: rgba(0, 0, 0, 0.06);
|
||||
--admin-sidebar-active-bg: rgba(107, 85, 255, 0.2);
|
||||
--admin-sidebar-active-border: #6b55ff;
|
||||
--admin-sidebar-text: #374151;
|
||||
--admin-sidebar-text-muted: #6b7280;
|
||||
--admin-header-bg: rgba(255, 255, 255, 0.8);
|
||||
--admin-header-border: rgba(0, 0, 0, 0.06);
|
||||
--admin-header-hover: rgba(107, 85, 255, 0.08);
|
||||
--admin-tags-bg: rgba(255, 255, 255, 0.75);
|
||||
--admin-tags-item-bg: rgba(255, 255, 255, 0.6);
|
||||
--admin-tags-active-bg: rgba(107, 85, 255, 0.25);
|
||||
--admin-tags-border: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
@@ -25,10 +25,10 @@
|
||||
|
||||
// 左侧参数面板
|
||||
.params-panel {
|
||||
min-width: 320px;
|
||||
max-width: 320px;
|
||||
min-width: 380px;
|
||||
max-width: 380px;
|
||||
margin: 10px;
|
||||
padding: 0 15px 20px 15px;
|
||||
padding: 15px 15px 20px 15px;
|
||||
border-radius: 10px;
|
||||
color: var(--text-theme-color);
|
||||
font-size: 14px;
|
||||
@@ -422,6 +422,9 @@
|
||||
align-items: flex-start;
|
||||
flex-flow: column;
|
||||
padding: 0 20px;
|
||||
// 允许中间区域在 flex 布局中收缩,避免挤压右侧操作区
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
|
||||
.prompt,
|
||||
.failed {
|
||||
@@ -440,6 +443,10 @@
|
||||
|
||||
.failed {
|
||||
color: #e4696b;
|
||||
// 错误信息中经常包含很长的无空格字符串(如 JSON、错误栈),
|
||||
// 强制单词内换行,防止撑破布局把右侧删除按钮挤出视口
|
||||
word-break: break-all;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.pb-2 {
|
||||
@@ -495,6 +502,9 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
// 保证错误状态下删除按钮始终占据固定宽度,不被中间内容挤压
|
||||
min-width: 60px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,8 +1,8 @@
|
||||
@font-face {
|
||||
font-family: "iconfont"; /* Project id 4125778 */
|
||||
src: url('iconfont.woff2?t=1757571432313') format('woff2'),
|
||||
url('iconfont.woff?t=1757571432313') format('woff'),
|
||||
url('iconfont.ttf?t=1757571432313') format('truetype');
|
||||
src: url('iconfont.woff2?t=1773972304243') format('woff2'),
|
||||
url('iconfont.woff?t=1773972304243') format('woff'),
|
||||
url('iconfont.ttf?t=1773972304243') format('truetype');
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
@@ -13,6 +13,126 @@
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon-ppt:before {
|
||||
content: "\e67d";
|
||||
}
|
||||
|
||||
.icon-pin:before {
|
||||
content: "\e9f2";
|
||||
}
|
||||
|
||||
.icon-unpin:before {
|
||||
content: "\e67c";
|
||||
}
|
||||
|
||||
.icon-rebot:before {
|
||||
content: "\e6bf";
|
||||
}
|
||||
|
||||
.icon-rebot2:before {
|
||||
content: "\e67b";
|
||||
}
|
||||
|
||||
.icon-doubao:before {
|
||||
content: "\e67a";
|
||||
}
|
||||
|
||||
.icon-seedance:before {
|
||||
content: "\e902";
|
||||
}
|
||||
|
||||
.icon-folder:before {
|
||||
content: "\e806";
|
||||
}
|
||||
|
||||
.icon-calendar:before {
|
||||
content: "\e81a";
|
||||
}
|
||||
|
||||
.icon-timer:before {
|
||||
content: "\ece1";
|
||||
}
|
||||
|
||||
.icon-aigc:before {
|
||||
content: "\e676";
|
||||
}
|
||||
|
||||
.icon-wan:before {
|
||||
content: "\e673";
|
||||
}
|
||||
|
||||
.icon-minimax:before {
|
||||
content: "\e7df";
|
||||
}
|
||||
|
||||
.icon-gemini:before {
|
||||
content: "\e674";
|
||||
}
|
||||
|
||||
.icon-sora:before {
|
||||
content: "\e675";
|
||||
}
|
||||
|
||||
.icon-workflow:before {
|
||||
content: "\e68b";
|
||||
}
|
||||
|
||||
.icon-ai-agent:before {
|
||||
content: "\eba3";
|
||||
}
|
||||
|
||||
.icon-channel2:before {
|
||||
content: "\e66d";
|
||||
}
|
||||
|
||||
.icon-channel:before {
|
||||
content: "\e738";
|
||||
}
|
||||
|
||||
.icon-warning:before {
|
||||
content: "\e668";
|
||||
}
|
||||
|
||||
.icon-info:before {
|
||||
content: "\e66c";
|
||||
}
|
||||
|
||||
.icon-aspect_9_16:before {
|
||||
content: "\e65f";
|
||||
}
|
||||
|
||||
.icon-aspect_2_3:before {
|
||||
content: "\e660";
|
||||
}
|
||||
|
||||
.icon-aspect_4_5:before {
|
||||
content: "\e662";
|
||||
}
|
||||
|
||||
.icon-aspect_3_4:before {
|
||||
content: "\e663";
|
||||
}
|
||||
|
||||
.icon-aspect_4_3:before {
|
||||
content: "\e664";
|
||||
}
|
||||
|
||||
.icon-aspect_3_2:before {
|
||||
content: "\e665";
|
||||
}
|
||||
|
||||
.icon-aspect_1_1:before {
|
||||
content: "\e667";
|
||||
}
|
||||
|
||||
.icon-aspect_5_4:before {
|
||||
content: "\e669";
|
||||
}
|
||||
|
||||
.icon-aspect_16_9:before {
|
||||
content: "\e66b";
|
||||
}
|
||||
|
||||
.icon-resize:before {
|
||||
content: "\e718";
|
||||
}
|
||||
@@ -329,10 +449,6 @@
|
||||
content: "\e8e9";
|
||||
}
|
||||
|
||||
.icon-AIduihua_jihuo:before {
|
||||
content: "\e6bb";
|
||||
}
|
||||
|
||||
.icon-MidJourney:before {
|
||||
content: "\e60e";
|
||||
}
|
||||
@@ -341,10 +457,6 @@
|
||||
content: "\e60f";
|
||||
}
|
||||
|
||||
.icon-info:before {
|
||||
content: "\e6a0";
|
||||
}
|
||||
|
||||
.icon-more-horizontal:before {
|
||||
content: "\e60d";
|
||||
}
|
||||
@@ -609,10 +721,6 @@
|
||||
content: "\e6c3";
|
||||
}
|
||||
|
||||
.icon-ppt:before {
|
||||
content: "\e642";
|
||||
}
|
||||
|
||||
.icon-txt:before {
|
||||
content: "\e644";
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -5,6 +5,216 @@
|
||||
"css_prefix_text": "icon-",
|
||||
"description": "",
|
||||
"glyphs": [
|
||||
{
|
||||
"icon_id": "6233087",
|
||||
"name": "PPT图标",
|
||||
"font_class": "ppt",
|
||||
"unicode": "e67d",
|
||||
"unicode_decimal": 59005
|
||||
},
|
||||
{
|
||||
"icon_id": "703362",
|
||||
"name": "pin",
|
||||
"font_class": "pin",
|
||||
"unicode": "e9f2",
|
||||
"unicode_decimal": 59890
|
||||
},
|
||||
{
|
||||
"icon_id": "35252746",
|
||||
"name": "取消固定",
|
||||
"font_class": "unpin",
|
||||
"unicode": "e67c",
|
||||
"unicode_decimal": 59004
|
||||
},
|
||||
{
|
||||
"icon_id": "17548043",
|
||||
"name": "智能地址",
|
||||
"font_class": "rebot",
|
||||
"unicode": "e6bf",
|
||||
"unicode_decimal": 59071
|
||||
},
|
||||
{
|
||||
"icon_id": "22095008",
|
||||
"name": "VR小助手",
|
||||
"font_class": "rebot2",
|
||||
"unicode": "e67b",
|
||||
"unicode_decimal": 59003
|
||||
},
|
||||
{
|
||||
"icon_id": "44204797",
|
||||
"name": "豆包",
|
||||
"font_class": "doubao",
|
||||
"unicode": "e67a",
|
||||
"unicode_decimal": 59002
|
||||
},
|
||||
{
|
||||
"icon_id": "46195143",
|
||||
"name": "doubao",
|
||||
"font_class": "seedance",
|
||||
"unicode": "e902",
|
||||
"unicode_decimal": 59650
|
||||
},
|
||||
{
|
||||
"icon_id": "6151242",
|
||||
"name": "folder",
|
||||
"font_class": "folder",
|
||||
"unicode": "e806",
|
||||
"unicode_decimal": 59398
|
||||
},
|
||||
{
|
||||
"icon_id": "6151276",
|
||||
"name": "calendar-alt",
|
||||
"font_class": "calendar",
|
||||
"unicode": "e81a",
|
||||
"unicode_decimal": 59418
|
||||
},
|
||||
{
|
||||
"icon_id": "21096843",
|
||||
"name": "timer",
|
||||
"font_class": "timer",
|
||||
"unicode": "ece1",
|
||||
"unicode_decimal": 60641
|
||||
},
|
||||
{
|
||||
"icon_id": "40481666",
|
||||
"name": "ai",
|
||||
"font_class": "aigc",
|
||||
"unicode": "e676",
|
||||
"unicode_decimal": 58998
|
||||
},
|
||||
{
|
||||
"icon_id": "37654849",
|
||||
"name": "通义万相",
|
||||
"font_class": "wan",
|
||||
"unicode": "e673",
|
||||
"unicode_decimal": 58995
|
||||
},
|
||||
{
|
||||
"icon_id": "43413883",
|
||||
"name": "MiniMax",
|
||||
"font_class": "minimax",
|
||||
"unicode": "e7df",
|
||||
"unicode_decimal": 59359
|
||||
},
|
||||
{
|
||||
"icon_id": "43756975",
|
||||
"name": "gemini-ai",
|
||||
"font_class": "gemini",
|
||||
"unicode": "e674",
|
||||
"unicode_decimal": 58996
|
||||
},
|
||||
{
|
||||
"icon_id": "46182385",
|
||||
"name": "Sora",
|
||||
"font_class": "sora",
|
||||
"unicode": "e675",
|
||||
"unicode_decimal": 58997
|
||||
},
|
||||
{
|
||||
"icon_id": "7057916",
|
||||
"name": "workflow",
|
||||
"font_class": "workflow",
|
||||
"unicode": "e68b",
|
||||
"unicode_decimal": 59019
|
||||
},
|
||||
{
|
||||
"icon_id": "45175337",
|
||||
"name": "AI_Agent",
|
||||
"font_class": "ai-agent",
|
||||
"unicode": "eba3",
|
||||
"unicode_decimal": 60323
|
||||
},
|
||||
{
|
||||
"icon_id": "4577005",
|
||||
"name": "渠道",
|
||||
"font_class": "channel2",
|
||||
"unicode": "e66d",
|
||||
"unicode_decimal": 58989
|
||||
},
|
||||
{
|
||||
"icon_id": "6692801",
|
||||
"name": "channel",
|
||||
"font_class": "channel",
|
||||
"unicode": "e738",
|
||||
"unicode_decimal": 59192
|
||||
},
|
||||
{
|
||||
"icon_id": "8759958",
|
||||
"name": "warning",
|
||||
"font_class": "warning",
|
||||
"unicode": "e668",
|
||||
"unicode_decimal": 58984
|
||||
},
|
||||
{
|
||||
"icon_id": "21161469",
|
||||
"name": "info",
|
||||
"font_class": "info",
|
||||
"unicode": "e66c",
|
||||
"unicode_decimal": 58988
|
||||
},
|
||||
{
|
||||
"icon_id": "44336762",
|
||||
"name": "9_16",
|
||||
"font_class": "aspect_9_16",
|
||||
"unicode": "e65f",
|
||||
"unicode_decimal": 58975
|
||||
},
|
||||
{
|
||||
"icon_id": "44336765",
|
||||
"name": "2_3",
|
||||
"font_class": "aspect_2_3",
|
||||
"unicode": "e660",
|
||||
"unicode_decimal": 58976
|
||||
},
|
||||
{
|
||||
"icon_id": "44336767",
|
||||
"name": "4_5",
|
||||
"font_class": "aspect_4_5",
|
||||
"unicode": "e662",
|
||||
"unicode_decimal": 58978
|
||||
},
|
||||
{
|
||||
"icon_id": "44336769",
|
||||
"name": "3_4",
|
||||
"font_class": "aspect_3_4",
|
||||
"unicode": "e663",
|
||||
"unicode_decimal": 58979
|
||||
},
|
||||
{
|
||||
"icon_id": "44336774",
|
||||
"name": "4_3",
|
||||
"font_class": "aspect_4_3",
|
||||
"unicode": "e664",
|
||||
"unicode_decimal": 58980
|
||||
},
|
||||
{
|
||||
"icon_id": "44336777",
|
||||
"name": "3_2",
|
||||
"font_class": "aspect_3_2",
|
||||
"unicode": "e665",
|
||||
"unicode_decimal": 58981
|
||||
},
|
||||
{
|
||||
"icon_id": "44336781",
|
||||
"name": "1_1",
|
||||
"font_class": "aspect_1_1",
|
||||
"unicode": "e667",
|
||||
"unicode_decimal": 58983
|
||||
},
|
||||
{
|
||||
"icon_id": "44336784",
|
||||
"name": "5_4",
|
||||
"font_class": "aspect_5_4",
|
||||
"unicode": "e669",
|
||||
"unicode_decimal": 58985
|
||||
},
|
||||
{
|
||||
"icon_id": "44336786",
|
||||
"name": "16_9",
|
||||
"font_class": "aspect_16_9",
|
||||
"unicode": "e66b",
|
||||
"unicode_decimal": 58987
|
||||
},
|
||||
{
|
||||
"icon_id": "10564356",
|
||||
"name": "resize",
|
||||
@@ -558,13 +768,6 @@
|
||||
"unicode": "e8e9",
|
||||
"unicode_decimal": 59625
|
||||
},
|
||||
{
|
||||
"icon_id": "35446270",
|
||||
"name": "AI对话_激活",
|
||||
"font_class": "AIduihua_jihuo",
|
||||
"unicode": "e6bb",
|
||||
"unicode_decimal": 59067
|
||||
},
|
||||
{
|
||||
"icon_id": "39584617",
|
||||
"name": "MidJourney",
|
||||
@@ -579,13 +782,6 @@
|
||||
"unicode": "e60f",
|
||||
"unicode_decimal": 58895
|
||||
},
|
||||
{
|
||||
"icon_id": "1227734",
|
||||
"name": "info",
|
||||
"font_class": "info",
|
||||
"unicode": "e6a0",
|
||||
"unicode_decimal": 59040
|
||||
},
|
||||
{
|
||||
"icon_id": "159969",
|
||||
"name": "more",
|
||||
@@ -721,7 +917,7 @@
|
||||
},
|
||||
{
|
||||
"icon_id": "41645421",
|
||||
"name": "luma-logo",
|
||||
"name": "luma",
|
||||
"font_class": "luma",
|
||||
"unicode": "e704",
|
||||
"unicode_decimal": 59140
|
||||
@@ -1048,13 +1244,6 @@
|
||||
"unicode": "e6c3",
|
||||
"unicode_decimal": 59075
|
||||
},
|
||||
{
|
||||
"icon_id": "4628503",
|
||||
"name": "ppt",
|
||||
"font_class": "ppt",
|
||||
"unicode": "e642",
|
||||
"unicode_decimal": 58946
|
||||
},
|
||||
{
|
||||
"icon_id": "6233095",
|
||||
"name": "txt",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,540 @@
|
||||
<template>
|
||||
<div class="agent-manage-page">
|
||||
<!-- 系统预设的智能体:默认一行,可展开 -->
|
||||
<section class="agent-section">
|
||||
<div class="agent-section-head">
|
||||
<h3 class="agent-section-title">系统预设的智能体</h3>
|
||||
<el-button
|
||||
text
|
||||
type="primary"
|
||||
size="small"
|
||||
class="expand-btn"
|
||||
@click="systemExpanded = !systemExpanded"
|
||||
>
|
||||
{{ systemExpanded ? '收起' : '展开' }}
|
||||
<el-icon class="expand-icon" :class="{ expanded: systemExpanded }">
|
||||
<ArrowDown />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="system-cards" :class="{ 'one-row': !systemExpanded }">
|
||||
<button
|
||||
v-for="item in systemAgentsDisplay"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="system-card"
|
||||
@click="$emit('select', item)"
|
||||
>
|
||||
<div class="system-card-icon-wrap">
|
||||
<el-image v-if="item.icon" :src="item.icon" class="system-card-icon" fit="cover" />
|
||||
<span v-else class="system-card-icon-ph" />
|
||||
</div>
|
||||
<div class="system-card-body pl-2">
|
||||
<span class="system-card-name">{{ item.name }}</span>
|
||||
<p class="system-card-desc">{{ item.hello_msg || '' }}</p>
|
||||
</div>
|
||||
<span class="system-card-more" @click.stop>
|
||||
<el-dropdown trigger="click" @command="(cmd) => onSystemCommand(cmd, item)">
|
||||
<span class="more-trigger">
|
||||
<i class="iconfont icon-more-horizontal"></i>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="chat">
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
发起新对话
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="pin">
|
||||
<template v-if="isPinned(item.id)">
|
||||
<i class="iconfont icon-unpin"></i>
|
||||
{{ '取消固定' }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="iconfont icon-pin"></i>
|
||||
{{ '固定' }}
|
||||
</template>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 我的智能体 -->
|
||||
<section class="agent-section">
|
||||
<div class="agent-section-head">
|
||||
<h3 class="agent-section-title">
|
||||
我的智能体
|
||||
<el-icon class="title-info"><InfoFilled /></el-icon>
|
||||
</h3>
|
||||
<el-button type="primary" size="small" @click="$emit('new')">+ 新建智能体</el-button>
|
||||
</div>
|
||||
<div class="my-list">
|
||||
<div
|
||||
v-for="item in myAgents"
|
||||
:key="item.id"
|
||||
class="my-list-row"
|
||||
@click="$emit('select', item)"
|
||||
>
|
||||
<div class="my-list-icon">
|
||||
<el-image v-if="item.icon" :src="item.icon" class="my-list-icon-img" fit="cover" />
|
||||
<span v-else class="my-list-icon-ph" />
|
||||
</div>
|
||||
<div class="my-list-body">
|
||||
<span class="my-list-name">{{ item.name }}</span>
|
||||
<p class="my-list-desc">{{ item.hello_msg || '' }}</p>
|
||||
</div>
|
||||
<div class="my-list-actions" @click.stop>
|
||||
<span class="my-list-action" title="编辑" @click="emit('edit', item)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
</span>
|
||||
<el-dropdown trigger="click" @command="(cmd) => onMyCommand(cmd, item)">
|
||||
<span class="my-list-action more-trigger">
|
||||
<i class="iconfont icon-more-horizontal"></i>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="chat">
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
发起新对话
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="pin">
|
||||
<template v-if="isPinned(item.id)">
|
||||
<i class="iconfont icon-unpin"></i>
|
||||
{{ '取消固定' }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="iconfont icon-pin"></i>
|
||||
{{ '固定' }}
|
||||
</template>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="edit">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="copy">
|
||||
<el-icon><DocumentCopy /></el-icon>
|
||||
复制
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="delete" divided>
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span style="color: var(--el-color-danger)">删除</span>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="myAgents.length === 0" class="my-list-empty">
|
||||
暂无自己创建的智能体,点击「新建智能体」创建
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
ArrowDown,
|
||||
ChatDotRound,
|
||||
Delete,
|
||||
DocumentCopy,
|
||||
Edit,
|
||||
InfoFilled,
|
||||
Rank,
|
||||
Share,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { httpGet, httpPost } from '@/utils/http'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
profile: {
|
||||
type: Object,
|
||||
default: () => ({ gem_ids: [] }),
|
||||
},
|
||||
refreshTrigger: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select', 'new', 'edit', 'profile-update'])
|
||||
|
||||
const allAgents = ref([])
|
||||
const systemExpanded = ref(false)
|
||||
|
||||
const gemIds = computed(() => props.profile?.gem_ids || [])
|
||||
|
||||
// 统一按数字比较,避免 API 返回字符串导致 pin/unpin 失效
|
||||
const toNum = (v) => (typeof v === 'number' && !Number.isNaN(v) ? v : Number(v))
|
||||
const normalizedGemIds = computed(() =>
|
||||
(gemIds.value || []).map(toNum).filter((n) => !Number.isNaN(n) && n > 0)
|
||||
)
|
||||
|
||||
const systemAgents = computed(() => allAgents.value.filter((a) => a.user_id === 0))
|
||||
const systemAgentsDisplay = computed(() =>
|
||||
systemExpanded.value ? systemAgents.value : systemAgents.value.slice(0, 3)
|
||||
)
|
||||
const myAgents = computed(() => allAgents.value.filter((a) => a.user_id > 0))
|
||||
|
||||
function isPinned(id) {
|
||||
const n = toNum(id)
|
||||
return normalizedGemIds.value.some((x) => x === n)
|
||||
}
|
||||
|
||||
async function updateGemIds(newIds) {
|
||||
const raw = Array.isArray(newIds) ? newIds.slice(0, 8) : []
|
||||
const ids = raw.map(toNum).filter((n) => !Number.isNaN(n) && n > 0)
|
||||
try {
|
||||
await httpPost('/api/user/profile/update', { ...props.profile, gem_ids: ids })
|
||||
emit('profile-update', ids)
|
||||
} catch (e) {
|
||||
ElMessage.error('操作失败:' + (e.message || ''))
|
||||
}
|
||||
}
|
||||
|
||||
function doPin(id) {
|
||||
const n = toNum(id)
|
||||
if (normalizedGemIds.value.some((x) => x === n)) return
|
||||
if (normalizedGemIds.value.length >= 8) {
|
||||
ElMessage.warning('最多固定 8 个智能体')
|
||||
return
|
||||
}
|
||||
const ids = [...normalizedGemIds.value, n]
|
||||
updateGemIds(ids)
|
||||
}
|
||||
|
||||
function doUnpin(id) {
|
||||
const target = toNum(id)
|
||||
const ids = normalizedGemIds.value.filter((x) => x !== target)
|
||||
updateGemIds(ids)
|
||||
}
|
||||
|
||||
async function fetchAgentList() {
|
||||
try {
|
||||
const res = await httpGet('/api/app/list/user')
|
||||
allAgents.value = res.data || []
|
||||
} catch (e) {
|
||||
ElMessage.error('加载智能体列表失败:' + (e.message || ''))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchAgentList)
|
||||
watch(() => props.refreshTrigger, fetchAgentList)
|
||||
|
||||
function onSystemCommand(cmd, item) {
|
||||
if (cmd === 'chat') emit('select', item)
|
||||
else if (cmd === 'pin') {
|
||||
if (isPinned(item.id)) doUnpin(item.id)
|
||||
else doPin(item.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function onMyCommand(cmd, item) {
|
||||
if (cmd === 'chat') {
|
||||
emit('select', item)
|
||||
return
|
||||
}
|
||||
if (cmd === 'edit') {
|
||||
emit('edit', item)
|
||||
return
|
||||
}
|
||||
if (cmd === 'pin') {
|
||||
if (isPinned(item.id)) doUnpin(item.id)
|
||||
else doPin(item.id)
|
||||
return
|
||||
}
|
||||
if (cmd === 'copy') {
|
||||
try {
|
||||
const res = await httpPost('/api/app/copy', { source_id: item.id })
|
||||
ElMessage.success('已复制到「我的智能体」')
|
||||
const listRes = await httpGet('/api/app/list/user')
|
||||
allAgents.value = listRes.data || []
|
||||
if (res.data?.id) doPin(res.data.id)
|
||||
} catch (e) {
|
||||
ElMessage.error('复制失败:' + (e.message || ''))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (cmd === 'delete') {
|
||||
try {
|
||||
await httpPost('/api/app/remove', { id: item.id })
|
||||
ElMessage.success('已删除')
|
||||
const listRes = await httpGet('/api/app/list/user')
|
||||
allAgents.value = listRes.data || []
|
||||
doUnpin(item.id)
|
||||
} catch (e) {
|
||||
ElMessage.error('删除失败:' + (e.message || ''))
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.agent-manage-page {
|
||||
padding: 0 0 32px;
|
||||
}
|
||||
|
||||
.agent-section {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.agent-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.agent-section-title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-fb, #111827);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.title-info {
|
||||
font-size: 14px;
|
||||
color: #9ca3af;
|
||||
cursor: help;
|
||||
}
|
||||
}
|
||||
|
||||
.expand-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
transition: transform 0.2s ease;
|
||||
&.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.system-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
|
||||
&.one-row {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.system-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: #f9fafb;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
.system-card-icon-wrap {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.system-card-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.system-card-icon-ph {
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: linear-gradient(135deg, #a78bfa 0%, #7c3aed 100%);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.system-card-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-right: 28px;
|
||||
}
|
||||
|
||||
.system-card-name {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-fb, #111827);
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.system-card-desc {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.system-card-pin,
|
||||
.system-card-more {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 8px;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
|
||||
&:hover {
|
||||
color: #374151;
|
||||
}
|
||||
}
|
||||
|
||||
.system-card-more {
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.system-card-pin {
|
||||
right: 36px;
|
||||
}
|
||||
|
||||
.more-trigger {
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 我的智能体列表 */
|
||||
.my-list {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.my-list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
}
|
||||
|
||||
.my-list-icon {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-right: 12px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.my-list-icon-img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.my-list-icon-ph {
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: linear-gradient(135deg, #a78bfa 0%, #7c3aed 100%);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.my-list-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.my-list-name {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-fb, #111827);
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.my-list-desc {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: #6b7280;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.my-list-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.my-list-action {
|
||||
color: #9ca3af;
|
||||
padding: 6px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.my-list-empty {
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.system-cards.one-row {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -73,6 +73,7 @@ import { FormatFileSize, GetFileIcon } from '@/store/system'
|
||||
import { computed } from 'vue'
|
||||
import { httpDownload } from '@/utils/http'
|
||||
import { replaceImg } from '@/utils/libs'
|
||||
import { showMessageError } from '@/utils/dialog'
|
||||
|
||||
const props = defineProps({
|
||||
files: {
|
||||
|
||||
@@ -6,17 +6,15 @@
|
||||
<el-input v-model="form.email" />
|
||||
</el-form-item>
|
||||
<el-form-item label="验证码">
|
||||
<el-row :gutter="0">
|
||||
<el-col :span="16">
|
||||
<el-input v-model="form.code" maxlength="6" />
|
||||
</el-col>
|
||||
<el-col :span="8" style="padding-left: 10px">
|
||||
<div class="flex w-full items-center gap-2">
|
||||
<el-input v-model="form.code" maxlength="6" class="flex-1" />
|
||||
<span class="flex-none">
|
||||
<send-msg :receiver="form.email" type="email" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="dialog-footer text-center">
|
||||
<div class="pt-3 flex justify-end">
|
||||
<el-button type="primary" @click="save"> 保存 </el-button>
|
||||
<el-button @click="emits('hide')"> 取消 </el-button>
|
||||
</div>
|
||||
@@ -62,14 +60,6 @@ const save = () => {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.form {
|
||||
.text-center {
|
||||
text-align: center;
|
||||
padding-bottom: 15px;
|
||||
font-size: 14px;
|
||||
color: #a1a1a1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.el-form-item__content {
|
||||
.el-row {
|
||||
width: 100%;
|
||||
|
||||
@@ -6,17 +6,15 @@
|
||||
<el-input v-model="form.mobile" />
|
||||
</el-form-item>
|
||||
<el-form-item label="验证码">
|
||||
<el-row :gutter="0">
|
||||
<el-col :span="16">
|
||||
<el-input v-model="form.code" maxlength="6" />
|
||||
</el-col>
|
||||
<el-col :span="8" style="padding-left: 10px">
|
||||
<div class="flex w-full items-center gap-2">
|
||||
<el-input v-model="form.code" maxlength="6" class="flex-1" />
|
||||
<span class="flex-none">
|
||||
<send-msg :receiver="form.mobile" size="default" type="mobile" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="dialog-footer text-center">
|
||||
<div class="pt-3 flex justify-end">
|
||||
<el-button type="primary" @click="save"> 保存 </el-button>
|
||||
<el-button @click="emits('hide')"> 取消 </el-button>
|
||||
</div>
|
||||
@@ -62,14 +60,6 @@ const save = () => {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.form {
|
||||
.text-center {
|
||||
text-align: center;
|
||||
padding-bottom: 15px;
|
||||
font-size: 14px;
|
||||
color: #a1a1a1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.el-form-item__content {
|
||||
.el-row {
|
||||
width: 100%;
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
<template>
|
||||
<div class="chat-input-container">
|
||||
<div class="chat-input-wrapper">
|
||||
<div ref="textHeightRef" class="text-height-calculator">{{ localPrompt }}</div>
|
||||
<div class="input-content">
|
||||
<div class="input-area">
|
||||
<div v-if="files.length > 0" class="file-preview-list">
|
||||
<file-list :files="files" @remove-file="handleRemoveFile" />
|
||||
</div>
|
||||
<textarea
|
||||
ref="inputRef"
|
||||
class="prompt-textarea"
|
||||
:rows="row"
|
||||
v-model="localPrompt"
|
||||
@keydown="handleInput"
|
||||
@input="handleInput"
|
||||
@paste="handlePaste"
|
||||
:placeholder="placeholder"
|
||||
autofocus
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="toolbar-container">
|
||||
<div class="toolbar-left">
|
||||
<span class="toolbar-btn" title="上传附件">
|
||||
<file-select :user-id="userId" @selected="handleFileSelected" />
|
||||
</span>
|
||||
<el-dropdown :hide-on-click="false" trigger="click">
|
||||
<span class="toolbar-btn" title="插件">
|
||||
<i class="iconfont icon-plugin"></i>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu class="tools-dropdown">
|
||||
<el-checkbox-group v-model="localToolSelected">
|
||||
<el-dropdown-item v-for="item in tools" :key="item.id">
|
||||
<el-checkbox :value="item.id" :label="item.label" />
|
||||
<el-tooltip :content="item.description" placement="right">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</el-dropdown-item>
|
||||
</el-checkbox-group>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<span class="toolbar-btn" title="配置" @click="$emit('show-setting')">
|
||||
<i class="iconfont icon-config"></i>
|
||||
</span>
|
||||
<span v-if="agentName" class="agent-label">
|
||||
{{ agentName }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<span class="model-selector-wrapper">
|
||||
<el-select
|
||||
v-model="localModelId"
|
||||
size="small"
|
||||
class="model-select"
|
||||
:disabled="disableModel"
|
||||
placeholder="选择模型"
|
||||
filterable
|
||||
>
|
||||
<template #label="{ label }">
|
||||
<span class="text-sm font-bold">{{ selectedModel?.name || label }}</span>
|
||||
</template>
|
||||
<el-option v-for="m in models" :key="m.id" :value="m.id">
|
||||
<div class="flex items-center justify-center gap-1.5 h-full">
|
||||
<span class="text-sm font-bold">{{ m.name }}</span>
|
||||
<el-tag size="small" :type="m.power > 0 ? 'info' : 'success'" effect="plain">{{
|
||||
m.power > 0 ? m.power + '算力/次' : '免费'
|
||||
}}</el-tag>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</span>
|
||||
<span class="send-btn-wrapper">
|
||||
<el-button
|
||||
type="info"
|
||||
v-if="isGenerating"
|
||||
@click="$emit('stop')"
|
||||
plain
|
||||
class="action-btn stop-btn"
|
||||
>
|
||||
<el-icon><VideoPause /></el-icon>
|
||||
</el-button>
|
||||
<el-button
|
||||
@click="$emit('send')"
|
||||
:disabled="isGenerating"
|
||||
v-else
|
||||
title="发送"
|
||||
class="action-btn send-btn"
|
||||
>
|
||||
<el-icon><Promotion /></el-icon>
|
||||
</el-button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import FileList from '@/components/FileList.vue'
|
||||
import FileSelect from '@/components/FileSelect.vue'
|
||||
import { InfoFilled, Promotion, VideoPause } from '@element-plus/icons-vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
files: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
modelId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
models: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
tools: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
toolSelected: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isGenerating: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disableModel: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '按 Enter 键发送消息,使用 Shift + Enter 换行',
|
||||
},
|
||||
userId: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
agentName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:modelValue',
|
||||
'update:modelId',
|
||||
'update:toolSelected',
|
||||
'send',
|
||||
'stop',
|
||||
'paste',
|
||||
'input',
|
||||
'file-selected',
|
||||
'file-removed',
|
||||
'show-setting',
|
||||
])
|
||||
|
||||
const inputRef = ref(null)
|
||||
const textHeightRef = ref(null)
|
||||
const row = ref(3)
|
||||
|
||||
const localPrompt = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const localModelId = computed({
|
||||
get: () => props.modelId,
|
||||
set: (value) => emit('update:modelId', value),
|
||||
})
|
||||
|
||||
const localToolSelected = computed({
|
||||
get: () => props.toolSelected,
|
||||
set: (value) => emit('update:toolSelected', value),
|
||||
})
|
||||
|
||||
const selectedModel = computed(() => {
|
||||
return props.models.find((model) => model.id === props.modelId)
|
||||
})
|
||||
|
||||
const handleInput = (e) => {
|
||||
if (!textHeightRef.value || !inputRef.value) return
|
||||
|
||||
const lineHeight = parseFloat(window.getComputedStyle(inputRef.value).lineHeight)
|
||||
textHeightRef.value.style.width = inputRef.value.clientWidth + 'px'
|
||||
const lines = Math.floor(textHeightRef.value.clientHeight / lineHeight)
|
||||
|
||||
if (localPrompt.value.length < 10) {
|
||||
row.value = 1
|
||||
} else if (lines <= 7) {
|
||||
row.value = lines
|
||||
} else {
|
||||
row.value = 7
|
||||
}
|
||||
|
||||
emit('input', e)
|
||||
|
||||
// 输入回车自动提交
|
||||
if (e.keyCode === 13) {
|
||||
// Shift + Enter 换行
|
||||
if (e.shiftKey) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
emit('send')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePaste = (e) => {
|
||||
emit('paste', e)
|
||||
}
|
||||
|
||||
const handleFileSelected = (file) => {
|
||||
emit('file-selected', file)
|
||||
}
|
||||
|
||||
const handleRemoveFile = (file) => {
|
||||
emit('file-removed', file)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chat-input-container {
|
||||
width: 100%;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.chat-input-wrapper {
|
||||
width: 100%;
|
||||
background: var(--el-bg-color, #ffffff);
|
||||
border: 2px solid var(--el-border-color-light, #e4e7ed);
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary-light-5, #a0cfff);
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--el-color-primary, #409eff);
|
||||
box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.text-height-calculator {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
line-height: 24px;
|
||||
font-size: 14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.input-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.file-preview-list {
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter, #ebeef5);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.prompt-textarea {
|
||||
width: 100%;
|
||||
min-height: 56px;
|
||||
line-height: 24px;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-primary, #303133);
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: none;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
font-family: inherit;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--el-text-color-placeholder, #a8abb2);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--el-border-color-lighter, #ebeef5);
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.agent-label {
|
||||
margin-left: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--el-color-primary, #409eff);
|
||||
font-size: 12px;
|
||||
color: var(--el-color-primary, #409eff);
|
||||
max-width: 140px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--el-text-color-regular, #606266);
|
||||
|
||||
&:hover {
|
||||
background: var(--el-fill-color-light, #f5f7fa);
|
||||
color: var(--el-color-primary, #409eff);
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.model-selector-wrapper {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.model-select {
|
||||
min-width: 180px;
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.model-label-tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.send-btn-wrapper {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&.send-btn {
|
||||
background: var(--el-color-primary, #409eff);
|
||||
color: #ffffff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--el-color-primary-dark-2, #337ecc);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&.stop-btn {
|
||||
background: var(--el-color-info-light-3, #b1b3b8);
|
||||
color: #ffffff;
|
||||
|
||||
&:hover {
|
||||
background: var(--el-color-info, #909399);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-icon) {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
// Dark mode support
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.chat-input-wrapper {
|
||||
background: var(--el-bg-color, #1a1a1a);
|
||||
border-color: var(--el-border-color-light, #414243);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary-light-3, #79bbff);
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--el-color-primary, #409eff);
|
||||
box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.prompt-textarea {
|
||||
color: var(--el-text-color-primary, #e5eaf3);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--el-text-color-placeholder, #8d9095);
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
color: var(--el-text-color-regular, #cfd3dc);
|
||||
|
||||
&:hover {
|
||||
background: var(--el-fill-color-light, #262727);
|
||||
color: var(--el-color-primary, #409eff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive design
|
||||
@media (max-width: 768px) {
|
||||
.chat-input-container {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.chat-input-wrapper {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.toolbar-container {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-right {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
|
||||
.iconfont {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.model-select {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
:deep(.el-icon) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<div class="gem-list">
|
||||
<div
|
||||
v-for="gem in displayList"
|
||||
:key="gem.id"
|
||||
class="gem-row"
|
||||
:class="{ active: gem.id === activeId }"
|
||||
@click="$emit('select', gem)"
|
||||
>
|
||||
<div class="gem-row-icon">
|
||||
<el-image v-if="gem.icon" :src="gem.icon" class="gem-icon-img" />
|
||||
<span v-else class="gem-icon-placeholder" />
|
||||
</div>
|
||||
<span class="gem-row-name">{{ gem.name }}</span>
|
||||
<!-- 侧栏模式且使用 list 时显示更多(取消固定 / 发起新对话) -->
|
||||
<span v-if="mode === 'sidebar' && list != null" class="gem-row-more" @click.stop>
|
||||
<el-dropdown trigger="click" @command="(cmd) => onCommand(cmd, gem)">
|
||||
<span class="more-trigger">
|
||||
<i class="iconfont icon-more-horizontal"></i>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="chat">
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
发起新对话
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="unpin">
|
||||
<i class="iconfont icon-unpin"></i>
|
||||
取消固定
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</span>
|
||||
<span v-else class="gem-row-pin" @click.stop>
|
||||
<i class="iconfont icon-more"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ChatDotRound, Rank } from '@element-plus/icons-vue'
|
||||
import { httpGet } from '@/utils/http'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
activeId: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
/** 外部传入列表时使用,不请求接口;侧栏传入 pinned 列表 */
|
||||
list: {
|
||||
type: Array,
|
||||
default: null,
|
||||
},
|
||||
/** sidebar: 侧栏固定列表;manage: 右侧管理页列表 */
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'sidebar',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select', 'unpin'])
|
||||
|
||||
const gems = ref([])
|
||||
|
||||
const displayList = computed(() => {
|
||||
if (props.list != null && Array.isArray(props.list)) {
|
||||
return props.list.length > 8 ? props.list.slice(0, 8) : props.list
|
||||
}
|
||||
return gems.value
|
||||
})
|
||||
|
||||
function onCommand(cmd, gem) {
|
||||
if (cmd === 'chat') {
|
||||
emit('select', gem)
|
||||
} else if (cmd === 'unpin') {
|
||||
emit('unpin', gem.id)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (props.list != null) return
|
||||
try {
|
||||
const res = await httpGet('/api/app/list/user')
|
||||
gems.value = res.data || []
|
||||
} catch (e) {
|
||||
ElMessage.error('加载智能体列表失败:' + (e.message || ''))
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.list,
|
||||
(v) => {
|
||||
if (v != null) gems.value = []
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.gem-list {
|
||||
padding: 4px 0 12px;
|
||||
}
|
||||
|
||||
.gem-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 2px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #e0e7ff;
|
||||
|
||||
.gem-row-name {
|
||||
color: #3730a3;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.gem-row-icon {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-right: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gem-icon-img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.gem-icon-placeholder {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(135deg, #a78bfa 0%, #7c3aed 100%);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.gem-row-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gem-row-pin,
|
||||
.gem-row-more {
|
||||
flex-shrink: 0;
|
||||
margin-left: 4px;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
|
||||
&:hover {
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.more-trigger {
|
||||
padding: 2px 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -16,6 +16,9 @@
|
||||
<div class="upload-placeholder">
|
||||
<el-icon :size="20"><UploadFilled /></el-icon>
|
||||
<span>上传图片</span>
|
||||
<el-button size="small" @click.stop="openPasteZone" v-if="!showPasteZone"
|
||||
>粘贴截图</el-button
|
||||
>
|
||||
</div>
|
||||
</el-upload>
|
||||
</div>
|
||||
@@ -25,13 +28,13 @@
|
||||
<el-tooltip content="删除" placement="top">
|
||||
<i
|
||||
class="iconfont icon-remove text-base text-red-500 cursor-pointer"
|
||||
@click="removeImage(index)"
|
||||
@click="removeImage(0)"
|
||||
></i>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="预览" placement="top">
|
||||
<i
|
||||
class="iconfont icon-eye-open text-lg text-white cursor-pointer"
|
||||
@click="previewImage(index)"
|
||||
@click="previewImage(0)"
|
||||
></i>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
@@ -42,21 +45,23 @@
|
||||
<!-- 多图模式 -->
|
||||
<template v-else>
|
||||
<div class="upload-list" v-if="imageList.length > 0">
|
||||
<div v-for="(image, index) in imageList" :key="index" class="upload-item">
|
||||
<el-image :src="image" fit="cover" class="upload-image" />
|
||||
<div class="upload-overlay flex items-center justify-center space-x-2">
|
||||
<el-tooltip content="删除" placement="top">
|
||||
<i
|
||||
class="iconfont icon-remove text-base text-red-500 cursor-pointer"
|
||||
@click="removeImage(index)"
|
||||
></i>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="预览" placement="top">
|
||||
<i
|
||||
class="iconfont icon-eye-open text-lg text-white cursor-pointer"
|
||||
@click="previewImage(index)"
|
||||
></i>
|
||||
</el-tooltip>
|
||||
<div ref="uploadListRef" class="upload-list-inner">
|
||||
<div v-for="(image, index) in imageList" :key="image" class="upload-item">
|
||||
<el-image :src="image" fit="cover" class="upload-image" />
|
||||
<div class="upload-overlay flex items-center justify-center space-x-2">
|
||||
<el-tooltip content="删除" placement="top">
|
||||
<i
|
||||
class="iconfont icon-remove text-base text-red-500 cursor-pointer"
|
||||
@click="removeImage(index)"
|
||||
></i>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="预览" placement="top">
|
||||
<i
|
||||
class="iconfont icon-eye-open text-lg text-white cursor-pointer"
|
||||
@click="previewImage(index)"
|
||||
></i>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 上传按钮 -->
|
||||
@@ -72,10 +77,14 @@
|
||||
:limit="maxCount"
|
||||
>
|
||||
<div class="upload-placeholder">
|
||||
<el-icon :size="20"><UploadFilled /></el-icon>
|
||||
<span>上传图片</span>
|
||||
<i class="iconfont icon-plus"></i>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div class="paste-actions">
|
||||
<el-button size="small" @click="openPasteZone" v-if="!showPasteZone"
|
||||
>粘贴截图</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 初始上传区域 -->
|
||||
@@ -98,9 +107,24 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
<div class="paste-actions">
|
||||
<el-button size="small" @click="openPasteZone" v-if="!showPasteZone">粘贴截图</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 粘贴区域(多图模式与单图模式共用) -->
|
||||
<div
|
||||
v-if="showPasteZone && (props.multiple || props.maxCount > 1 || imageList.length === 0)"
|
||||
ref="pasteZoneRef"
|
||||
class="paste-zone paste-zone-global"
|
||||
tabindex="0"
|
||||
@paste.prevent="onPaste"
|
||||
>
|
||||
<div class="paste-zone-text">在此区域按 Ctrl+V 粘贴截图</div>
|
||||
<el-button text size="small" class="paste-zone-close" @click="closePasteZone">关闭</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 上传进度 -->
|
||||
<el-progress
|
||||
v-if="uploading"
|
||||
@@ -123,7 +147,8 @@ import { httpPost } from '@/utils/http'
|
||||
import { replaceImg } from '@/utils/libs'
|
||||
import { UploadFilled } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { computed, ref } from 'vue'
|
||||
import { Sortable } from 'sortablejs'
|
||||
import { computed, nextTick, ref, watch, onBeforeUnmount } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -156,12 +181,23 @@ const uploadProgress = ref(0)
|
||||
const previewVisible = ref(false)
|
||||
const previewImageSrc = ref('')
|
||||
|
||||
// 粘贴截图区域
|
||||
const showPasteZone = ref(false)
|
||||
const pasteZoneRef = ref(null)
|
||||
|
||||
// 拖拽排序
|
||||
const uploadListRef = ref(null)
|
||||
let sortableInstance = null
|
||||
|
||||
// 图片列表
|
||||
const imageList = computed({
|
||||
get() {
|
||||
if (props.multiple || props.maxCount > 1) {
|
||||
return Array.isArray(props.modelValue) ? props.modelValue : []
|
||||
} else {
|
||||
if (Array.isArray(props.modelValue)) {
|
||||
return props.modelValue.length > 0 && props.modelValue[0] ? [props.modelValue[0]] : []
|
||||
}
|
||||
return props.modelValue && props.modelValue.length > 0 ? [props.modelValue] : []
|
||||
}
|
||||
},
|
||||
@@ -247,21 +283,97 @@ const previewImage = (index) => {
|
||||
previewImageSrc.value = imageList.value[index]
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
const openPasteZone = () => {
|
||||
showPasteZone.value = true
|
||||
nextTick(() => {
|
||||
if (pasteZoneRef.value) {
|
||||
pasteZoneRef.value.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const closePasteZone = () => {
|
||||
showPasteZone.value = false
|
||||
}
|
||||
|
||||
const onPaste = (e) => {
|
||||
const items = e.clipboardData && e.clipboardData.items
|
||||
if (!items || !items.length) return
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i]
|
||||
if (item.kind === 'file' && item.type && item.type.startsWith('image/')) {
|
||||
const file = item.getAsFile()
|
||||
if (file) {
|
||||
handleUpload({ file })
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 拖拽排序:同步新顺序到 v-model
|
||||
const handleSortEnd = (evt) => {
|
||||
const { oldIndex, newIndex } = evt
|
||||
if (oldIndex === newIndex) return
|
||||
const list = [...imageList.value]
|
||||
const [removed] = list.splice(oldIndex, 1)
|
||||
list.splice(newIndex, 0, removed)
|
||||
imageList.value = list
|
||||
// 调试:拖拽后打印图片列表
|
||||
console.log('[ImageUpload] 拖拽排序', { oldIndex, newIndex, imageList: list })
|
||||
}
|
||||
|
||||
const initSortable = () => {
|
||||
if (!uploadListRef.value || sortableInstance) return
|
||||
sortableInstance = Sortable.create(uploadListRef.value, {
|
||||
animation: 150,
|
||||
ghostClass: 'upload-item--sort-ghost',
|
||||
onEnd: handleSortEnd,
|
||||
})
|
||||
}
|
||||
|
||||
const destroySortable = () => {
|
||||
if (sortableInstance) {
|
||||
sortableInstance.destroy()
|
||||
sortableInstance = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.maxCount, imageList.value.length],
|
||||
() => {
|
||||
if (props.maxCount > 1 && imageList.value.length > 0) {
|
||||
nextTick(() => initSortable())
|
||||
} else {
|
||||
destroySortable()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onBeforeUnmount(destroySortable)
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.image__upload-container {
|
||||
--upload-size: 100px;
|
||||
width: 100%;
|
||||
|
||||
.el-upload-dragger {
|
||||
--el-upload-dragger-padding-horizontal: 20px;
|
||||
}
|
||||
|
||||
.single-upload {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
width: var(--upload-size);
|
||||
height: var(--upload-size);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.single-image-item {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
width: var(--upload-size);
|
||||
height: var(--upload-size);
|
||||
position: relative;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
@@ -274,13 +386,24 @@ const previewImage = (index) => {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.upload-list-inner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.upload-item {
|
||||
position: relative;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
width: var(--upload-size);
|
||||
height: var(--upload-size);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #dcdfe6;
|
||||
cursor: move;
|
||||
|
||||
&.upload-item--sort-ghost {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.upload-image {
|
||||
width: 100%;
|
||||
@@ -312,8 +435,8 @@ const previewImage = (index) => {
|
||||
width: 100%;
|
||||
|
||||
.el-upload-dragger {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
width: var(--upload-size);
|
||||
height: var(--upload-size);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -344,6 +467,32 @@ const previewImage = (index) => {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.paste-actions {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.paste-zone {
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
border: 1px dashed #c0c4cc;
|
||||
border-radius: 6px;
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background-color: #f5f7fa;
|
||||
outline: none;
|
||||
|
||||
.paste-zone-text {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.paste-zone-close {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-upload) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -347,6 +347,7 @@ import { Checked, Iphone, Lock, Message } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { marked } from 'marked'
|
||||
import QRCode from 'qrcode'
|
||||
import { isWechat } from '@/utils/libs'
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
@@ -419,6 +420,45 @@ watch(
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
// 如果是微信浏览器,则尝试获取微信公众号授权登录
|
||||
if (isWechat()) {
|
||||
// 检查 URL 中是否包含 code 参数
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const code = urlParams.get('code')
|
||||
if (code) {
|
||||
// 可以在这里将 code 发送到后端进行登录验证
|
||||
httpPost('/api/user/wxAuthLogin', { code: code })
|
||||
.then((res) => {
|
||||
setUserToken(res.data.token)
|
||||
store.setIsLogin(true)
|
||||
ElMessage.success('登录成功!')
|
||||
emits('hide')
|
||||
emits('success')
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('登录失败,' + e.message)
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
} else {
|
||||
// 如果是微信浏览器,获取微信公众号配置
|
||||
httpGet('/api/config/get?key=wx_gzh')
|
||||
.then((res) => {
|
||||
// 构造微信授权 URL
|
||||
const redirectUri = encodeURIComponent(`${location.protocol}//${location.host}/login`)
|
||||
const authUrl = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${res.data.app_id}&redirect_uri=${redirectUri}&response_type=code&scope=snsapi_userinfo&state=STATE#wechat_redirect`
|
||||
|
||||
// 如果是微信浏览器,直接跳转到授权页面
|
||||
window.location.href = authUrl
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('获取微信公众号配置失败:', e.message)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 获取系统配置
|
||||
getSystemInfo()
|
||||
.then((res) => {
|
||||
if (res.data) {
|
||||
@@ -447,6 +487,7 @@ onMounted(() => {
|
||||
ElMessage.error('获取系统配置失败:' + e.message)
|
||||
})
|
||||
|
||||
// 获取验证码配置
|
||||
httpGet('/api/captcha/config').then((res) => {
|
||||
enableCaptcha.value = res.data['enabled']
|
||||
captchaType.value = res.data['type']
|
||||
|
||||
@@ -21,8 +21,14 @@
|
||||
|
||||
<el-option v-for="item in items" :key="item.name" :label="item.name" :value="item">
|
||||
<div class="flex justify-start">
|
||||
<i
|
||||
class="iconfont !text-2xl mr-2"
|
||||
:class="item.icon.iconfont"
|
||||
v-if="item.icon.iconfont"
|
||||
></i>
|
||||
<span
|
||||
class="flex items-center justify-center text-white model-version mr-2 w-[40px] h-[40px] rounded-lg"
|
||||
v-else
|
||||
:class="item.icon.size ? item.icon.size : '!text-xl'"
|
||||
>{{ item.icon.text }}</span
|
||||
>
|
||||
@@ -39,8 +45,30 @@
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
|
||||
<!-- 模型介绍 -->
|
||||
<div
|
||||
v-if="selectedModel && selectedModel.label"
|
||||
class="w-full p-4 bg-gray-50 rounded-lg border border-gray-200"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-sm font-semibold text-blue-600 mb-1">
|
||||
<i
|
||||
class="iconfont !text-base"
|
||||
:class="selectedModel.icon.iconfont"
|
||||
v-if="selectedModel.icon.iconfont"
|
||||
></i>
|
||||
|
||||
{{ selectedModel.name }}
|
||||
</h4>
|
||||
<p class="text-sm text-gray-600 whitespace-pre-line leading-relaxed">
|
||||
{{ selectedModel.label }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-for="param in selectedModel.params">
|
||||
<div class="w-full" :key="param.name" v-if="param.type !== 'hidden'">
|
||||
<div class="w-full" :key="param.name" v-if="isParamVisible(param)">
|
||||
<div class="w-full flex flex-col !items-start space-y-2" v-if="param.type === 'switch'">
|
||||
<div class="w-full flex justify-between">
|
||||
<label class="label font-bold">{{ param.label }}</label>
|
||||
@@ -54,7 +82,7 @@
|
||||
<span v-if="param.required" class="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<p v-if="param.info" class="text-xs text-gray-500 mb-1">{{ param.info }}</p>
|
||||
<div class="flex w-full">
|
||||
<div class="flex w-full flex-col">
|
||||
<el-input
|
||||
v-if="param.type === 'text'"
|
||||
v-model="modelValue[param.name]"
|
||||
@@ -123,15 +151,41 @@
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<ReferenceInput
|
||||
v-if="param.type === 'textarea' && param.name === 'prompt'"
|
||||
v-model="modelValue[param.name]"
|
||||
:autosize="param.autosize"
|
||||
:maxlength="param.maxlength"
|
||||
:show-word-limit="param.showWordLimit"
|
||||
:placeholder="param.placeholder"
|
||||
:image-list="
|
||||
Array.isArray(modelValue[param.reference])
|
||||
? modelValue[param.reference]
|
||||
: modelValue[param.reference]
|
||||
? [modelValue[param.reference]]
|
||||
: []
|
||||
"
|
||||
/>
|
||||
<el-input
|
||||
v-else-if="param.type === 'textarea'"
|
||||
type="textarea"
|
||||
v-if="param.type === 'textarea'"
|
||||
v-model="modelValue[param.name]"
|
||||
:autosize="param.autosize"
|
||||
:maxlength="param.maxlength"
|
||||
:show-word-limit="param.showWordLimit"
|
||||
:placeholder="param.placeholder"
|
||||
/>
|
||||
<div class="flex justify-end pt-2 w-full" v-if="param.enablePromptOptimizer">
|
||||
<el-button
|
||||
@click="generatePrompt"
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="submitting"
|
||||
>
|
||||
<i class="iconfont icon-chuangzuo mr-1"></i>
|
||||
优化提示词
|
||||
</el-button>
|
||||
</div>
|
||||
<ImageUpload
|
||||
v-if="param.type === 'image'"
|
||||
v-model="modelValue[param.name]"
|
||||
@@ -159,7 +213,11 @@
|
||||
<script setup>
|
||||
import FileUpload from './FileUpload.vue'
|
||||
import ImageUpload from './ImageUpload.vue'
|
||||
import ReferenceInput from './ReferenceInput.vue'
|
||||
import ParamEmpty from './ui/ParamEmpty.vue'
|
||||
import { httpPost } from '@/utils/http'
|
||||
import { showMessageError } from '@/utils/dialog'
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
|
||||
const title = ref('参数构建器')
|
||||
const statusText = ref('功能正在开发中')
|
||||
@@ -188,8 +246,67 @@ const props = defineProps({
|
||||
|
||||
const selectedModel = ref(props.items[0])
|
||||
const requiredKeys = ref(props.requiredKeys)
|
||||
const submitting = ref(false)
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:requiredKeys'])
|
||||
const emit = defineEmits(['update:modelValue', 'update:requiredKeys', 'price-params-change'])
|
||||
|
||||
// 判断单个 showWhen 条件是否匹配
|
||||
const matchSingleShowWhen = (condition, values) => {
|
||||
if (!condition || !values) return false
|
||||
const { field, value } = condition
|
||||
if (!field) return false
|
||||
return values[field] === value
|
||||
}
|
||||
|
||||
// 统一的参数可见性判断,支持:
|
||||
// - 无 showWhen:始终可见
|
||||
// - showWhen 为对象:单条件
|
||||
// - showWhen 为数组:所有条件满足(AND)
|
||||
const isParamVisible = (param, values = modelValue.value) => {
|
||||
if (!param) return false
|
||||
if (param.type === 'hidden') return false
|
||||
|
||||
const { showWhen } = param
|
||||
if (!showWhen) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 对象写法:单条件
|
||||
if (!Array.isArray(showWhen)) {
|
||||
return matchSingleShowWhen(showWhen, values)
|
||||
}
|
||||
|
||||
// 数组写法:全部条件满足才显示(AND)
|
||||
return showWhen.every((condition) => matchSingleShowWhen(condition, values))
|
||||
}
|
||||
|
||||
const recalcRequiredKeys = (values, model) => {
|
||||
const next = {}
|
||||
if (model && model.params) {
|
||||
model.params.forEach((param) => {
|
||||
if (param.required && isParamVisible(param, values)) {
|
||||
next[param.name] = { required: true, label: param.label }
|
||||
}
|
||||
})
|
||||
}
|
||||
requiredKeys.value = next
|
||||
}
|
||||
|
||||
const generatePrompt = async () => {
|
||||
const prompt = modelValue.value?.prompt
|
||||
if (!prompt) {
|
||||
return showMessageError('请输入原始提示词')
|
||||
}
|
||||
try {
|
||||
submitting.value = true
|
||||
const res = await httpPost('/api/prompt/video', { prompt })
|
||||
modelValue.value = { ...modelValue.value, prompt: res.data }
|
||||
} catch (error) {
|
||||
showMessageError('生成提示词失败:' + error.message)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化 modelValue 默认值
|
||||
const initModelValue = (model) => {
|
||||
@@ -197,12 +314,8 @@ const initModelValue = (model) => {
|
||||
return {}
|
||||
}
|
||||
const defaultValues = {}
|
||||
requiredKeys.value = {}
|
||||
if (model && model.params) {
|
||||
model.params.forEach((param) => {
|
||||
if (param.required) {
|
||||
requiredKeys.value[param.name] = { required: true, label: param.label }
|
||||
}
|
||||
// 根据参数类型设置默认值
|
||||
switch (param.type) {
|
||||
case 'text':
|
||||
@@ -229,7 +342,12 @@ const initModelValue = (model) => {
|
||||
defaultValues[param.name] = param.value || null
|
||||
break
|
||||
case 'image':
|
||||
defaultValues[param.name] = param.value || []
|
||||
defaultValues[param.name] =
|
||||
param.value || (param.multiple || (param.maxCount && param.maxCount > 1) ? [] : '')
|
||||
break
|
||||
case 'file':
|
||||
defaultValues[param.name] =
|
||||
param.value || (param.multiple || (param.maxCount && param.maxCount > 1) ? [] : '')
|
||||
break
|
||||
default:
|
||||
defaultValues[param.name] = param.value || ''
|
||||
@@ -241,17 +359,38 @@ const initModelValue = (model) => {
|
||||
defaultValues.action = selectedModel.value.action
|
||||
? selectedModel.value.action
|
||||
: 'CVSync2AsyncSubmitTask'
|
||||
recalcRequiredKeys(defaultValues, model)
|
||||
return defaultValues
|
||||
}
|
||||
|
||||
// 初始化默认值
|
||||
const modelValue = ref(initModelValue(selectedModel.value))
|
||||
|
||||
// 检查字段是否在 priceParams 中
|
||||
const isPriceParam = (fieldName) => {
|
||||
if (!selectedModel.value || !selectedModel.value.priceParams) {
|
||||
return false
|
||||
}
|
||||
return selectedModel.value.priceParams.includes(fieldName)
|
||||
}
|
||||
|
||||
// 监听 modelValue 变化,通知父组件
|
||||
watch(
|
||||
modelValue,
|
||||
(newValue) => {
|
||||
(newValue, oldValue) => {
|
||||
emit('update:modelValue', newValue)
|
||||
recalcRequiredKeys(newValue, selectedModel.value)
|
||||
|
||||
// 检查是否有 priceParams 相关字段变化
|
||||
if (selectedModel.value && selectedModel.value.priceParams) {
|
||||
const priceParamsChanged = selectedModel.value.priceParams.some((paramName) => {
|
||||
return newValue[paramName] !== oldValue?.[paramName]
|
||||
})
|
||||
|
||||
if (priceParamsChanged) {
|
||||
emit('price-params-change', newValue)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
@@ -288,6 +427,10 @@ const changeModel = (item) => {
|
||||
selectedModel.value = item
|
||||
// 更新 modelValue 为选中模型的默认值
|
||||
modelValue.value = initModelValue(item)
|
||||
// 模型切换时也触发价格参数变化事件
|
||||
if (item.priceParams && item.priceParams.length > 0) {
|
||||
emit('price-params-change', modelValue.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
:before-close="close"
|
||||
title="修改密码"
|
||||
>
|
||||
<div class="form px-3" id="password-form">
|
||||
<div class="pt-1" id="password-form">
|
||||
<el-form :model="form" label-position="top">
|
||||
<el-form-item label="原始密码">
|
||||
<el-input v-model="form['old_pass']" type="password" />
|
||||
@@ -23,7 +23,7 @@
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<span class="pt-1 flex justify-end">
|
||||
<el-button @click="close">关闭</el-button>
|
||||
<el-button type="primary" @click="save"> 保存 </el-button>
|
||||
</span>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div class="form" id="redeem-form">
|
||||
<el-form :model="form">
|
||||
<div class="pt-3" id="redeem-form">
|
||||
<el-form :model="form" label-position="top">
|
||||
<el-form-item>
|
||||
<el-input v-model="form.code" placeholder="请输入兑换码" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="dialog-footer">
|
||||
<div class="pt-3 flex justify-end">
|
||||
<el-button type="primary" @click="save">兑换</el-button>
|
||||
<el-button @click="emits('hide')">取消</el-button>
|
||||
</div>
|
||||
@@ -41,8 +41,6 @@ const save = () => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.form {
|
||||
padding: 20px;
|
||||
|
||||
.form-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
@@ -50,10 +48,5 @@ const save = () => {
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<div class="reference-input w-full">
|
||||
<el-input
|
||||
ref="inputRef"
|
||||
v-model="innerValue"
|
||||
type="textarea"
|
||||
:autosize="autosize"
|
||||
:maxlength="maxlength"
|
||||
:show-word-limit="showWordLimit"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:rows="rows"
|
||||
v-loading="loading"
|
||||
@input="handleInput"
|
||||
@keydown.esc="showImageMentionPopup = false"
|
||||
/>
|
||||
|
||||
<!-- @ 触发的参考图列表弹窗,定位到光标旁 -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showImageMentionPopup && imageListForPrompt.length > 0"
|
||||
ref="imageMentionPopupRef"
|
||||
class="image-mention-popup"
|
||||
:style="{ left: mentionPopupLeft + 'px', top: mentionPopupTop + 'px' }"
|
||||
>
|
||||
<div
|
||||
v-for="(item, idx) in imageListForPrompt"
|
||||
:key="idx"
|
||||
class="image-mention-item"
|
||||
@click="onSelectImageMention(item)"
|
||||
>
|
||||
<img :src="item.url" class="image-mention-thumb" alt="" @error="onThumbError" />
|
||||
<span class="image-mention-name">{{ item.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Teleport, computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
imageList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '请在此输入绘画提示词,输入 @ 可引用参考图(图1、图2...)',
|
||||
},
|
||||
autosize: {
|
||||
type: Object,
|
||||
default: () => ({ minRows: 4, maxRows: 6 }),
|
||||
},
|
||||
maxlength: {
|
||||
type: Number,
|
||||
default: 1024,
|
||||
},
|
||||
showWordLimit: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
rows: {
|
||||
type: Number,
|
||||
default: 4,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
// 受控 v-model
|
||||
const innerValue = computed({
|
||||
get() {
|
||||
return props.modelValue
|
||||
},
|
||||
set(val) {
|
||||
emit('update:modelValue', val)
|
||||
},
|
||||
})
|
||||
|
||||
const inputRef = ref(null)
|
||||
|
||||
// @ 引用参考图:弹窗显隐与光标定位
|
||||
const showImageMentionPopup = ref(false)
|
||||
const mentionPopupLeft = ref(0)
|
||||
const mentionPopupTop = ref(0)
|
||||
const triggerIndex = ref(0)
|
||||
const imageMentionPopupRef = ref(null)
|
||||
|
||||
// 参考图列表 for 提示词(图 1、图 2... + url)
|
||||
const imageListForPrompt = computed(() => {
|
||||
const imgs = Array.isArray(props.imageList) ? props.imageList.filter(Boolean) : []
|
||||
return imgs.map((url, i) => ({ name: `图${i + 1}`, url }))
|
||||
})
|
||||
|
||||
// 获取 textarea 光标处的视口像素坐标(用于弹窗定位)
|
||||
function getCaretPixelPosition(textarea, caretIndex) {
|
||||
if (!textarea || caretIndex == null) return { left: 0, top: 0 }
|
||||
const style = getComputedStyle(textarea)
|
||||
const mirror = document.createElement('div')
|
||||
const mirrorStyle = mirror.style
|
||||
mirrorStyle.position = 'fixed'
|
||||
mirrorStyle.left = '-9999px'
|
||||
mirrorStyle.top = '0'
|
||||
mirrorStyle.visibility = 'hidden'
|
||||
mirrorStyle.whiteSpace = 'pre-wrap'
|
||||
mirrorStyle.wordWrap = 'break-word'
|
||||
mirrorStyle.width = style.width
|
||||
mirrorStyle.font = style.font
|
||||
mirrorStyle.fontSize = style.fontSize
|
||||
mirrorStyle.fontFamily = style.fontFamily
|
||||
mirrorStyle.lineHeight = style.lineHeight
|
||||
mirrorStyle.padding = style.padding
|
||||
mirrorStyle.border = style.border
|
||||
mirrorStyle.boxSizing = style.boxSizing
|
||||
const text = textarea.value.substring(0, caretIndex)
|
||||
mirror.textContent = text
|
||||
const span = document.createElement('span')
|
||||
span.textContent = '\u200b'
|
||||
mirror.appendChild(span)
|
||||
document.body.appendChild(mirror)
|
||||
const mirrorRect = mirror.getBoundingClientRect()
|
||||
const spanRect = span.getBoundingClientRect()
|
||||
document.body.removeChild(mirror)
|
||||
const taRect = textarea.getBoundingClientRect()
|
||||
const left = taRect.left + (spanRect.left - mirrorRect.left) - textarea.scrollLeft
|
||||
const top = taRect.top + (spanRect.top - mirrorRect.top) - textarea.scrollTop
|
||||
return { left, top }
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
const el = inputRef.value?.$el
|
||||
const textarea = el?.querySelector?.('textarea') ?? el
|
||||
if (!textarea || typeof textarea.selectionStart !== 'number') return
|
||||
|
||||
const pos = textarea.selectionStart
|
||||
const text = textarea.value || ''
|
||||
|
||||
// 光标不在有效位置时直接关闭弹窗
|
||||
if (pos < 1 || pos > text.length) {
|
||||
showImageMentionPopup.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const char = text[pos - 1]
|
||||
if (char !== '@') {
|
||||
showImageMentionPopup.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (imageListForPrompt.value.length === 0) {
|
||||
showImageMentionPopup.value = false
|
||||
return
|
||||
}
|
||||
|
||||
triggerIndex.value = pos - 1
|
||||
const { left, top } = getCaretPixelPosition(textarea, pos)
|
||||
mentionPopupLeft.value = Math.round(left)
|
||||
mentionPopupTop.value = Math.round(top) + 20
|
||||
showImageMentionPopup.value = true
|
||||
}
|
||||
|
||||
function onSelectImageMention(item) {
|
||||
const text = innerValue.value || ''
|
||||
const idx = triggerIndex.value
|
||||
const before = text.slice(0, idx)
|
||||
const after = text.slice(idx + 1)
|
||||
const nextText = `${before} ${item.name} ${after}`
|
||||
innerValue.value = nextText
|
||||
showImageMentionPopup.value = false
|
||||
|
||||
nextTick(() => {
|
||||
const el = inputRef.value?.$el
|
||||
const textarea = el?.querySelector?.('textarea') ?? el
|
||||
if (textarea) {
|
||||
const newPos = idx + item.name.length + 2
|
||||
textarea.focus()
|
||||
textarea.setSelectionRange(newPos, newPos)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onThumbError(e) {
|
||||
if (e?.target) {
|
||||
e.target.style.display = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
// 点击弹窗外部关闭
|
||||
let clickOutsideCleanup = null
|
||||
watch(showImageMentionPopup, (visible) => {
|
||||
if (clickOutsideCleanup) {
|
||||
clickOutsideCleanup()
|
||||
clickOutsideCleanup = null
|
||||
}
|
||||
if (!visible) return
|
||||
const onMouseDown = (e) => {
|
||||
const popup = imageMentionPopupRef.value
|
||||
const inputEl = inputRef.value?.$el
|
||||
const target = e.target
|
||||
if (popup?.contains(target) || inputEl?.contains(target)) return
|
||||
showImageMentionPopup.value = false
|
||||
}
|
||||
nextTick(() => {
|
||||
document.addEventListener('mousedown', onMouseDown)
|
||||
clickOutsideCleanup = () => document.removeEventListener('mousedown', onMouseDown)
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (clickOutsideCleanup) clickOutsideCleanup()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
/* 参考图 @ 提及弹窗样式,全局使用 */
|
||||
.image-mention-popup {
|
||||
position: fixed;
|
||||
z-index: 2000;
|
||||
min-width: 160px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
border: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.image-mention-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
|
||||
&:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
}
|
||||
|
||||
.image-mention-thumb {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.image-mention-name {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
@@ -17,9 +17,11 @@
|
||||
<el-input v-model="form.mobile" placeholder="请输入手机号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="验证码">
|
||||
<div class="flex">
|
||||
<el-input v-model="form.code" maxlength="6" class="mr-2 max-w-[200px]" />
|
||||
<send-msg size="" :receiver="form.mobile" type="mobile" />
|
||||
<div class="flex w-full items-center gap-2">
|
||||
<el-input v-model="form.code" maxlength="6" class="flex-1" />
|
||||
<span class="flex-none">
|
||||
<send-msg :receiver="form.mobile" type="mobile" />
|
||||
</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-tab-pane>
|
||||
@@ -28,9 +30,11 @@
|
||||
<el-input v-model="form.email" placeholder="请输入邮箱地址" />
|
||||
</el-form-item>
|
||||
<el-form-item label="验证码">
|
||||
<div class="flex">
|
||||
<el-input v-model="form.code" maxlength="6" class="mr-2 w-1/2" />
|
||||
<send-msg size="" :receiver="form.email" type="email" />
|
||||
<div class="flex w-full items-center gap-2">
|
||||
<el-input v-model="form.code" maxlength="6" class="flex-1" />
|
||||
<span class="flex-none">
|
||||
<send-msg :receiver="form.email" type="email" />
|
||||
</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-tab-pane>
|
||||
@@ -46,8 +50,8 @@
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button type="primary" @click="save" round> 重置密码 </el-button>
|
||||
<div class="pt-1 flex justify-end">
|
||||
<el-button type="primary" @click="save"> 重置密码 </el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
@@ -112,10 +116,6 @@ const close = function () {
|
||||
|
||||
<style lang="scss">
|
||||
.reset-pass {
|
||||
.form {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.code-row {
|
||||
width: 100%;
|
||||
.send-button {
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="show"
|
||||
:fullscreen="true"
|
||||
@close="close"
|
||||
style="--el-dialog-border-radius: 0px"
|
||||
>
|
||||
<template #header>
|
||||
<div class="header">
|
||||
<h3 style="color: var(--text-theme-color)">绘画任务详情</h3>
|
||||
</div>
|
||||
</template>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="16">
|
||||
<div class="img-container">
|
||||
<el-image :src="item['img_url']" fit="contain">
|
||||
<template #placeholder>
|
||||
<div class="image-slot">正在加载图片</div>
|
||||
</template>
|
||||
|
||||
<template #error>
|
||||
<div class="image-slot">
|
||||
<el-icon>
|
||||
<i class="iconfont icon-image"></i>
|
||||
</el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="task-info">
|
||||
<div class="info-line">
|
||||
<el-divider> 正向提示词 </el-divider>
|
||||
<div class="prompt">
|
||||
<span>{{ item.prompt }}</span>
|
||||
<el-icon class="copy-prompt-wall" :data-clipboard-text="item.prompt">
|
||||
<i class="iconfont icon-copy"></i>
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-line">
|
||||
<el-divider> 反向提示词 </el-divider>
|
||||
<div class="prompt">
|
||||
<span>{{ item.params.negative_prompt }}</span>
|
||||
<el-icon class="copy-prompt-wall" :data-clipboard-text="item.params.negative_prompt">
|
||||
<i class="iconfont icon-copy"></i>
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-line">
|
||||
<div class="wrapper">
|
||||
<label>采样方法:</label>
|
||||
<div class="item-value">{{ item.params.sampler }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-line">
|
||||
<div class="wrapper">
|
||||
<label>图片尺寸:</label>
|
||||
<div class="item-value">{{ item.params.width }} x {{ item.params.height }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-line">
|
||||
<div class="wrapper">
|
||||
<label>迭代步数:</label>
|
||||
<div class="item-value">{{ item.params.steps }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-line">
|
||||
<div class="wrapper">
|
||||
<label>引导系数:</label>
|
||||
<div class="item-value">{{ item.params.cfg_scale }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-line">
|
||||
<div class="wrapper">
|
||||
<label>随机因子:</label>
|
||||
<div class="item-value">{{ item.params.seed }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="item.params.hd_fix">
|
||||
<el-divider> 高清修复 </el-divider>
|
||||
<div class="info-line">
|
||||
<div class="wrapper">
|
||||
<label>重绘幅度:</label>
|
||||
<div class="item-value">{{ item.params.hd_redraw_rate }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-line">
|
||||
<div class="wrapper">
|
||||
<label>放大算法:</label>
|
||||
<div class="item-value">{{ item.params.hd_scale_alg }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-line">
|
||||
<div class="wrapper">
|
||||
<label>放大倍数:</label>
|
||||
<div class="item-value">{{ item.params.hd_scale }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-line">
|
||||
<div class="wrapper">
|
||||
<label>迭代步数:</label>
|
||||
<div class="item-value">{{ item.params.hd_steps }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="copy-params">
|
||||
<el-button type="primary" round @click="drawSame(item)">画一张同款的</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { showMessageError, showMessageOK } from '@/utils/dialog'
|
||||
import Clipboard from 'clipboard'
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: Boolean,
|
||||
data: Object,
|
||||
})
|
||||
|
||||
const item = ref(props.data)
|
||||
const show = ref(props.modelValue)
|
||||
const emit = defineEmits(['drawSame', 'close'])
|
||||
|
||||
const clipboard = ref(null)
|
||||
onMounted(() => {
|
||||
clipboard.value = new Clipboard('.copy-prompt-wall')
|
||||
clipboard.value.on('success', () => {
|
||||
showMessageOK('复制成功!')
|
||||
})
|
||||
|
||||
clipboard.value.on('error', () => {
|
||||
showMessageError('复制失败!')
|
||||
})
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
show.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
(newValue) => {
|
||||
item.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
const drawSame = (item) => {
|
||||
emit('drawSame', item)
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -1,43 +0,0 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
class="config-dialog"
|
||||
v-model="showDialog"
|
||||
:close-on-click-modal="true"
|
||||
:before-close="close"
|
||||
style="max-width: 400px"
|
||||
title="账户信息"
|
||||
>
|
||||
<div class="flex-center-col pl-4 pr-4" id="user-info">
|
||||
<user-profile @hide="close" />
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import UserProfile from '@/components/UserProfile.vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
})
|
||||
|
||||
const showDialog = computed(() => {
|
||||
return props.show
|
||||
})
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
const emits = defineEmits(['hide'])
|
||||
const close = function () {
|
||||
emits('hide', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.config-dialog {
|
||||
.el-dialog {
|
||||
--el-dialog-width: 90%;
|
||||
max-width: 800px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -43,9 +43,9 @@
|
||||
<el-button type="primary" size="small" @click="signIn" class="ml-2">签到</el-button>
|
||||
</el-tooltip>
|
||||
</el-form-item>
|
||||
<el-form-item label="会员到期时间" v-if="user['expired_time'] > 0">
|
||||
<!-- <el-form-item label="会员到期时间" v-if="user['expired_time'] > 0">
|
||||
<el-tag type="danger">{{ dateFormat(user['expired_time']) }}</el-tag>
|
||||
</el-form-item>
|
||||
</el-form-item> -->
|
||||
|
||||
<el-row class="opt-line">
|
||||
<el-button :dark="false" type="primary" @click="save">保存</el-button>
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
<template>
|
||||
<div class="welcome">
|
||||
<div class="container">
|
||||
<h2 class="title">{{ title }}-{{ version }}</h2>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<div class="grid-content">
|
||||
<div class="item-title">
|
||||
<div><i class="iconfont icon-quick-start"></i></div>
|
||||
<div>小试牛刀</div>
|
||||
</div>
|
||||
|
||||
<div class="list-box">
|
||||
<ul>
|
||||
<li v-for="item in samples" :key="item">
|
||||
<a @click="send(item)">{{ item }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="grid-content">
|
||||
<div class="item-title">
|
||||
<div><i class="iconfont icon-plugin"></i></div>
|
||||
<div>插件增强</div>
|
||||
</div>
|
||||
|
||||
<div class="list-box">
|
||||
<ul>
|
||||
<li v-for="item in plugins" :key="item.value">
|
||||
<a @click="send(item.value)">{{ item.text }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="grid-content">
|
||||
<div class="item-title">
|
||||
<div><i class="iconfont icon-control"></i></div>
|
||||
<div>能力扩展</div>
|
||||
</div>
|
||||
|
||||
<div class="list-box">
|
||||
<ul>
|
||||
<li v-for="item in capabilities" :key="item">
|
||||
<span v-if="item.value === ''">{{ item.text }}</span>
|
||||
<a @click="send(item.value)" v-else>{{ item.text }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { getSystemInfo } from '@/store/cache'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
const title = ref(import.meta.env.VITE_TITLE)
|
||||
const version = ref(import.meta.env.VITE_VERSION)
|
||||
|
||||
const samples = ref([
|
||||
'用小学生都能听懂的术语解释什么是量子纠缠',
|
||||
'能给一位6岁男孩的生日会提供一些创造性的建议吗?',
|
||||
'如何用 Go 语言实现支持代理 Http client 请求?',
|
||||
])
|
||||
|
||||
const plugins = ref([
|
||||
{
|
||||
value: '今日早报',
|
||||
text: '今日早报:获取当天全球的热门新闻事件列表',
|
||||
},
|
||||
{
|
||||
value: '微博热搜',
|
||||
text: '微博热搜:新浪微博热搜榜,微博当日热搜榜单',
|
||||
},
|
||||
{
|
||||
value: '今日头条',
|
||||
text: '今日头条:给用户推荐当天的头条新闻,周榜热文',
|
||||
},
|
||||
])
|
||||
|
||||
const capabilities = ref([
|
||||
{
|
||||
text: '轻松扮演翻译专家,程序员,AI 女友,文案高手...',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
text: '国产大语言模型支持,百度文心,科大讯飞,ChatGLM...',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
text: '绘画:马斯克开拖拉机,20世纪,中国农村。3:2',
|
||||
value: '绘画:马斯克开拖拉机,20世纪,中国农村。3:2',
|
||||
},
|
||||
])
|
||||
|
||||
onMounted(() => {
|
||||
getSystemInfo()
|
||||
.then((res) => {
|
||||
title.value = res.data.title
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('获取系统配置失败:' + e.message)
|
||||
})
|
||||
})
|
||||
|
||||
const emits = defineEmits(['send'])
|
||||
const send = (text) => {
|
||||
emits('send', text)
|
||||
}
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.welcome {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 8vh;
|
||||
|
||||
.container {
|
||||
max-width: 768px;
|
||||
width: 100%;
|
||||
|
||||
.title {
|
||||
// font-size: 2.25rem
|
||||
line-height: 2.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.grid-content {
|
||||
.item-title {
|
||||
div {
|
||||
padding: 6px 10px;
|
||||
|
||||
.iconfont {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list-box {
|
||||
ul {
|
||||
padding: 10px;
|
||||
|
||||
li {
|
||||
font-size: 14px;
|
||||
padding: 0.75rem;
|
||||
border-radius: 5px;
|
||||
background-color: var(--chat-wel-bg);
|
||||
color: var(--theme-text-color-secondary);
|
||||
line-height: 1.5;
|
||||
|
||||
a {
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -158,8 +158,12 @@ const logout = function () {
|
||||
overflow: hidden;
|
||||
height: 50px;
|
||||
font-size: 22px;
|
||||
background-color: var(--chat-content-bg);
|
||||
background: var(--admin-header-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-bottom: 1px solid var(--admin-header-border);
|
||||
color: var(--theme-text-color-primary);
|
||||
transition: background 0.2s ease, border-color 0.2s ease;
|
||||
|
||||
.collapse-btn {
|
||||
display: flex;
|
||||
@@ -167,11 +171,13 @@ const logout = function () {
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
float: left;
|
||||
padding: 0 10px;
|
||||
padding: 0 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
border-radius: 0;
|
||||
|
||||
&:hover {
|
||||
background-color: #eaecef;
|
||||
background: var(--admin-header-hover);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +186,16 @@ const logout = function () {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 50px;
|
||||
|
||||
:deep(.el-breadcrumb__item) {
|
||||
.el-breadcrumb__inner {
|
||||
color: var(--theme-text-color-secondary);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
&:last-child .el-breadcrumb__inner {
|
||||
color: var(--theme-text-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
@@ -219,20 +235,31 @@ const logout = function () {
|
||||
|
||||
.user-name {
|
||||
margin-left: 10px;
|
||||
cursor: pointer;
|
||||
|
||||
.el-icon {
|
||||
color: #303133;
|
||||
color: var(--theme-text-color-secondary);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover .el-icon {
|
||||
color: var(--theme-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.user-name:hover .user-avatar {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.el-dropdown-link {
|
||||
color: #fff;
|
||||
color: var(--theme-text-color-primary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -261,7 +288,4 @@ const logout = function () {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
class="sidebar-el-menu"
|
||||
:default-active="onRoutes"
|
||||
:collapse="sidebar.collapse"
|
||||
background-color="#324157"
|
||||
text-color="#bfcbd9"
|
||||
active-text-color="#20a0ff"
|
||||
background-color="transparent"
|
||||
text-color="var(--admin-sidebar-text-muted)"
|
||||
active-text-color="var(--admin-sidebar-text)"
|
||||
unique-opened
|
||||
router
|
||||
>
|
||||
@@ -33,7 +33,7 @@
|
||||
{{ threeItem.title }}
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
<el-menu-item v-else :index="subItem.index" :key="subItem.index">
|
||||
<el-menu-item v-else :index="subItem.index">
|
||||
<i v-if="subItem.icon" :class="'iconfont icon-' + subItem.icon"></i>
|
||||
{{ subItem.title }}
|
||||
</el-menu-item>
|
||||
@@ -156,9 +156,10 @@ const items = [
|
||||
},
|
||||
{
|
||||
icon: 'mp3',
|
||||
index: '/admin/medias',
|
||||
title: '音视频记录',
|
||||
index: '/admin/records/suno',
|
||||
title: 'Suno音乐',
|
||||
},
|
||||
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -179,6 +180,42 @@ const items = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'video',
|
||||
title: '视频生成',
|
||||
index: '/admin/video',
|
||||
subs: [
|
||||
{
|
||||
icon: 'config',
|
||||
index: '/admin/video/config',
|
||||
title: '生成配置',
|
||||
},
|
||||
|
||||
{
|
||||
icon: 'list',
|
||||
index: '/admin/records/videos',
|
||||
title: '生成记录',
|
||||
},
|
||||
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'ppt',
|
||||
title: 'AIPPT',
|
||||
index: '/admin/ppt',
|
||||
subs: [
|
||||
{
|
||||
icon: 'config',
|
||||
index: '/admin/ppt/config',
|
||||
title: 'PPT 生成配置',
|
||||
},
|
||||
{
|
||||
icon: 'list',
|
||||
index: '/admin/ppt/jobs',
|
||||
title: 'PPT 任务列表',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
icon: 'moderation',
|
||||
@@ -249,6 +286,11 @@ const items = [
|
||||
index: '/admin/config/plugin',
|
||||
title: '插件配置',
|
||||
},
|
||||
{
|
||||
index: '/admin/config/wechat',
|
||||
title: '微信配置',
|
||||
icon: 'wechat',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -288,29 +330,6 @@ const items = [
|
||||
index: '/admin/loginLog',
|
||||
title: '用户登录日志',
|
||||
},
|
||||
// {
|
||||
// icon: 'menu',
|
||||
// index: '1',
|
||||
// title: '常用模板页面',
|
||||
// subs: [
|
||||
// {
|
||||
// index: '/admin/demo/form',
|
||||
// title: '表单页面',
|
||||
// },
|
||||
// {
|
||||
// index: '/admin/demo/table',
|
||||
// title: '常用表格',
|
||||
// },
|
||||
// {
|
||||
// index: '/admin/demo/import',
|
||||
// title: '导入Excel',
|
||||
// },
|
||||
// {
|
||||
// index: '/admin/demo/editor',
|
||||
// title: '富文本编辑器',
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
]
|
||||
|
||||
const route = useRoute()
|
||||
@@ -330,34 +349,43 @@ setMenuItems(items)
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
overflow-y: scroll;
|
||||
background-color: #324157;
|
||||
background: var(--admin-sidebar-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-right: 1px solid var(--admin-sidebar-border);
|
||||
transition: background 0.2s ease, border-color 0.2s ease;
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
padding: 6px 15px;
|
||||
cursor: pointer;
|
||||
background-color: #324157;
|
||||
background: transparent;
|
||||
transition: background 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--admin-header-hover);
|
||||
}
|
||||
|
||||
img {
|
||||
height: 36px;
|
||||
padding-top: 5px;
|
||||
padding: 2px;
|
||||
border-radius: 100%;
|
||||
background: #fff;
|
||||
border: 2px solid #754ff6;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: #ffffff;
|
||||
color: var(--admin-sidebar-text);
|
||||
font-weight: bold;
|
||||
padding: 12px 0 12px 10px;
|
||||
transition: width 2s ease;
|
||||
transition: width 2s ease, color 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
background: transparent;
|
||||
|
||||
.el-menu-item,
|
||||
.el-sub-menu {
|
||||
@@ -367,42 +395,37 @@ setMenuItems(items)
|
||||
}
|
||||
}
|
||||
|
||||
.el-menu-item.is-active {
|
||||
background-color: rgb(40, 52, 70);
|
||||
.el-menu-item {
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
margin: 2px 8px;
|
||||
border-radius: 8px;
|
||||
|
||||
&:hover {
|
||||
background: var(--admin-header-hover);
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background: var(--admin-sidebar-active-bg);
|
||||
color: var(--admin-sidebar-text);
|
||||
box-shadow: inset 3px 0 0 var(--admin-sidebar-active-border);
|
||||
}
|
||||
}
|
||||
|
||||
.el-sub-menu__title {
|
||||
transition: background 0.2s ease;
|
||||
margin: 2px 8px;
|
||||
border-radius: 8px;
|
||||
|
||||
&:hover {
|
||||
background: var(--admin-header-hover);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.sidebar-el-menu:not(.el-menu--collapse) {
|
||||
width: 250px;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar::-webkit-scrollbar {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.sidebar.dark {
|
||||
border-right: 1px solid var(--el-border-color-dark);
|
||||
|
||||
.logo {
|
||||
background: var(--el-bg-color);
|
||||
border-right: 1px solid var(--el-border-color);
|
||||
|
||||
.text {
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
background: var(--el-bg-color);
|
||||
|
||||
.el-menu-item.is-active {
|
||||
background-color: var(--el-menu-bg-color-dark);
|
||||
}
|
||||
|
||||
.el-menu-item:hover {
|
||||
background-color: var(--el-menu-bg-color-darker);
|
||||
}
|
||||
.sidebar-el-menu {
|
||||
border-right: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sidebar-el-menu:not(.el-menu--collapse) {
|
||||
@@ -410,7 +433,11 @@ setMenuItems(items)
|
||||
}
|
||||
|
||||
.el-menu {
|
||||
border-color: var(--el-border-color);
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar::-webkit-scrollbar {
|
||||
width: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -123,10 +123,13 @@ const handleTags = (command) => {
|
||||
position: relative;
|
||||
height: 30px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
background: var(--admin-tags-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
padding: 5px 120px 5px 10px;
|
||||
-webkit-box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
border-bottom: 1px solid var(--admin-tags-border);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
transition: background 0.2s ease, border-color 0.2s ease;
|
||||
|
||||
ul {
|
||||
box-sizing: border-box;
|
||||
@@ -138,21 +141,28 @@ const handleTags = (command) => {
|
||||
align-items: center;
|
||||
float: left;
|
||||
margin: 3px 5px 2px 3px;
|
||||
border-radius: 3px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
height: 23px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--admin-tags-border);
|
||||
background: var(--admin-tags-item-bg);
|
||||
padding: 0 5px 0 12px;
|
||||
color: var(--el-text-color);
|
||||
-webkit-transition: all 0.3s ease-in;
|
||||
-moz-transition: all 0.3s ease-in;
|
||||
transition: all 0.3s ease-in;
|
||||
color: var(--theme-text-color-secondary);
|
||||
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--el-menu-bg-color-dark);
|
||||
background: var(--admin-header-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.tags-li.active {
|
||||
background: var(--admin-tags-active-bg);
|
||||
border-color: rgba(107, 85, 255, 0.4);
|
||||
.tags-li-title {
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,11 +173,8 @@ const handleTags = (command) => {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
margin-right: 5px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.tags-li.active .tags-li-title {
|
||||
color: var(--el-color-primary);
|
||||
color: var(--theme-text-color-secondary);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,12 +187,14 @@ const handleTags = (command) => {
|
||||
text-align: center;
|
||||
width: 110px;
|
||||
height: 30px;
|
||||
background: var(--el-bg-color);
|
||||
background: transparent;
|
||||
z-index: 10;
|
||||
}
|
||||
}
|
||||
|
||||
.tags.dark {
|
||||
border-bottom: 1px solid var(--el-border-color);
|
||||
.tags-li.active {
|
||||
border-color: rgba(107, 85, 255, 0.5);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -45,12 +45,32 @@
|
||||
</CustomSelect>
|
||||
</div>
|
||||
|
||||
<!-- 模型介绍 -->
|
||||
<div
|
||||
v-if="selectedModel && selectedModel.label"
|
||||
class="w-full p-4 bg-gray-50 rounded-lg border border-gray-200"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-sm font-semibold text-blue-600 mb-1">
|
||||
<i
|
||||
class="iconfont !text-base"
|
||||
:class="selectedModel.icon.iconfont"
|
||||
v-if="selectedModel.icon.iconfont"
|
||||
></i>
|
||||
{{ selectedModel.name }}
|
||||
</h4>
|
||||
<p class="text-sm text-gray-600 whitespace-pre-line leading-relaxed">
|
||||
{{ selectedModel.label }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 参数渲染(移动端卡片样式) -->
|
||||
<template v-for="param in selectedModel.params">
|
||||
<div
|
||||
class="bg-white rounded-xl shadow-sm w-full p-4"
|
||||
:key="param.name"
|
||||
v-if="param.type !== 'hidden'"
|
||||
v-if="isParamVisible(param, modelValue)"
|
||||
>
|
||||
<!-- switch 类型单独处理 -->
|
||||
<div class="w-full flex flex-col !items-start space-y-2" v-if="param.type === 'switch'">
|
||||
@@ -156,6 +176,17 @@
|
||||
:show-word-limit="param.showWordLimit"
|
||||
:placeholder="param.placeholder"
|
||||
/>
|
||||
<div class="flex justify-end pt-2 w-full" v-if="param.enablePromptOptimizer">
|
||||
<van-button
|
||||
@click="generatePrompt"
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="submitting"
|
||||
>
|
||||
<i class="iconfont icon-chuangzuo mr-1"></i>
|
||||
优化提示词
|
||||
</van-button>
|
||||
</div>
|
||||
<ImageUpload
|
||||
v-if="param.type === 'image'"
|
||||
v-model="modelValue[param.name]"
|
||||
@@ -185,6 +216,8 @@ import FileUpload from '@/components/FileUpload.vue'
|
||||
import ImageUpload from '@/components/ImageUpload.vue'
|
||||
import CustomSelect from '@/components/mobile/CustomSelect.vue'
|
||||
import ParamEmpty from '@/components/ui/ParamEmpty.vue'
|
||||
import { httpPost } from '@/utils/http'
|
||||
import { showMessageError } from '@/utils/dialog'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
const title = ref('参数构建器')
|
||||
@@ -215,20 +248,53 @@ const props = defineProps({
|
||||
const selectedModel = ref(props.items[0])
|
||||
const selectedModelKey = ref(props.items[0] ? props.items[0].key : '')
|
||||
const requiredKeys = ref(props.requiredKeys)
|
||||
const submitting = ref(false)
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:requiredKeys'])
|
||||
const emit = defineEmits(['update:modelValue', 'update:requiredKeys', 'price-params-change'])
|
||||
|
||||
// 与桌面端保持一致的 showWhen 判断逻辑
|
||||
const matchSingleShowWhen = (condition, values) => {
|
||||
if (!condition || !values) return false
|
||||
const { field, value } = condition
|
||||
if (!field) return false
|
||||
return values[field] === value
|
||||
}
|
||||
|
||||
const isParamVisible = (param, values = modelValue.value) => {
|
||||
if (!param) return false
|
||||
if (param.type === 'hidden') return false
|
||||
|
||||
const { showWhen } = param
|
||||
if (!showWhen) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!Array.isArray(showWhen)) {
|
||||
return matchSingleShowWhen(showWhen, values)
|
||||
}
|
||||
|
||||
return showWhen.every((condition) => matchSingleShowWhen(condition, values))
|
||||
}
|
||||
|
||||
const recalcRequiredKeys = (values, model) => {
|
||||
const next = {}
|
||||
if (model && model.params) {
|
||||
model.params.forEach((param) => {
|
||||
if (param.required && isParamVisible(param, values)) {
|
||||
next[param.name] = { required: true, label: param.label }
|
||||
}
|
||||
})
|
||||
}
|
||||
requiredKeys.value = next
|
||||
}
|
||||
|
||||
const initModelValue = (model) => {
|
||||
if (!props.items || props.items.length === 0) {
|
||||
return {}
|
||||
}
|
||||
const defaultValues = {}
|
||||
requiredKeys.value = {}
|
||||
if (model && model.params) {
|
||||
model.params.forEach((param) => {
|
||||
if (param.required) {
|
||||
requiredKeys.value[param.name] = { required: true, label: param.label }
|
||||
}
|
||||
switch (param.type) {
|
||||
case 'text':
|
||||
case 'textarea':
|
||||
@@ -253,7 +319,12 @@ const initModelValue = (model) => {
|
||||
defaultValues[param.name] = param.value || null
|
||||
break
|
||||
case 'image':
|
||||
defaultValues[param.name] = param.value || []
|
||||
defaultValues[param.name] =
|
||||
param.value || (param.multiple || (param.maxCount && param.maxCount > 1) ? [] : '')
|
||||
break
|
||||
case 'file':
|
||||
defaultValues[param.name] =
|
||||
param.value || (param.multiple || (param.maxCount && param.maxCount > 1) ? [] : '')
|
||||
break
|
||||
default:
|
||||
defaultValues[param.name] = param.value || ''
|
||||
@@ -264,6 +335,7 @@ const initModelValue = (model) => {
|
||||
defaultValues.action = selectedModel.value.action
|
||||
? selectedModel.value.action
|
||||
: 'CVSync2AsyncSubmitTask'
|
||||
recalcRequiredKeys(defaultValues, model)
|
||||
return defaultValues
|
||||
}
|
||||
|
||||
@@ -281,8 +353,20 @@ const modelOptions = computed(() => {
|
||||
|
||||
watch(
|
||||
modelValue,
|
||||
(newValue) => {
|
||||
(newValue, oldValue) => {
|
||||
emit('update:modelValue', newValue)
|
||||
recalcRequiredKeys(newValue, selectedModel.value)
|
||||
|
||||
// 检查是否有 priceParams 相关字段变化
|
||||
if (selectedModel.value && selectedModel.value.priceParams) {
|
||||
const priceParamsChanged = selectedModel.value.priceParams.some((paramName) => {
|
||||
return newValue[paramName] !== oldValue?.[paramName]
|
||||
})
|
||||
|
||||
if (priceParamsChanged) {
|
||||
emit('price-params-change', newValue)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
@@ -309,14 +393,14 @@ watch(selectedModelKey, (key) => {
|
||||
if (!key) return
|
||||
const found = (props.items || []).find((m) => m.key === key)
|
||||
if (found) {
|
||||
selectedModel.value = found
|
||||
modelValue.value = initModelValue(found)
|
||||
changeModel(found)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (props.modelValue && Object.keys(props.modelValue).length > 0) {
|
||||
modelValue.value = { ...props.modelValue }
|
||||
recalcRequiredKeys(modelValue.value, selectedModel.value)
|
||||
} else {
|
||||
modelValue.value = initModelValue(selectedModel.value)
|
||||
}
|
||||
@@ -333,6 +417,45 @@ const formatParamOptions = (param) => {
|
||||
image: o.image,
|
||||
}))
|
||||
}
|
||||
|
||||
// 检查字段是否在 priceParams 中
|
||||
const isPriceParam = (fieldName) => {
|
||||
if (!selectedModel.value || !selectedModel.value.priceParams) {
|
||||
return false
|
||||
}
|
||||
return selectedModel.value.priceParams.includes(fieldName)
|
||||
}
|
||||
|
||||
// 生成提示词
|
||||
const generatePrompt = async () => {
|
||||
const prompt = modelValue.value?.prompt
|
||||
if (!prompt) {
|
||||
return showMessageError('请输入原始提示词')
|
||||
}
|
||||
try {
|
||||
submitting.value = true
|
||||
const res = await httpPost('/api/prompt/video', { prompt })
|
||||
modelValue.value = { ...modelValue.value, prompt: res.data }
|
||||
} catch (error) {
|
||||
showMessageError('生成提示词失败:' + error.message)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 模型切换方法
|
||||
const changeModel = (item) => {
|
||||
if (item) {
|
||||
selectedModel.value = item
|
||||
selectedModelKey.value = item.key
|
||||
// 更新 modelValue 为选中模型的默认值
|
||||
modelValue.value = initModelValue(item)
|
||||
// 模型切换时也触发价格参数变化事件
|
||||
if (item.priceParams && item.priceParams.length > 0) {
|
||||
emit('price-params-change', modelValue.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
class="ppt-preview-dialog"
|
||||
width="90%"
|
||||
top="5vh"
|
||||
destroy-on-close
|
||||
@closed="onDialogClosed"
|
||||
>
|
||||
<template v-if="loading">
|
||||
<div class="p-10 text-center text-gray-400">
|
||||
<el-icon class="is-loading"><Loading /></el-icon> 加载中...
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="errorText">
|
||||
<el-alert type="error" :title="errorText" show-icon />
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-alert
|
||||
v-if="taskStatus === 'failed' && taskErrorMessage"
|
||||
type="error"
|
||||
:title="taskErrorMessage"
|
||||
show-icon
|
||||
class="mb-3"
|
||||
/>
|
||||
<div
|
||||
v-if="currentSlide"
|
||||
class="mb-3 flex flex-wrap items-center justify-end gap-2 border-b border-slate-100 pb-3"
|
||||
>
|
||||
<el-popover
|
||||
placement="bottom-end"
|
||||
:width="360"
|
||||
trigger="click"
|
||||
:title="currentSlide.title || '—'"
|
||||
:content="currentSlide.image_prompt || '—'"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button type="primary" class="!m-0">显示提示词</el-button>
|
||||
</template>
|
||||
</el-popover>
|
||||
<el-button v-if="editable" class="!m-0" @click="openSlideEditDrawer">修改</el-button>
|
||||
<el-dropdown
|
||||
v-if="showExport && taskStatus === 'completed' && hasSlideImages"
|
||||
trigger="click"
|
||||
@command="onExportCommand"
|
||||
>
|
||||
<el-button class="!m-0" :loading="exportLoading">
|
||||
导出
|
||||
<el-icon class="ml-0.5"><ArrowDown /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="pdf">导出 PDF</el-dropdown-item>
|
||||
<el-dropdown-item command="pptx">导出 PPTX</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
<div class="ppt-preview-layout flex min-h-0 items-stretch gap-4">
|
||||
<div class="flex min-h-0 min-w-0 flex-1 flex-col gap-3">
|
||||
<template v-if="currentSlide">
|
||||
<div class="flex min-w-0 flex-col gap-2">
|
||||
<div class="relative aspect-video w-full overflow-hidden rounded-lg bg-[#f5f5f5]">
|
||||
<el-image
|
||||
v-if="currentSlide.image_url"
|
||||
:src="replaceImg(currentSlide.image_url)"
|
||||
fit="contain"
|
||||
class="absolute inset-0 h-full w-full"
|
||||
/>
|
||||
<div
|
||||
v-else-if="taskStatus === 'failed'"
|
||||
class="absolute inset-0 flex min-h-0 flex-col items-center justify-center gap-2 bg-slate-100/95"
|
||||
>
|
||||
<span class="text-sm font-medium text-slate-600">本页未生成配图</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="ppt-gen-surface absolute inset-0 flex min-h-0 flex-col items-center justify-center gap-2"
|
||||
>
|
||||
<el-icon class="is-loading text-2xl text-[var(--el-color-primary)]">
|
||||
<Loading />
|
||||
</el-icon>
|
||||
<span class="text-sm font-medium text-slate-600">正在生成中</span>
|
||||
<span class="max-w-[90%] text-center text-xs text-slate-400"
|
||||
>配图生成完成后将自动显示</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty v-else description="暂无幻灯片" />
|
||||
</div>
|
||||
<div class="ppt-preview-thumbs flex w-[120px] shrink-0 flex-col gap-2">
|
||||
<div
|
||||
v-for="(slide, idx) in slides"
|
||||
:key="slide.slide_index"
|
||||
class="relative w-full shrink-0 cursor-pointer overflow-hidden rounded-md border-2 border-transparent transition-colors duration-200"
|
||||
:class="
|
||||
slide.slide_index === currentSlide?.slide_index
|
||||
? 'border-[var(--el-color-primary)]'
|
||||
: 'hover:border-gray-300'
|
||||
"
|
||||
@click="currentSlideIndex = idx"
|
||||
>
|
||||
<div class="relative aspect-video w-full overflow-hidden rounded-sm bg-slate-200/80">
|
||||
<el-image
|
||||
v-if="slide.image_url"
|
||||
:src="slideThumb(slide.image_url)"
|
||||
fit="cover"
|
||||
class="absolute inset-0 h-full w-full"
|
||||
/>
|
||||
<div
|
||||
v-else-if="taskStatus === 'failed'"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center bg-slate-200/90 px-0.5"
|
||||
>
|
||||
<span class="text-center text-[9px] leading-tight text-slate-600">未生成</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="ppt-gen-surface ppt-gen-surface--thumb absolute inset-0 flex flex-col items-center justify-center gap-0.5 px-0.5"
|
||||
>
|
||||
<el-icon class="is-loading text-[var(--el-color-primary)]"><Loading /></el-icon>
|
||||
<span class="text-center text-[9px] leading-tight text-slate-500">生成中</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="pointer-events-none absolute left-1 top-1 z-10 min-w-[1.125rem] rounded bg-black/65 px-1.5 py-1 text-center text-[14px] font-medium leading-none text-white shadow-sm backdrop-blur-[2px]"
|
||||
>
|
||||
{{ idx + 1 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-drawer
|
||||
v-if="editable"
|
||||
v-model="slideEditDrawerVisible"
|
||||
title="修改幻灯片"
|
||||
direction="rtl"
|
||||
size="min(420px, 92vw)"
|
||||
destroy-on-close
|
||||
@closed="onSlideEditDrawerClosed"
|
||||
>
|
||||
<div v-if="currentSlide" class="flex flex-col gap-4">
|
||||
<div class="aspect-video w-full overflow-hidden rounded-lg bg-slate-100">
|
||||
<el-image
|
||||
v-if="currentSlide.image_url"
|
||||
:src="replaceImg(currentSlide.image_url)"
|
||||
fit="contain"
|
||||
class="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div class="mb-2 text-sm font-medium text-slate-700">生成新版本</div>
|
||||
<el-input
|
||||
v-model="slideEditPrompt"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="描述你想做的修改..."
|
||||
:disabled="slideEditGenerating"
|
||||
/>
|
||||
<el-button
|
||||
type="primary"
|
||||
class="mt-3 w-full"
|
||||
:loading="slideEditGenerating"
|
||||
:disabled="!taskId || !currentSlide?.image_url"
|
||||
@click="submitSlideEdit"
|
||||
>
|
||||
生成
|
||||
</el-button>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-2 text-sm font-medium text-slate-700">历史版本</div>
|
||||
<div class="mb-2 text-xs text-slate-400">点击下方历史图片,可切换当前幻灯片版本</div>
|
||||
<div
|
||||
v-if="!slideImageHistory.length"
|
||||
class="rounded-lg border border-dashed border-slate-200 py-6 text-center text-xs text-slate-400"
|
||||
>
|
||||
暂无历史版本
|
||||
</div>
|
||||
<div v-else class="grid max-h-[40vh] grid-cols-1 gap-3 overflow-y-auto pr-0.5 sm:grid-cols-2">
|
||||
<div
|
||||
v-for="(ver, vidx) in slideImageHistory"
|
||||
:key="vidx + '-' + (ver.image_url || '')"
|
||||
class="cursor-pointer rounded-md border-2 transition-colors"
|
||||
:class="
|
||||
activeSlideVersionIndex === vidx
|
||||
? 'border-[var(--el-color-primary)] bg-[var(--el-fill-color-light)]'
|
||||
: 'border-transparent hover:border-slate-300'
|
||||
"
|
||||
@click="activateSlideVersion(vidx)"
|
||||
>
|
||||
<div class="relative aspect-video w-full overflow-hidden rounded-sm bg-slate-100">
|
||||
<el-image :src="historyThumb(ver.image_url)" fit="cover" class="h-full w-full" />
|
||||
<span
|
||||
v-if="activeSlideVersionIndex === vidx"
|
||||
class="absolute right-1 top-1 rounded bg-[var(--el-color-primary)] px-1.5 py-0.5 text-[10px] font-medium text-white shadow"
|
||||
>当前</span
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
class="mt-1.5 line-clamp-3 px-0.5 text-left text-[11px] leading-snug text-slate-600"
|
||||
:title="slideVersionPromptText(ver, vidx)"
|
||||
>
|
||||
{{ slideVersionPromptText(ver, vidx) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ArrowDown, Loading } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getThumbURL, replaceImg } from '@/utils/libs'
|
||||
import { httpDownload, httpGet, httpPatch, httpPost } from '@/utils/http'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
taskId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
endpointBase: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
/** 预览弹窗内是否展示导出(管理端可在列表导出,预览仅浏览) */
|
||||
showExport: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'updated', 'closed'])
|
||||
|
||||
const dialogVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const errorText = ref('')
|
||||
const slides = ref([])
|
||||
const taskStatus = ref('')
|
||||
const taskErrorMessage = ref('')
|
||||
const taskTitle = ref('')
|
||||
const currentSlideIndex = ref(0)
|
||||
const exportLoading = ref(false)
|
||||
|
||||
const slideEditDrawerVisible = ref(false)
|
||||
const slideEditPrompt = ref('')
|
||||
const slideEditGenerating = ref(false)
|
||||
|
||||
const currentSlide = computed(() => slides.value[currentSlideIndex.value] || null)
|
||||
const hasSlideImages = computed(() =>
|
||||
slides.value.some((slide) => slide.image_url && String(slide.image_url).trim())
|
||||
)
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
if (loading.value) return '加载中...'
|
||||
if (errorText.value) return '幻灯片详情'
|
||||
const title = currentSlide.value?.title || taskTitle.value
|
||||
return title && String(title).trim() ? title : '幻灯片详情'
|
||||
})
|
||||
|
||||
const slideImageHistory = computed(() => {
|
||||
const slide = currentSlide.value
|
||||
if (!slide) return []
|
||||
const history = slide.image_history
|
||||
if (Array.isArray(history) && history.length) {
|
||||
return history.map((item) => {
|
||||
if (typeof item === 'string') {
|
||||
return { image_url: item, prompt: '' }
|
||||
}
|
||||
return {
|
||||
image_url: item.image_url || '',
|
||||
prompt: item.prompt != null ? String(item.prompt) : '',
|
||||
}
|
||||
})
|
||||
}
|
||||
if (slide.image_url) {
|
||||
return [
|
||||
{
|
||||
image_url: slide.image_url,
|
||||
prompt: slide.image_prompt != null ? String(slide.image_prompt) : '',
|
||||
},
|
||||
]
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const activeSlideVersionIndex = computed(() => {
|
||||
const slide = currentSlide.value
|
||||
if (!slide?.image_url) return -1
|
||||
const index = slideImageHistory.value.findIndex((item) => item.image_url === slide.image_url)
|
||||
return index >= 0 ? index : 0
|
||||
})
|
||||
|
||||
const pptThumb = (url, width, height = 0) => {
|
||||
if (!url) return ''
|
||||
return getThumbURL(replaceImg(url), width, height)
|
||||
}
|
||||
|
||||
const slideThumb = (url) => pptThumb(url, 120, 0)
|
||||
const historyThumb = (url) => pptThumb(url, 200, 0)
|
||||
|
||||
const detailUrl = computed(() => {
|
||||
if (!props.taskId) return ''
|
||||
return `${props.endpointBase}/${encodeURIComponent(props.taskId)}`
|
||||
})
|
||||
|
||||
const exportUrl = (format) =>
|
||||
`${props.endpointBase}/${encodeURIComponent(props.taskId)}/export?format=${encodeURIComponent(format)}`
|
||||
|
||||
const resetState = () => {
|
||||
slides.value = []
|
||||
taskStatus.value = ''
|
||||
taskErrorMessage.value = ''
|
||||
taskTitle.value = ''
|
||||
currentSlideIndex.value = 0
|
||||
errorText.value = ''
|
||||
slideEditDrawerVisible.value = false
|
||||
slideEditPrompt.value = ''
|
||||
}
|
||||
|
||||
const loadDetail = async () => {
|
||||
if (!props.taskId || !dialogVisible.value) return
|
||||
loading.value = true
|
||||
errorText.value = ''
|
||||
try {
|
||||
const res = await httpGet(detailUrl.value)
|
||||
const data = res.data || {}
|
||||
taskTitle.value = data.title || ''
|
||||
taskStatus.value = data.status || ''
|
||||
taskErrorMessage.value = data.error_message || ''
|
||||
slides.value = data.slides || []
|
||||
currentSlideIndex.value = 0
|
||||
} catch (e) {
|
||||
errorText.value = '加载失败:' + e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.modelValue, props.taskId],
|
||||
([visible, taskId]) => {
|
||||
if (!visible) return
|
||||
if (!taskId) {
|
||||
resetState()
|
||||
return
|
||||
}
|
||||
loadDetail()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const safeExportFileBase = (title, taskId) => {
|
||||
const raw = (title && String(title).trim()) || taskId || 'export'
|
||||
return String(raw)
|
||||
.replace(/[/\\:*?"<>|]/g, '_')
|
||||
.slice(0, 120)
|
||||
}
|
||||
|
||||
const onExportCommand = async (cmd) => {
|
||||
if (cmd !== 'pdf' && cmd !== 'pptx') return
|
||||
exportLoading.value = true
|
||||
const ext = cmd === 'pdf' ? '.pdf' : '.pptx'
|
||||
try {
|
||||
const response = await httpDownload(exportUrl(cmd))
|
||||
if (response.status !== 200) {
|
||||
throw new Error('导出失败')
|
||||
}
|
||||
const blob = new Blob([response.data])
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(blob)
|
||||
link.download = `${safeExportFileBase(taskTitle.value, props.taskId)}${ext}`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(link.href)
|
||||
ElMessage.success('已开始下载')
|
||||
} catch (e) {
|
||||
const msg = typeof e === 'string' ? e : e?.message || e?.response?.data?.message || '未知错误'
|
||||
ElMessage.error('导出失败:' + msg)
|
||||
} finally {
|
||||
exportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openSlideEditDrawer = () => {
|
||||
if (!currentSlide.value?.image_url) {
|
||||
ElMessage.warning('该页暂无配图,无法编辑')
|
||||
return
|
||||
}
|
||||
slideEditPrompt.value = ''
|
||||
slideEditDrawerVisible.value = true
|
||||
}
|
||||
|
||||
const onSlideEditDrawerClosed = () => {
|
||||
slideEditPrompt.value = ''
|
||||
}
|
||||
|
||||
const mergeSlides = (nextSlides) => {
|
||||
if (!Array.isArray(nextSlides) || !nextSlides.length) return
|
||||
const currentSlideID = currentSlide.value?.slide_index
|
||||
slides.value = nextSlides
|
||||
if (currentSlideID != null) {
|
||||
const nextIndex = nextSlides.findIndex((slide) => slide.slide_index === currentSlideID)
|
||||
if (nextIndex >= 0) currentSlideIndex.value = nextIndex
|
||||
}
|
||||
}
|
||||
|
||||
const submitSlideEdit = async () => {
|
||||
const prompt = (slideEditPrompt.value || '').trim()
|
||||
const slide = currentSlide.value
|
||||
if (!props.taskId || !slide?.slide_index) return
|
||||
if (!prompt) {
|
||||
ElMessage.warning('请填写修改说明')
|
||||
return
|
||||
}
|
||||
slideEditGenerating.value = true
|
||||
try {
|
||||
const res = await httpPost(
|
||||
`${props.endpointBase}/${encodeURIComponent(props.taskId)}/slides/${slide.slide_index}/edit-image`,
|
||||
{ prompt }
|
||||
)
|
||||
const payload = res?.data || {}
|
||||
if (payload.slides?.length) mergeSlides(payload.slides)
|
||||
else await loadDetail()
|
||||
slideEditPrompt.value = ''
|
||||
ElMessage.success('已生成新版本')
|
||||
emit('updated')
|
||||
} catch (e) {
|
||||
const msg = typeof e === 'string' ? e : e?.message || e?.response?.data?.message || '生成失败'
|
||||
ElMessage.error(msg)
|
||||
} finally {
|
||||
slideEditGenerating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function slideVersionPromptText(ver, index) {
|
||||
const prompt = (ver.prompt || '').trim()
|
||||
if (index === 0) {
|
||||
return prompt ? `初始:${prompt}` : '初始版本(文生图)'
|
||||
}
|
||||
return prompt ? `修改:${prompt}` : `版本 ${index + 1}(无说明)`
|
||||
}
|
||||
|
||||
const activateSlideVersion = async (versionIndex) => {
|
||||
if (activeSlideVersionIndex.value === versionIndex) return
|
||||
const slide = currentSlide.value
|
||||
if (!props.taskId || !slide?.slide_index) return
|
||||
try {
|
||||
const res = await httpPatch(
|
||||
`${props.endpointBase}/${encodeURIComponent(props.taskId)}/slides/${slide.slide_index}/active-image`,
|
||||
{ version_index: versionIndex }
|
||||
)
|
||||
const payload = res?.data || {}
|
||||
if (payload.slides?.length) mergeSlides(payload.slides)
|
||||
else await loadDetail()
|
||||
emit('updated')
|
||||
} catch (e) {
|
||||
const msg = typeof e === 'string' ? e : e?.message || e?.response?.data?.message || '切换失败'
|
||||
ElMessage.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
const onDialogClosed = () => {
|
||||
resetState()
|
||||
emit('closed')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ppt-preview-dialog :deep(.el-dialog__body) {
|
||||
padding-top: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ppt-preview-layout {
|
||||
height: calc(90vh - 120px);
|
||||
max-height: calc(90vh - 120px);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ppt-preview-thumbs {
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
@keyframes ppt-shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ppt-gen-surface {
|
||||
background: linear-gradient(105deg, #e4e7ec 0%, #ebeef3 35%, #f4f6f9 50%, #e4e7ec 100%);
|
||||
background-size: 220% 100%;
|
||||
animation: ppt-shimmer 2.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ppt-gen-surface--thumb {
|
||||
animation-duration: 1.6s;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div
|
||||
class="relative flex bg-gray-100 rounded-lg py-1 mb-2.5 overflow-hidden"
|
||||
ref="tabsHeader"
|
||||
>
|
||||
<div
|
||||
class="flex-1 text-center py-1.5 px-3 font-medium text-gray-700 cursor-pointer transition-colors duration-300 rounded-md relative z-20 hover:text-purple-600"
|
||||
v-for="(tab, index) in panes"
|
||||
:key="tab.name"
|
||||
:class="{ '!text-purple-600': modelValue === tab.name }"
|
||||
@click="handleTabClick(tab.name, index)"
|
||||
ref="tabItems"
|
||||
>
|
||||
<component
|
||||
v-if="tab.labelSlot"
|
||||
:is="{ render: () => tab.labelSlot() }"
|
||||
/>
|
||||
<template v-else>
|
||||
{{ tab.label }}
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
class="absolute top-1 bottom-1 bg-white rounded-md shadow-sm transition-all duration-300 ease-out z-10"
|
||||
:style="indicatorStyle"
|
||||
ref="indicator"
|
||||
></div>
|
||||
</div>
|
||||
<div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, provide, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'tab-click'])
|
||||
|
||||
const tabsHeader = ref(null)
|
||||
const tabItems = ref([])
|
||||
const indicator = ref(null)
|
||||
const panes = ref([])
|
||||
|
||||
const indicatorStyle = ref({
|
||||
transform: 'translateX(0px)',
|
||||
width: '0px',
|
||||
})
|
||||
|
||||
// 提供当前激活的 tab 给子组件
|
||||
provide(
|
||||
'currentTab',
|
||||
computed(() => props.modelValue)
|
||||
)
|
||||
|
||||
// 提供注册 pane 的方法给子组件
|
||||
provide('registerPane', (pane) => {
|
||||
// 检查是否已经存在相同 name 的 pane,避免重复注册
|
||||
const existingIndex = panes.value.findIndex((p) => p.name === pane.name)
|
||||
if (existingIndex === -1) {
|
||||
// 不存在则添加
|
||||
panes.value.push(pane)
|
||||
} else {
|
||||
// 存在则更新
|
||||
panes.value[existingIndex] = pane
|
||||
}
|
||||
})
|
||||
|
||||
const handleTabClick = (tabName, index) => {
|
||||
emit('update:modelValue', tabName)
|
||||
emit('tab-click', tabName, index)
|
||||
updateIndicator(index)
|
||||
}
|
||||
|
||||
const updateIndicator = async (activeIndex) => {
|
||||
await nextTick()
|
||||
if (tabItems.value && tabItems.value.length > 0 && tabsHeader.value) {
|
||||
const container = tabsHeader.value
|
||||
const containerWidth = container.offsetWidth
|
||||
const padding = 8 // p-1 左右内边距总和 (4px * 2)
|
||||
const availableWidth = containerWidth - padding
|
||||
const tabCount = tabItems.value.length
|
||||
const tabWidth = availableWidth / tabCount
|
||||
const leftPosition = activeIndex * tabWidth + 4 // 加上左内边距
|
||||
|
||||
indicatorStyle.value = {
|
||||
transform: `translateX(${leftPosition}px)`,
|
||||
width: `${tabWidth}px`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 监听 modelValue 变化,更新指示器位置
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
const activeIndex = panes.value.findIndex(
|
||||
(pane) => pane.name === newValue
|
||||
)
|
||||
if (activeIndex !== -1) {
|
||||
updateIndicator(activeIndex)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化指示器位置
|
||||
nextTick(() => {
|
||||
const activeIndex = panes.value.findIndex(
|
||||
(pane) => pane.name === props.modelValue
|
||||
)
|
||||
if (activeIndex !== -1) {
|
||||
updateIndicator(activeIndex)
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<!-- 多项目输入组件 -->
|
||||
<div class="items-input-box">
|
||||
<div class="flex items-start gap-2 flex-wrap">
|
||||
<el-tag
|
||||
v-for="tag in tags"
|
||||
:key="tag"
|
||||
@@ -26,20 +26,24 @@
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
// eslint-disable-next-line no-undef
|
||||
const props = defineProps({
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
// eslint-disable-next-line no-undef
|
||||
const emits = defineEmits(['update:value'])
|
||||
const tags = ref(props.value)
|
||||
const tags = ref(props.modelValue)
|
||||
const inputValue = ref('')
|
||||
const inputVisible = ref(false)
|
||||
const InputRef = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
console.log(props.modelValue)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
tags.value = newValue
|
||||
}
|
||||
@@ -65,14 +69,3 @@ const handleInputConfirm = () => {
|
||||
emits('update:value', tags.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.items-input-box {
|
||||
display: flex;
|
||||
|
||||
.el-tag {
|
||||
display: flex;
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import '@/assets/css/tailwind.css'
|
||||
import '@/assets/css/custom-scroll.scss'
|
||||
import '@/assets/iconfont/iconfont.css'
|
||||
import { useThemeStore } from '@/store/theme'
|
||||
import 'animate.css/animate.min.css'
|
||||
|
||||
+49
-19
@@ -33,12 +33,6 @@ const homeRoutes = {
|
||||
meta: { title: 'MidJourney 绘画中心' },
|
||||
component: () => import('@/views/ImageMj.vue'),
|
||||
},
|
||||
{
|
||||
name: 'image-sd',
|
||||
path: '/sd',
|
||||
meta: { title: 'stable diffusion 绘画中心' },
|
||||
component: () => import('@/views/ImageSd.vue'),
|
||||
},
|
||||
{
|
||||
name: 'member',
|
||||
path: '/member',
|
||||
@@ -53,9 +47,9 @@ const homeRoutes = {
|
||||
},
|
||||
{
|
||||
name: 'images',
|
||||
path: '/images-wall',
|
||||
path: '/gallery',
|
||||
meta: { title: '作品展示' },
|
||||
component: () => import('@/views/ImagesWall.vue'),
|
||||
component: () => import('@/views/Gallery.vue'),
|
||||
},
|
||||
{
|
||||
name: 'user-invitation',
|
||||
@@ -76,10 +70,16 @@ const homeRoutes = {
|
||||
component: () => import('@/views/MarkMap.vue'),
|
||||
},
|
||||
{
|
||||
name: 'dalle',
|
||||
path: '/dalle',
|
||||
meta: { title: 'DALLE-3' },
|
||||
component: () => import('@/views/Dalle.vue'),
|
||||
name: 'image',
|
||||
path: '/image',
|
||||
meta: { title: 'AI图像生成' },
|
||||
component: () => import('@/views/Image.vue'),
|
||||
},
|
||||
{
|
||||
name: 'ppt',
|
||||
path: '/ppt',
|
||||
meta: { title: 'PPT 生成' },
|
||||
component: () => import('@/views/PPTCreate.vue'),
|
||||
},
|
||||
{
|
||||
name: 'suno',
|
||||
@@ -316,10 +316,16 @@ const routes = [
|
||||
component: () => import('@/views/admin/records/ImageList.vue'),
|
||||
},
|
||||
{
|
||||
path: '/admin/medias',
|
||||
name: 'admin-medias',
|
||||
meta: { title: '音视频管理' },
|
||||
component: () => import('@/views/admin/records/Medias.vue'),
|
||||
path: '/admin/records/suno',
|
||||
name: 'admin-suno',
|
||||
meta: { title: 'Suno音乐管理' },
|
||||
component: () => import('@/views/admin/records/Suno.vue'),
|
||||
},
|
||||
{
|
||||
path: '/admin/records/videos',
|
||||
name: 'admin-videos',
|
||||
meta: { title: '视频任务管理' },
|
||||
component: () => import('@/views/admin/records/Videos.vue'),
|
||||
},
|
||||
{
|
||||
path: '/admin/jimeng/jobs',
|
||||
@@ -333,6 +339,24 @@ const routes = [
|
||||
meta: { title: '即梦设置' },
|
||||
component: () => import('@/views/admin/jimeng/JimengConfig.vue'),
|
||||
},
|
||||
{
|
||||
path: '/admin/video/config',
|
||||
name: 'admin-video-config',
|
||||
meta: { title: '视频生成配置' },
|
||||
component: () => import('@/views/admin/video/VideoConfig.vue'),
|
||||
},
|
||||
{
|
||||
path: '/admin/ppt/config',
|
||||
name: 'admin-ppt-config',
|
||||
meta: { title: 'PPT 生成配置' },
|
||||
component: () => import('@/views/admin/settings/PPTConfig.vue'),
|
||||
},
|
||||
{
|
||||
path: '/admin/ppt/jobs',
|
||||
name: 'admin-ppt-jobs',
|
||||
meta: { title: 'PPT 任务列表' },
|
||||
component: () => import('@/views/admin/ppt/PPTJobs.vue'),
|
||||
},
|
||||
{
|
||||
path: '/admin/powerLog',
|
||||
name: 'admin-power-log',
|
||||
@@ -345,6 +369,12 @@ const routes = [
|
||||
meta: { title: '管理员' },
|
||||
component: () => import('@/views/admin/Manager.vue'),
|
||||
},
|
||||
{
|
||||
path: '/admin/config/wechat',
|
||||
name: 'admin-wechat-config',
|
||||
meta: { title: '微信配置' },
|
||||
component: () => import('@/views/admin/settings/WechatConfig.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -407,9 +437,9 @@ const mobileRoutes = {
|
||||
},
|
||||
{
|
||||
meta: { title: '作品展示' },
|
||||
path: '/mobile/imgWall',
|
||||
name: 'mobile-img-wall',
|
||||
component: () => import('@/views/mobile/pages/ImgWall.vue'),
|
||||
path: '/mobile/gallery',
|
||||
name: 'mobile-gallery',
|
||||
component: () => import('@/views/mobile/pages/Gallery.vue'),
|
||||
},
|
||||
{
|
||||
path: '/mobile/chat/session',
|
||||
|
||||
@@ -103,110 +103,50 @@ export const JimengParams = {
|
||||
options: [
|
||||
// 1K 分辨率
|
||||
{
|
||||
label: '1:1 (1024 x 1024)',
|
||||
label: '1024x1024 (1:1)',
|
||||
value: '1024x1024',
|
||||
},
|
||||
{
|
||||
label: '4:3 (1152 x 864)',
|
||||
value: '1152x864',
|
||||
},
|
||||
{
|
||||
label: '3:4 (864 x 1152)',
|
||||
value: '864x1152',
|
||||
},
|
||||
{
|
||||
label: '3:2 (1248 x 832)',
|
||||
value: '1248x832',
|
||||
},
|
||||
{
|
||||
label: '2:3 (832 x 1248)',
|
||||
value: '832x1248',
|
||||
},
|
||||
{
|
||||
label: '16:9 (1280 x 720)',
|
||||
value: '1280x720',
|
||||
},
|
||||
{
|
||||
label: '9:16 (720 x 1280)',
|
||||
value: '720x1280',
|
||||
},
|
||||
{
|
||||
label: '21:9 (1512 x 648)',
|
||||
value: '1512x648',
|
||||
},
|
||||
{
|
||||
label: '9:21 (648 x 1512)',
|
||||
value: '648x1512',
|
||||
},
|
||||
// 2K 分辨率
|
||||
{
|
||||
label: '1:1 (2048 x 2048)',
|
||||
label: '2048x2048 (1:1)',
|
||||
value: '2048x2048',
|
||||
},
|
||||
{
|
||||
label: '4:3 (2304 x 1728)',
|
||||
label: '2304x1728 (4:3)',
|
||||
value: '2304x1728',
|
||||
},
|
||||
{
|
||||
label: '3:4 (1728 x 2304)',
|
||||
value: '1728x2304',
|
||||
},
|
||||
{
|
||||
label: '3:2 (2496 x 1664)',
|
||||
label: '2496x1664 (3:2)',
|
||||
value: '2496x1664',
|
||||
},
|
||||
{
|
||||
label: '2:3 (1664 x 2496)',
|
||||
value: '1664x2496',
|
||||
},
|
||||
{
|
||||
label: '16:9 (2560 x 1440)',
|
||||
label: '2560x1440 (16:9)',
|
||||
value: '2560x1440',
|
||||
},
|
||||
{
|
||||
label: '9:16 (1440 x 2560)',
|
||||
value: '1440x2560',
|
||||
},
|
||||
{
|
||||
label: '21:9 (3024 x 1296)',
|
||||
label: '3024x1296 (21:9)',
|
||||
value: '3024x1296',
|
||||
},
|
||||
{
|
||||
label: '9:21 (1296 x 3024)',
|
||||
value: '1296x3024',
|
||||
},
|
||||
// 4K 分辨率
|
||||
{
|
||||
label: '1:1 (4096 x 4096)',
|
||||
label: '4096x4096 (1:1)',
|
||||
value: '4096x4096',
|
||||
},
|
||||
{
|
||||
label: '4:3 (4736 x 3552)',
|
||||
value: '4736x3552',
|
||||
label: '4694x3520 (4:3)',
|
||||
value: '4694x3520',
|
||||
},
|
||||
{
|
||||
label: '3:4 (3552 x 4736)',
|
||||
value: '3552x4736',
|
||||
label: '4992x3328 (3:2)',
|
||||
value: '4992x3328',
|
||||
},
|
||||
{
|
||||
label: '3:2 (5024 x 3360)',
|
||||
value: '5024x3360',
|
||||
label: '5404x3040 (16:9)',
|
||||
value: '5404x3040',
|
||||
},
|
||||
{
|
||||
label: '2:3 (3360 x 5024)',
|
||||
value: '3360x5024',
|
||||
},
|
||||
{
|
||||
label: '16:9 (5472 x 2072)',
|
||||
value: '5472x2072',
|
||||
},
|
||||
{
|
||||
label: '9:16 (2072 x 5472)',
|
||||
value: '2072x5472',
|
||||
},
|
||||
{
|
||||
label: '21:9 (6272 x 2688)',
|
||||
value: '6272x2688',
|
||||
label: '6198x2656 (21:9)',
|
||||
value: '6198x2656',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -512,6 +452,57 @@ export const JimengParams = {
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
name: 'AI营销商品图3.0',
|
||||
icon: { text: '3.0' },
|
||||
label: '支持上传包含物品主体的营销商品图和提示词,通过AI技术实现商品图的背景替换',
|
||||
key: 'i2i_dreamlight3_0_background_replace',
|
||||
params: [
|
||||
{
|
||||
name: 'image_urls',
|
||||
label: '商品图片',
|
||||
type: 'image',
|
||||
required: true,
|
||||
placeholder: '请上传商品图片',
|
||||
maxSize: 5,
|
||||
multiple: false,
|
||||
maxCount: 1,
|
||||
accept: '.png,.jpg,.jpeg',
|
||||
info: '支持上传包含物品主体的营销商品图',
|
||||
},
|
||||
{
|
||||
name: 'prompt',
|
||||
label: '背景提示词',
|
||||
type: 'textarea',
|
||||
required: true,
|
||||
showWordLimit: true,
|
||||
maxlength: 800,
|
||||
autosize: { minRows: 3, maxRows: 5 },
|
||||
placeholder: '请输入背景描述,如:雪山,雪地,草甸,一望无际的雪原,优美的风景',
|
||||
info: '用于描述期望的背景场景,支持中英文输入',
|
||||
},
|
||||
{
|
||||
name: 'seg_prompt',
|
||||
label: '抠图提示词',
|
||||
type: 'textarea',
|
||||
required: false,
|
||||
showWordLimit: true,
|
||||
maxlength: 200,
|
||||
autosize: { minRows: 2, maxRows: 3 },
|
||||
placeholder: '请输入需要分割的主体描述,如:毛绒熊',
|
||||
info: '可选,用于精确指定需要分割的主体。如果不提供,将自动分割图片中的主要主体(人物/商品)',
|
||||
},
|
||||
{
|
||||
name: 'light_switch',
|
||||
type: 'switch',
|
||||
required: false,
|
||||
label: '保留商品光照',
|
||||
info: '开启后保留原商品的光照效果,关闭后光照会适配新背景环境',
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
name: '图片 3.0 图像特效',
|
||||
icon: { text: '3.0' },
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -39,17 +39,7 @@ export const useVideoStore = defineStore('video', () => {
|
||||
negative_prompt: '',
|
||||
image: '',
|
||||
image_tail: '',
|
||||
camera_control: {
|
||||
type: '',
|
||||
config: {
|
||||
horizontal: 0,
|
||||
vertical: 0,
|
||||
pan: 0,
|
||||
tilt: 0,
|
||||
roll: 0,
|
||||
zoom: 0,
|
||||
},
|
||||
},
|
||||
sound: false,
|
||||
})
|
||||
const kelingUseImageMode = ref(false)
|
||||
const kelingStartImage = ref([])
|
||||
@@ -64,25 +54,6 @@ export const useVideoStore = defineStore('video', () => {
|
||||
]
|
||||
const durationOptions = ['5', '10']
|
||||
const modeOptions = ['std', 'pro']
|
||||
const cameraControlOptions = [
|
||||
'',
|
||||
'simple',
|
||||
'down_back',
|
||||
'forward_up',
|
||||
'right_turn_forward',
|
||||
'left_turn_forward',
|
||||
]
|
||||
const getCameraControlLabel = (option) => {
|
||||
const labelMap = {
|
||||
'': '请选择',
|
||||
simple: '简单运镜',
|
||||
down_back: '下移拉远',
|
||||
forward_up: '推进上移',
|
||||
right_turn_forward: '右旋推进',
|
||||
left_turn_forward: '左旋推进',
|
||||
}
|
||||
return labelMap[option] || option
|
||||
}
|
||||
|
||||
// 页面数据
|
||||
const page = ref(1)
|
||||
@@ -361,8 +332,6 @@ export const useVideoStore = defineStore('video', () => {
|
||||
modelOptions,
|
||||
durationOptions,
|
||||
modeOptions,
|
||||
cameraControlOptions,
|
||||
getCameraControlLabel,
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
|
||||
@@ -104,9 +104,6 @@ export const useSunoStore = defineStore('suno', () => {
|
||||
const items = []
|
||||
|
||||
for (let v of res.data.items) {
|
||||
if (v.progress === 100) {
|
||||
v.major_model_version = v['raw_data']['major_model_version']
|
||||
}
|
||||
if (v.progress === 0 || v.progress === 102) {
|
||||
needPull = true
|
||||
}
|
||||
|
||||
+450
-598
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,8 @@
|
||||
|
||||
import { getAdminToken, getUserToken, removeAdminToken, removeUserToken } from '@/store/session'
|
||||
import axios from 'axios'
|
||||
import { showMessageError } from '@/utils/dialog'
|
||||
import { replaceImg } from '@/utils/libs'
|
||||
|
||||
// Blob 数据读取和解析的辅助函数
|
||||
export async function parseBlobResponse(blob) {
|
||||
@@ -107,6 +109,19 @@ export function httpPost(url, data = {}, options = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
export function httpPatch(url, data = {}, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
axios
|
||||
.patch(url, data, options)
|
||||
.then((response) => {
|
||||
resolve(response.data)
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function httpDownload(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
axios({
|
||||
@@ -139,3 +154,29 @@ export function httpPostDownload(url, data) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 文件下载
|
||||
export const downloadFile = async (item, key) => {
|
||||
const url = replaceImg(item[key])
|
||||
const downloadURL = `/api/download?url=${url}`
|
||||
const urlObj = new URL(url)
|
||||
const fileName = urlObj.pathname.split('/').pop()
|
||||
|
||||
item.downloading = true
|
||||
|
||||
try {
|
||||
const response = await httpDownload(downloadURL)
|
||||
const blob = new Blob([response.data])
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(blob)
|
||||
link.download = fileName
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(link.href)
|
||||
item.downloading = false
|
||||
} catch (error) {
|
||||
showMessageError('下载失败')
|
||||
item.downloading = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* Util lib functions
|
||||
*/
|
||||
import { showConfirmDialog } from 'vant'
|
||||
import { httpGet } from '@/utils/http'
|
||||
|
||||
// generate a random string
|
||||
export function randString(length) {
|
||||
@@ -260,3 +261,76 @@ export function isChrome() {
|
||||
export function formatDateTime(timestamp, format = 'yyyy-MM-dd HH:mm:ss') {
|
||||
return dateFormat(timestamp, format)
|
||||
}
|
||||
|
||||
export function isWechat() {
|
||||
const ua = navigator.userAgent.toLowerCase()
|
||||
return ua.indexOf('micromessenger') !== -1
|
||||
}
|
||||
|
||||
// 默认缩略图模板(本地存储格式)
|
||||
const DEFAULT_THUMB_TEMPLATE = '?imageView2/4/w/{width}/h/{height}/q/75'
|
||||
const THUMB_TEMPLATE_KEY = 'GEEKAI_THUMB_TEMPLATE'
|
||||
|
||||
/**
|
||||
* 初始化缩略图模板(从后端加载并缓存到localStorage)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function initThumbTemplate() {
|
||||
try {
|
||||
const res = await httpGet('/api/config/oss/thumb')
|
||||
if (res.data && res.data.template) {
|
||||
const template = res.data.template || DEFAULT_THUMB_TEMPLATE
|
||||
localStorage.setItem(THUMB_TEMPLATE_KEY, template)
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('获取缩略图模板失败,使用默认模板:', error)
|
||||
}
|
||||
|
||||
// 获取失败时使用默认模板并缓存
|
||||
localStorage.setItem(THUMB_TEMPLATE_KEY, DEFAULT_THUMB_TEMPLATE)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存的缩略图模板
|
||||
* @returns {string} 缩略图模板
|
||||
*/
|
||||
function getCachedThumbTemplate() {
|
||||
const cached = localStorage.getItem(THUMB_TEMPLATE_KEY)
|
||||
return cached || DEFAULT_THUMB_TEMPLATE
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成缩略图URL
|
||||
* @param {string} originalURL - 原始图片URL
|
||||
* @param {number} width - 缩略图宽度
|
||||
* @param {number} height - 缩略图高度,0表示自适应
|
||||
* @param {string} template - 缩略图模板(可选),如果不提供则从localStorage获取
|
||||
* @returns {string} 缩略图URL
|
||||
*/
|
||||
export function getThumbURL(originalURL, width = 300, height = 0, template = null) {
|
||||
if (!originalURL) {
|
||||
return originalURL
|
||||
}
|
||||
|
||||
// 如果没有提供模板,从localStorage获取
|
||||
if (!template) {
|
||||
template = getCachedThumbTemplate()
|
||||
}
|
||||
|
||||
// 如果模板为空字符串,返回原图(表示不支持缩略图)
|
||||
if (template === '') {
|
||||
return originalURL
|
||||
}
|
||||
|
||||
// 如果模板为空,使用默认模板(降级方案)
|
||||
if (!template) {
|
||||
template = DEFAULT_THUMB_TEMPLATE
|
||||
}
|
||||
|
||||
// 替换变量
|
||||
const thumbURL = template.replace(/{width}/g, width).replace(/{height}/g, height)
|
||||
|
||||
// 拼接原始URL和缩略图参数
|
||||
return originalURL + thumbURL
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<el-button size="small" class="sm-btn-theme" @click="useRole(scope.item)"
|
||||
>使用</el-button
|
||||
>
|
||||
<el-tooltip content="从工作区移除" placement="top" v-if="hasRole(scope.item.key)">
|
||||
<el-tooltip content="从工作区移除" placement="top" v-if="hasRole(scope.item.id)">
|
||||
<el-button size="small" type="danger" @click="updateRole(scope.item, 'remove')"
|
||||
>移除</el-button
|
||||
>
|
||||
@@ -89,7 +89,7 @@ onMounted(() => {
|
||||
const getRoles = () => {
|
||||
checkSession()
|
||||
.then((user) => {
|
||||
roles.value = user.chat_roles
|
||||
roles.value = Array.isArray(user.chat_roles) ? user.chat_roles : []
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e.message)
|
||||
@@ -128,20 +128,20 @@ const updateRole = (row, opt) => {
|
||||
const title = ref('')
|
||||
if (opt === 'add') {
|
||||
title.value = '添加应用'
|
||||
const exists = arrayContains(roles.value, row.key)
|
||||
const exists = arrayContains(roles.value, row.id)
|
||||
if (exists) {
|
||||
return
|
||||
}
|
||||
roles.value.push(row.key)
|
||||
roles.value.push(row.id)
|
||||
} else {
|
||||
title.value = '移除应用'
|
||||
const exists = arrayContains(roles.value, row.key)
|
||||
const exists = arrayContains(roles.value, row.id)
|
||||
if (!exists) {
|
||||
return
|
||||
}
|
||||
roles.value = removeArrayItem(roles.value, row.key)
|
||||
roles.value = removeArrayItem(roles.value, row.id)
|
||||
}
|
||||
httpPost('/api/app/update', { keys: roles.value })
|
||||
httpPost('/api/app/workspace', { ids: roles.value })
|
||||
.then(() => {
|
||||
ElMessage.success({
|
||||
message: title.value + '成功!',
|
||||
@@ -157,8 +157,8 @@ const updateRole = (row, opt) => {
|
||||
})
|
||||
}
|
||||
|
||||
const hasRole = (roleKey) => {
|
||||
return arrayContains(roles.value, roleKey, (v1, v2) => v1 === v2)
|
||||
const hasRole = (roleId) => {
|
||||
return arrayContains(roles.value, roleId, (v1, v2) => v1 === v2)
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
+836
-88
@@ -1,142 +1,890 @@
|
||||
<template>
|
||||
<div class="chat-export" v-loading="loading">
|
||||
<div class="chat-box" id="chat-box">
|
||||
<div class="title pt-4">
|
||||
<h2>{{ chatTitle }}</h2>
|
||||
<div class="chat-export-page" v-loading="loading">
|
||||
<div class="export-container">
|
||||
<!-- 页面头部 -->
|
||||
<header class="export-header">
|
||||
<div class="header-content">
|
||||
<h1 class="export-title">{{ chatTitle || '聊天记录导出' }}</h1>
|
||||
<div class="header-meta" v-if="chatData.length > 0">
|
||||
<span class="meta-item">
|
||||
<i class="iconfont icon-message"></i>
|
||||
{{ chatData.length }} 条消息
|
||||
</span>
|
||||
<span class="meta-item" v-if="exportDate">
|
||||
<i class="iconfont icon-clock"></i>
|
||||
{{ exportDate }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 聊天消息列表 -->
|
||||
<main class="export-content" v-if="chatData.length > 0">
|
||||
<div class="messages-list" id="messages-list">
|
||||
<div
|
||||
v-for="(message, index) in chatData"
|
||||
:key="message.id || index"
|
||||
class="message-item"
|
||||
:class="{
|
||||
'message-user': message.type === 'prompt',
|
||||
'message-assistant': message.type === 'reply' || message.type === 'mj',
|
||||
}"
|
||||
>
|
||||
<!-- 用户消息 -->
|
||||
<div v-if="message.type === 'prompt'" class="message-bubble message-bubble-user">
|
||||
<div class="message-header">
|
||||
<div class="message-avatar">
|
||||
<img
|
||||
:src="message.icon || '/images/user-icon.png'"
|
||||
alt="用户"
|
||||
class="avatar-img"
|
||||
/>
|
||||
</div>
|
||||
<div class="message-info">
|
||||
<span class="message-role">用户</span>
|
||||
<span class="message-time" v-if="message.created_at">
|
||||
{{ formatTime(message.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-body">
|
||||
<!-- 文件列表 -->
|
||||
<div v-if="message.content?.files && message.content.files.length > 0" class="message-files">
|
||||
<div
|
||||
v-for="file in message.content.files"
|
||||
:key="file.url"
|
||||
class="file-item"
|
||||
>
|
||||
<img
|
||||
v-if="isImageFile(file.ext)"
|
||||
:src="file.url"
|
||||
:alt="file.name"
|
||||
class="file-image"
|
||||
/>
|
||||
<div v-else class="file-card">
|
||||
<i class="iconfont icon-file"></i>
|
||||
<div class="file-info">
|
||||
<div class="file-name">{{ file.name }}</div>
|
||||
<div class="file-meta">{{ formatFileSize(file.size) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 文本内容 -->
|
||||
<div
|
||||
class="message-text"
|
||||
v-html="renderMarkdown(message.content?.text || message.content || '')"
|
||||
></div>
|
||||
<!-- 操作按钮 -->
|
||||
<div class="message-actions">
|
||||
<button
|
||||
class="action-btn"
|
||||
@click="copyMessage(message.content?.text || message.content || '')"
|
||||
title="复制"
|
||||
>
|
||||
<i class="iconfont icon-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI 回复消息 -->
|
||||
<div
|
||||
v-else-if="message.type === 'reply' || message.type === 'mj'"
|
||||
class="message-bubble message-bubble-assistant"
|
||||
>
|
||||
<div class="message-header">
|
||||
<div class="message-avatar">
|
||||
<img
|
||||
:src="message.icon || '/images/gpt-icon.png'"
|
||||
alt="AI助手"
|
||||
class="avatar-img"
|
||||
/>
|
||||
</div>
|
||||
<div class="message-info">
|
||||
<span class="message-role">AI 助手</span>
|
||||
<span class="message-time" v-if="message.created_at">
|
||||
{{ formatTime(message.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-body">
|
||||
<div
|
||||
class="message-text"
|
||||
v-html="renderMarkdown(getMessageContent(message))"
|
||||
></div>
|
||||
|
||||
<!-- 文件列表 -->
|
||||
<AttachmentList :files="message.content?.files || []" />
|
||||
<!-- 操作按钮 -->
|
||||
<div class="message-actions">
|
||||
<button
|
||||
class="action-btn"
|
||||
@click="copyMessage(getMessageContent(message))"
|
||||
title="复制"
|
||||
>
|
||||
<i class="iconfont icon-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div class="error-state" v-if="error && !loading">
|
||||
<div class="error-content">
|
||||
<i class="iconfont icon-warning error-icon"></i>
|
||||
<h3 class="error-title">加载失败</h3>
|
||||
<p class="error-text">{{ error }}</p>
|
||||
<button class="retry-btn" @click="init">重试</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="item in chatData" :key="item.id">
|
||||
<chat-prompt-line v-if="item.type === 'prompt'" :data="item" list-style="list" />
|
||||
<chat-reply-line
|
||||
v-else-if="item.type === 'reply'"
|
||||
:data="item"
|
||||
:read-only="true"
|
||||
list-style="list"
|
||||
/>
|
||||
<!-- 空状态 -->
|
||||
<div class="empty-state" v-else-if="!loading && !error && chatData.length === 0">
|
||||
<div class="empty-content">
|
||||
<i class="iconfont icon-chat empty-icon"></i>
|
||||
<h3 class="empty-title">暂无聊天记录</h3>
|
||||
<p class="empty-text">该聊天会话中没有消息记录</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- end chat box -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ChatPromptLine from '@/components/ChatPromptLine.vue'
|
||||
import ChatReplyLine from '@/components/ChatReplyLine.vue'
|
||||
import { httpGet } from '@/utils/http'
|
||||
import Clipboard from 'clipboard'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import hl from 'highlight.js'
|
||||
import 'highlight.js/styles/a11y-dark.css'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import emoji from 'markdown-it-emoji'
|
||||
import mathjaxPlugin from 'markdown-it-mathjax3'
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { processContent, isImage } from '@/utils/libs'
|
||||
import AttachmentList from '@/components/AttachmentList.vue'
|
||||
|
||||
const chatData = ref([])
|
||||
const router = useRouter()
|
||||
const chatId = router.currentRoute.value.query['chat_id']
|
||||
const loading = ref(true)
|
||||
const chatTitle = ref('')
|
||||
const chatData = ref([])
|
||||
const error = ref(null)
|
||||
const exportDate = ref('')
|
||||
|
||||
httpGet('/api/chat/history?chat_id=' + chatId)
|
||||
.then((res) => {
|
||||
// 初始化 Markdown 渲染器
|
||||
const md = new MarkdownIt({
|
||||
breaks: true,
|
||||
html: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
highlight: function (str, lang) {
|
||||
const codeIndex = Date.now() + Math.floor(Math.random() * 10000000)
|
||||
const copyBtn = `<button class="code-copy-btn" onclick="navigator.clipboard.writeText(\`${str.replace(/`/g, '\\`').replace(/\$/g, '\\$')}\`); this.textContent='已复制'; setTimeout(()=>this.textContent='复制', 2000)">复制</button>`
|
||||
|
||||
if (lang && hl.getLanguage(lang)) {
|
||||
try {
|
||||
const highlighted = hl.highlight(str, { language: lang, ignoreIllegals: true }).value
|
||||
return `<div class="code-block-wrapper">
|
||||
<div class="code-header">
|
||||
<span class="code-lang">${lang}</span>
|
||||
${copyBtn}
|
||||
</div>
|
||||
<pre class="code-block"><code class="language-${lang} hljs">${highlighted}</code></pre>
|
||||
</div>`
|
||||
} catch (e) {
|
||||
console.warn('代码高亮失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const escaped = md.utils.escapeHtml(str)
|
||||
return `<div class="code-block-wrapper">
|
||||
<div class="code-header">
|
||||
${copyBtn}
|
||||
</div>
|
||||
<pre class="code-block"><code>${escaped}</code></pre>
|
||||
</div>`
|
||||
},
|
||||
})
|
||||
md.use(mathjaxPlugin)
|
||||
md.use(emoji)
|
||||
|
||||
// 渲染 Markdown
|
||||
const renderMarkdown = (text) => {
|
||||
if (!text) return ''
|
||||
// 使用 processContent 处理内容(处理图片链接、think 标签等)
|
||||
const processed = processContent(String(text))
|
||||
return md.render(processed)
|
||||
}
|
||||
|
||||
// 获取消息内容
|
||||
const getMessageContent = (message) => {
|
||||
if (message.type === 'mj') {
|
||||
try {
|
||||
const content = typeof message.content === 'string'
|
||||
? JSON.parse(message.content)
|
||||
: message.content
|
||||
return content?.content || content?.text || ''
|
||||
} catch (e) {
|
||||
return message.content?.text || message.content || ''
|
||||
}
|
||||
}
|
||||
return message.content?.text || message.content || ''
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timestamp) => {
|
||||
if (!timestamp) return ''
|
||||
const date = new Date(timestamp * 1000)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
// 格式化文件大小
|
||||
const formatFileSize = (bytes) => {
|
||||
if (!bytes) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
// 判断是否为图片文件
|
||||
const isImageFile = (ext) => {
|
||||
if (!ext) return false
|
||||
// 使用项目中的 isImage 函数
|
||||
return isImage('.' + ext)
|
||||
}
|
||||
|
||||
// 复制消息
|
||||
const copyMessage = async (text) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
ElMessage.success('复制成功!')
|
||||
} catch (e) {
|
||||
ElMessage.error('复制失败:' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 高亮代码块
|
||||
const highlightCodeBlocks = () => {
|
||||
nextTick(() => {
|
||||
try {
|
||||
const messagesList = document.querySelector('#messages-list')
|
||||
if (!messagesList) return
|
||||
|
||||
hl.configure({ ignoreUnescapedHTML: true })
|
||||
const blocks = messagesList.querySelectorAll('pre code')
|
||||
blocks.forEach((block) => {
|
||||
if (!block.classList.contains('hljs')) {
|
||||
try {
|
||||
hl.highlightElement(block)
|
||||
} catch (e) {
|
||||
console.warn('代码高亮失败:', e)
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('初始化代码高亮失败:', e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 加载聊天历史
|
||||
const loadChatHistory = async () => {
|
||||
if (!chatId) {
|
||||
error.value = '缺少聊天ID参数'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await httpGet('/api/chat/history?chat_id=' + chatId)
|
||||
const data = res.data
|
||||
if (!data) {
|
||||
|
||||
if (!data || !Array.isArray(data)) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (data[i].type === 'prompt') {
|
||||
chatData.value.push(data[i])
|
||||
continue
|
||||
} else if (data[i].type === 'mj') {
|
||||
data[i].content = JSON.parse(data[i].content)
|
||||
data[i].content.content = data[i].content?.content
|
||||
chatData.value.push(data[i])
|
||||
continue
|
||||
chatData.value = data.map((item) => {
|
||||
// 处理 MJ 类型消息
|
||||
if (item.type === 'mj' && typeof item.content === 'string') {
|
||||
try {
|
||||
item.content = JSON.parse(item.content)
|
||||
} catch (e) {
|
||||
console.warn('解析 MJ 内容失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
data[i].orgContent = data[i].content
|
||||
data[i].content = data[i].content
|
||||
chatData.value.push(data[i])
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
hl.configure({ ignoreUnescapedHTML: true })
|
||||
const blocks = document.querySelector('#chat-box').querySelectorAll('pre code')
|
||||
blocks.forEach((block) => {
|
||||
hl.highlightElement(block)
|
||||
})
|
||||
return item
|
||||
})
|
||||
loading.value = false
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('加载聊天记录失败:' + e.message)
|
||||
})
|
||||
|
||||
httpGet('/api/chat/detail?chat_id=' + chatId)
|
||||
.then((res) => {
|
||||
chatTitle.value = res.data.title
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('加载会失败: ' + e.message)
|
||||
})
|
||||
highlightCodeBlocks()
|
||||
loading.value = false
|
||||
} catch (e) {
|
||||
console.error('加载聊天记录失败:', e)
|
||||
error.value = '加载聊天记录失败:' + (e.message || '未知错误')
|
||||
ElMessage.error(error.value)
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载聊天详情
|
||||
const loadChatDetail = async () => {
|
||||
if (!chatId) return
|
||||
|
||||
try {
|
||||
const res = await httpGet('/api/chat/detail?chat_id=' + chatId)
|
||||
if (res.data) {
|
||||
chatTitle.value = res.data.title || '聊天记录导出'
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('加载聊天详情失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
const init = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
exportDate.value = new Date().toLocaleString('zh-CN')
|
||||
|
||||
await Promise.all([loadChatHistory(), loadChatDetail()])
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const clipboard = new Clipboard('.copy-reply')
|
||||
clipboard.on('success', () => {
|
||||
ElMessage.success('复制成功!')
|
||||
})
|
||||
|
||||
clipboard.on('error', () => {
|
||||
ElMessage.error('复制失败!')
|
||||
})
|
||||
init()
|
||||
})
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.chat-export {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chat-export-page {
|
||||
min-height: 100vh;
|
||||
background: var(--theme-bg-color);
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0 20px;
|
||||
align-items: flex-start;
|
||||
|
||||
.chat-box {
|
||||
.export-container {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
// 变量定义
|
||||
--content-font-size: 16px;
|
||||
--content-color: #c1c1c1;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
background: var(--chat-bg, #ffffff);
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
min-height: 100vh;
|
||||
|
||||
font-family: 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
|
||||
padding: 0 0 50px 0;
|
||||
// 页面头部
|
||||
.export-header {
|
||||
background: var(--theme-bg-color);
|
||||
border-bottom: 1px solid var(--el-border-color);
|
||||
padding: 2rem 2rem 1.5rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(10px);
|
||||
background: var(--chat-bg, #ffffff);
|
||||
|
||||
.title {
|
||||
text-align: center;
|
||||
.header-content {
|
||||
.export-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--theme-text-color-primary);
|
||||
margin: 0 0 0.75rem 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.header-meta {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--theme-text-color-secondary);
|
||||
|
||||
i {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-line {
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
// 内容区域
|
||||
.export-content {
|
||||
padding: 2rem;
|
||||
|
||||
.chat-line-inner {
|
||||
max-width: 800px;
|
||||
.messages-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
|
||||
.message-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.message-user {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
&.message-assistant {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
max-width: 100%;
|
||||
min-width: 100%;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.message-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--theme-bg-color);
|
||||
border-bottom: 1px solid var(--el-border-color);
|
||||
|
||||
.message-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: var(--el-border-color);
|
||||
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.message-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex: 1;
|
||||
|
||||
.message-role {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--theme-text-color-primary);
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 0.75rem;
|
||||
color: var(--theme-text-color-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-body {
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--chat-content-bg, #f5f7fc);
|
||||
position: relative;
|
||||
|
||||
.message-files {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.file-item {
|
||||
.file-image {
|
||||
max-width: 100%;
|
||||
max-height: 300px;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.file-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--theme-bg-color);
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
|
||||
i {
|
||||
font-size: 1.5rem;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.file-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.file-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--theme-text-color-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.file-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--theme-text-color-secondary);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-text {
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
color: var(--theme-text-color-primary);
|
||||
word-break: break-word;
|
||||
|
||||
:deep(p) {
|
||||
margin: 0.75rem 0;
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(ul),
|
||||
:deep(ol) {
|
||||
margin: 0.75rem 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
:deep(li) {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
:deep(blockquote) {
|
||||
margin: 1rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--quote-bg-color, #f0f0f0);
|
||||
border-left: 3px solid var(--el-color-primary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
:deep(table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1rem 0;
|
||||
font-size: 0.875rem;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--el-border-color);
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--theme-bg-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
:deep(a) {
|
||||
color: var(--el-color-primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.code-block-wrapper) {
|
||||
margin: 1rem 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #2b2b2b;
|
||||
border: 1px solid var(--el-border-color);
|
||||
|
||||
.code-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #1e1e1e;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
|
||||
.code-lang {
|
||||
font-size: 0.75rem;
|
||||
color: #a0a0a0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.code-copy-btn {
|
||||
background: transparent;
|
||||
border: 1px solid #555;
|
||||
color: #ccc;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: #3a3a3a;
|
||||
border-color: #777;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.code-block {
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
background: #2b2b2b;
|
||||
|
||||
code {
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--el-border-color);
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--theme-text-color-secondary);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--theme-bg-color);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.message-user .message-bubble {
|
||||
background: var(--chat-user-content-bg, #e0dfff);
|
||||
border: 1px solid rgba(107, 80, 225, 0.2);
|
||||
|
||||
.message-body {
|
||||
background: var(--chat-user-content-bg, #e0dfff);
|
||||
}
|
||||
}
|
||||
|
||||
&.message-assistant .message-bubble {
|
||||
background: var(--chat-content-bg, #f5f7fc);
|
||||
border: 1px solid var(--el-border-color);
|
||||
|
||||
.message-body {
|
||||
background: var(--chat-content-bg, #f5f7fc);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
.error-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
padding: 3rem 2rem;
|
||||
|
||||
.error-content {
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
|
||||
.error-icon {
|
||||
font-size: 4rem;
|
||||
color: var(--el-color-error);
|
||||
opacity: 0.8;
|
||||
margin-bottom: 1rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--theme-text-color-primary);
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--el-color-error);
|
||||
margin: 0 0 1.5rem 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
padding: 0.625rem 1.5rem;
|
||||
background: var(--el-color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9375rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 空状态
|
||||
.empty-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
padding: 3rem 2rem;
|
||||
|
||||
.empty-content {
|
||||
text-align: center;
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
color: var(--theme-text-color-secondary);
|
||||
opacity: 0.5;
|
||||
margin-bottom: 1rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--theme-text-color-primary);
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--theme-text-color-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.chat-export {
|
||||
padding: 0 10px;
|
||||
.chat-export-page {
|
||||
.export-container {
|
||||
.export-header {
|
||||
padding: 1.5rem 1rem 1rem;
|
||||
|
||||
.chat-box {
|
||||
padding: 0 0 30px 0;
|
||||
.header-content {
|
||||
.export-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.title h2 {
|
||||
font-size: 18px;
|
||||
.header-meta {
|
||||
gap: 1rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-line {
|
||||
font-size: 13px;
|
||||
.export-content {
|
||||
padding: 1.5rem 1rem;
|
||||
|
||||
.messages-list {
|
||||
gap: 1.5rem;
|
||||
|
||||
.message-item {
|
||||
.message-bubble {
|
||||
//max-width: 95%;
|
||||
|
||||
.message-header {
|
||||
padding: 0.625rem 0.875rem;
|
||||
|
||||
.message-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.message-body {
|
||||
padding: 0.875rem 1rem;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.chat-export-page {
|
||||
.export-container {
|
||||
.export-header {
|
||||
padding: 1rem 0.75rem 0.75rem;
|
||||
|
||||
.header-content {
|
||||
.export-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.export-content {
|
||||
padding: 1rem 0.75rem;
|
||||
|
||||
.messages-list {
|
||||
gap: 1.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+529
-291
File diff suppressed because it is too large
Load Diff
@@ -8,11 +8,8 @@
|
||||
<el-radio-button value="mj"
|
||||
><i class="iconfont icon-mj mr-1"></i>MidJourney</el-radio-button
|
||||
>
|
||||
<el-radio-button value="sd"
|
||||
><i class="iconfont icon-sd mr-1"></i>StableDiffusion</el-radio-button
|
||||
>
|
||||
<el-radio-button value="dall"
|
||||
><i class="iconfont icon-dalle mr-1"></i>Dalle</el-radio-button
|
||||
<el-radio-button value="image"
|
||||
><i class="iconfont icon-dalle mr-1"></i>AI Image</el-radio-button
|
||||
>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
@@ -81,71 +78,9 @@
|
||||
</Waterfall>
|
||||
|
||||
<Waterfall
|
||||
v-if="imgType === 'sd'"
|
||||
id="waterfall-sd"
|
||||
:list="data['sd']"
|
||||
:row-key="waterfallOptions.rowKey"
|
||||
:gutter="waterfallOptions.gutter"
|
||||
:has-around-gutter="waterfallOptions.hasAroundGutter"
|
||||
:width="waterfallOptions.width"
|
||||
:breakpoints="waterfallOptions.breakpoints"
|
||||
:img-selector="waterfallOptions.imgSelector"
|
||||
:background-color="waterfallOptions.backgroundColor"
|
||||
:animation-effect="waterfallOptions.animationEffect"
|
||||
:animation-duration="waterfallOptions.animationDuration"
|
||||
:animation-delay="waterfallOptions.animationDelay"
|
||||
:animation-cancel="waterfallOptions.animationCancel"
|
||||
:lazyload="waterfallOptions.lazyload"
|
||||
:load-props="waterfallOptions.loadProps"
|
||||
:cross-origin="waterfallOptions.crossOrigin"
|
||||
:align="waterfallOptions.align"
|
||||
:is-loading="loading"
|
||||
:is-over="isOver"
|
||||
@afterRender="loading = false"
|
||||
>
|
||||
<template #default="{ item, url }">
|
||||
<div
|
||||
class="bg-gray-900 rounded-lg shadow-md overflow-hidden transition-all duration-300 ease-linear hover:shadow-md hover:shadow-purple-800 group"
|
||||
>
|
||||
<div class="overflow-hidden rounded-lg">
|
||||
<LazyImg
|
||||
:url="url"
|
||||
class="cursor-pointer transition-all duration-300 ease-linear group-hover:scale-105"
|
||||
@click="showTask(item)"
|
||||
/>
|
||||
</div>
|
||||
<div class="px-4 pt-2 pb-4 border-t border-t-gray-800">
|
||||
<div
|
||||
class="pt-3 flex justify-center items-center border-t border-t-gray-600 border-opacity-50"
|
||||
>
|
||||
<div class="opt">
|
||||
<el-tooltip class="box-item" content="复制提示词" placement="top">
|
||||
<el-button
|
||||
type="info"
|
||||
circle
|
||||
class="copy-prompt-wall"
|
||||
:data-clipboard-text="item.prompt"
|
||||
>
|
||||
<i class="iconfont icon-file"></i>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
|
||||
<el-tooltip class="box-item" content="画同款" placement="top">
|
||||
<el-button type="primary" circle @click="drawSameSd(item)">
|
||||
<i class="iconfont icon-palette"></i>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Waterfall>
|
||||
|
||||
<Waterfall
|
||||
v-if="imgType === 'dall'"
|
||||
id="waterfall-dall"
|
||||
:list="data['dall']"
|
||||
v-if="imgType === 'image'"
|
||||
id="waterfall-image"
|
||||
:list="data['image']"
|
||||
:row-key="waterfallOptions.rowKey"
|
||||
:gutter="waterfallOptions.gutter"
|
||||
:has-around-gutter="waterfallOptions.hasAroundGutter"
|
||||
@@ -223,14 +158,6 @@
|
||||
</div>
|
||||
<!-- end of waterfall -->
|
||||
</div>
|
||||
<!-- 任务详情弹框 -->
|
||||
<sd-task-view
|
||||
v-model="showTaskDialog"
|
||||
:data="item"
|
||||
@drawSame="drawSameSd"
|
||||
@close="showTaskDialog = false"
|
||||
/>
|
||||
|
||||
<!-- 图片预览 -->
|
||||
<el-image-viewer
|
||||
@close="
|
||||
@@ -246,9 +173,9 @@
|
||||
|
||||
<script setup>
|
||||
import BackTop from '@/components/BackTop.vue'
|
||||
import SdTaskView from '@/components/SdTaskView.vue'
|
||||
import { useSharedStore } from '@/store/sharedata'
|
||||
import { httpGet } from '@/utils/http'
|
||||
import { getThumbURL } from '@/utils/libs'
|
||||
import Clipboard from 'clipboard'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
@@ -261,15 +188,12 @@ const waterfallOptions = store.waterfallOptions
|
||||
|
||||
const data = ref({
|
||||
mj: [],
|
||||
sd: [],
|
||||
dall: [],
|
||||
image: [],
|
||||
})
|
||||
const loading = ref(true)
|
||||
const isOver = ref(false)
|
||||
const imgType = ref('mj') // 图片类别
|
||||
const listBoxHeight = window.innerHeight - 124
|
||||
const showTaskDialog = ref(false)
|
||||
const item = ref({})
|
||||
const previewURL = ref('')
|
||||
|
||||
const previewImg = (item) => {
|
||||
@@ -291,11 +215,8 @@ const getNext = () => {
|
||||
case 'mj':
|
||||
url = '/api/mj/imgWall'
|
||||
break
|
||||
case 'sd':
|
||||
url = '/api/sd/imgWall'
|
||||
break
|
||||
case 'dall':
|
||||
url = '/api/dall/imgWall'
|
||||
case 'image':
|
||||
url = '/api/image/imgWall'
|
||||
break
|
||||
}
|
||||
httpGet(`${url}?page=${page.value}&page_size=${pageSize.value}`)
|
||||
@@ -309,7 +230,7 @@ const getNext = () => {
|
||||
// 生成缩略图
|
||||
const imageList = res.data.items
|
||||
for (let i = 0; i < imageList.length; i++) {
|
||||
imageList[i]['img_thumb'] = imageList[i]['img_url'] + '?imageView2/4/w/300/h/0/q/75'
|
||||
imageList[i]['img_thumb'] = getThumbURL(imageList[i]['img_url'], 300, 0)
|
||||
}
|
||||
if (data.value[imgType.value].length === 0) {
|
||||
data.value[imgType.value] = imageList
|
||||
@@ -351,27 +272,14 @@ const changeImgType = () => {
|
||||
page.value = 0
|
||||
data.value = {
|
||||
mj: [],
|
||||
sd: [],
|
||||
dall: [],
|
||||
image: [],
|
||||
}
|
||||
loading.value = true
|
||||
isOver.value = false
|
||||
nextTick(() => getNext())
|
||||
}
|
||||
|
||||
const showTask = (row) => {
|
||||
item.value = row
|
||||
showTaskDialog.value = true
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const drawSameSd = (row) => {
|
||||
router.push({
|
||||
name: 'image-sd',
|
||||
params: { copyParams: JSON.stringify(row.params) },
|
||||
})
|
||||
}
|
||||
|
||||
const drawSameMj = (row) => {
|
||||
router.push({ name: 'image-mj', params: { prompt: row.prompt } })
|
||||
}
|
||||
+117
-36
@@ -1,19 +1,6 @@
|
||||
<template>
|
||||
<div class="layout">
|
||||
<div class="tab-box">
|
||||
<!-- <div class="flex-center-col pt-2 mb-2">
|
||||
<div class="flex flex-center-col">
|
||||
<div class="menuIcon" @click="store.setChatListExtend(!store.chatListExtend)">
|
||||
<el-tooltip content="隐藏对话列表" placement="right" v-if="store.chatListExtend">
|
||||
<i class="iconfont icon-colspan"></i>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="展开对话列表" placement="right" v-else>
|
||||
<i class="iconfont icon-expand"></i>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<div class="menu-list pt-2">
|
||||
<ul>
|
||||
<li
|
||||
@@ -66,27 +53,51 @@
|
||||
</ul>
|
||||
</template>
|
||||
</el-popover>
|
||||
|
||||
<el-popover placement="right-end" trigger="hover" v-if="loginUser.id">
|
||||
<template #reference>
|
||||
<li class="menu-list-item flex-center-col">
|
||||
<i class="iconfont icon-user-circle" />
|
||||
<el-avatar
|
||||
v-if="loginUser.avatar"
|
||||
:src="loginUser.avatar"
|
||||
shape="circle"
|
||||
:size="32"
|
||||
class="user-avatar"
|
||||
/>
|
||||
<i v-else class="iconfont icon-user-circle" />
|
||||
</li>
|
||||
</template>
|
||||
<template #default>
|
||||
<ul class="more-menus setting-menus">
|
||||
<li>
|
||||
<div @click="showConfigDialog = true" class="flex">
|
||||
<el-icon>
|
||||
<UserFilled />
|
||||
</el-icon>
|
||||
<span class="username title">账户信息</span>
|
||||
<div @click="showPowerLogDialog = true" class="flex">
|
||||
<i class="iconfont icon-list"></i>
|
||||
<span class="title">算力日志</span>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<a :href="githubURL" target="_blank" class="flex">
|
||||
<i class="iconfont icon-github"></i>
|
||||
<span class="title">项目源码</span>
|
||||
</a>
|
||||
<div @click="showMemberDialog = true" class="flex">
|
||||
<i class="iconfont icon-config"></i>
|
||||
<span class="title">用户设置</span>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div @click="showInvitationDialog = true" class="flex">
|
||||
<i class="iconfont icon-share"></i>
|
||||
<span class="title">推广计划</span>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<a :href="githubURL" target="_blank" class="flex">
|
||||
<i class="iconfont icon-github"></i>
|
||||
<span class="title">项目源码</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://docs.geekai.me" target="_blank" class="flex">
|
||||
<i class="iconfont icon-book"></i>
|
||||
<span class="title">项目文档</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a @click="logout" class="flex">
|
||||
@@ -114,14 +125,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<el-scrollbar class="right-main">
|
||||
<!-- <div class="topheader" v-if="loginUser.id === undefined || !loginUser.id">
|
||||
<el-button
|
||||
@click="router.push('/login')"
|
||||
class="btn-go animate__animated animate__pulse animate__infinite"
|
||||
round
|
||||
>登录</el-button
|
||||
>
|
||||
</div> -->
|
||||
<div class="content custom-scroll">
|
||||
<router-view :key="routerViewKey" v-slot="{ Component }">
|
||||
<transition name="move" mode="out-in">
|
||||
@@ -129,9 +132,51 @@
|
||||
</transition>
|
||||
</router-view>
|
||||
</div>
|
||||
<!-- </div> -->
|
||||
</el-scrollbar>
|
||||
<config-dialog v-if="loginUser.id" :show="showConfigDialog" @hide="showConfigDialog = false" />
|
||||
<!-- 算力日志弹窗 -->
|
||||
<el-dialog
|
||||
v-model="showPowerLogDialog"
|
||||
title="算力日志"
|
||||
width="90%"
|
||||
:close-on-click-modal="true"
|
||||
:close-on-press-escape="true"
|
||||
style="max-width: 1200px"
|
||||
@close="showPowerLogDialog = false"
|
||||
>
|
||||
<div class="powerlog-dialog-content">
|
||||
<PowerLog />
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 用户设置弹窗 -->
|
||||
<el-dialog
|
||||
v-model="showMemberDialog"
|
||||
title="用户设置"
|
||||
width="90%"
|
||||
:close-on-click-modal="true"
|
||||
:close-on-press-escape="true"
|
||||
style="max-width: 1400px"
|
||||
@close="showMemberDialog = false"
|
||||
>
|
||||
<div class="member-dialog-content">
|
||||
<Member />
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 推广计划弹窗 -->
|
||||
<el-dialog
|
||||
v-model="showInvitationDialog"
|
||||
title="推广计划"
|
||||
width="90%"
|
||||
:close-on-click-modal="true"
|
||||
:close-on-press-escape="true"
|
||||
style="max-width: 1200px"
|
||||
@close="showInvitationDialog = false"
|
||||
>
|
||||
<div class="invitation-dialog-content">
|
||||
<Invitation />
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="showLoginDialog" width="500px" @close="store.setShowLoginDialog(false)">
|
||||
<template #header>
|
||||
@@ -149,12 +194,13 @@
|
||||
<script setup>
|
||||
import LoginDialog from '@/components/LoginDialog.vue'
|
||||
import ThemeChange from '@/components/ThemeChange.vue'
|
||||
import ConfigDialog from '@/components/UserInfoDialog.vue'
|
||||
import PowerLog from '@/views/PowerLog.vue'
|
||||
import Member from '@/views/Member.vue'
|
||||
import Invitation from '@/views/Invitation.vue'
|
||||
import { checkSession, getSystemInfo } from '@/store/cache'
|
||||
import { removeUserToken } from '@/store/session'
|
||||
import { useSharedStore } from '@/store/sharedata'
|
||||
import { httpGet } from '@/utils/http'
|
||||
import { UserFilled } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
@@ -169,7 +215,9 @@ const title = ref('')
|
||||
const store = useSharedStore()
|
||||
const loginUser = ref({})
|
||||
const routerViewKey = ref(0)
|
||||
const showConfigDialog = ref(false)
|
||||
const showPowerLogDialog = ref(false)
|
||||
const showMemberDialog = ref(false)
|
||||
const showInvitationDialog = ref(false)
|
||||
const showLoginDialog = ref(false)
|
||||
const githubURL = ref(import.meta.env.VITE_GITHUB_URL)
|
||||
|
||||
@@ -294,3 +342,36 @@ const loginSuccess = () => {
|
||||
@use '../assets/css/custom-scroll.scss' as *;
|
||||
@use '../assets/css/home.scss' as *;
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.powerlog-dialog-content,
|
||||
.member-dialog-content,
|
||||
.invitation-dialog-content {
|
||||
max-height: calc(100vh - 150px);
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.powerlog-dialog-content {
|
||||
.power-log {
|
||||
.inner {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.member-dialog-content {
|
||||
.member-page {
|
||||
min-height: auto;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.invitation-dialog-content {
|
||||
.page-invitation {
|
||||
.inner {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,75 +1,104 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-dall">
|
||||
<div class="page-image">
|
||||
<div class="inner custom-scroll">
|
||||
<div class="sd-box">
|
||||
<div class="image-box">
|
||||
<h2 class="!text-[#252f76] py-3">AI图像生成</h2>
|
||||
|
||||
<div class="sd-params">
|
||||
<el-form :model="params" label-width="80px" label-position="left">
|
||||
<el-form :model="params" label-width="80px" label-position="top">
|
||||
<div class="param-line pt-1">
|
||||
<el-form-item label="生图模型">
|
||||
<template #default>
|
||||
<div class="form-item-inner">
|
||||
<el-select
|
||||
v-model="selectedModel"
|
||||
style="width: 150px"
|
||||
placeholder="请选择模型"
|
||||
@change="changeModel"
|
||||
>
|
||||
<el-option v-for="v in models" :label="v.name" :value="v" :key="v.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-select
|
||||
v-model="selectedModel"
|
||||
placeholder="请选择模型"
|
||||
@change="changeModel"
|
||||
>
|
||||
<el-option v-for="v in models" :label="v.name" :value="v" :key="v.value" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="param-line">
|
||||
<el-form-item label="图片比例">
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<span>图片比例</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="form-item-inner">
|
||||
<el-select
|
||||
v-model="params.aspect_ratio"
|
||||
style="width: 150px"
|
||||
value-key="value"
|
||||
@change="changeAspectRatio"
|
||||
<div class="form-item-inner flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="item in radioAspectList"
|
||||
:key="item.value"
|
||||
class="w-[56px] h-[66px] rounded-lg border cursor-pointer flex flex-col items-center justify-center bg-white transition-all duration-200"
|
||||
:class="
|
||||
selectedAspect === item.value
|
||||
? 'border-[#6366f1] bg-[#eef2ff]'
|
||||
: 'border-[#e5e7f5]'
|
||||
"
|
||||
@click="changeAspect(item.value)"
|
||||
>
|
||||
<el-option
|
||||
v-for="v in radioAspects"
|
||||
:label="v.label"
|
||||
:value="v.value"
|
||||
:key="v.value"
|
||||
>
|
||||
<i class="iconfont mr-1" :class="v.icon"></i>
|
||||
{{ v.label }}
|
||||
</el-option>
|
||||
</el-select>
|
||||
<div class="flex items-center justify-center h-[28px]">
|
||||
<i class="iconfont mr-1" :class="item.icon"></i>
|
||||
</div>
|
||||
<div class="text-xs font-semibold text-[#252f76] leading-tight">
|
||||
{{ item.value }}
|
||||
</div>
|
||||
<div class="text-[11px] text-gray-400 leading-tight line-clamp-1">
|
||||
{{ item.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="param-line">
|
||||
<el-input
|
||||
v-model="params.prompt"
|
||||
:autosize="{ minRows: 4, maxRows: 6 }"
|
||||
type="textarea"
|
||||
ref="promptRef"
|
||||
maxlength="1024"
|
||||
show-word-limit
|
||||
placeholder="请在此输入绘画提示词,您也可以点击下面的提示词助手生成绘画提示词"
|
||||
v-loading="promptGenerating"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-2 pr-2">
|
||||
<el-button @click="generatePrompt" type="primary" :loading="promptGenerating">
|
||||
<span v-if="!promptGenerating">
|
||||
<i class="iconfont icon-chuangzuo"></i>
|
||||
生成专业绘画指令
|
||||
</span>
|
||||
<span v-else>生成中...</span>
|
||||
</el-button>
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<span>图片尺寸</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="form-item-inner flex flex-col gap-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="item in sizeLevels"
|
||||
:key="item.value"
|
||||
class="w-[72px] h-[50px] rounded-lg border cursor-pointer flex flex-col justify-center items-center bg-white transition-all duration-200"
|
||||
:class="
|
||||
sizeLevel === item.value
|
||||
? 'border-[#6366f1] bg-[#eef2ff]'
|
||||
: 'border-[#e5e7f5]'
|
||||
"
|
||||
@click="changeSizeLevel(item.value)"
|
||||
>
|
||||
<template v-if="item.value === 'other'">
|
||||
<div class="text-[16px] font-semibold !text-purple-600">
|
||||
{{ item.label }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="text-[16px] font-semibold !text-purple-600 h-[20px]">
|
||||
{{ item.label }}
|
||||
</div>
|
||||
<div class="text-[11px] text-gray-400">{{ item.desc }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<el-input
|
||||
v-if="sizeLevel === 'other'"
|
||||
v-model="params.size"
|
||||
placeholder="宽×高,如 1024x1024"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 mb-2">
|
||||
@@ -83,6 +112,30 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="param-line prompt-input-wrap">
|
||||
<el-form-item label="绘图提示词">
|
||||
<ReferenceInput
|
||||
v-model="params.prompt"
|
||||
:image-list="params.image || []"
|
||||
:autosize="{ minRows: 4, maxRows: 6 }"
|
||||
:maxlength="1024"
|
||||
:show-word-limit="true"
|
||||
:loading="promptGenerating"
|
||||
placeholder="请在此输入绘画提示词,输入 @ 可引用参考图(图1、图2...)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-2 pr-2">
|
||||
<el-button @click="generatePrompt" type="primary" :loading="promptGenerating">
|
||||
<span v-if="!promptGenerating">
|
||||
<i class="iconfont icon-chuangzuo"></i>
|
||||
生成专业绘画指令
|
||||
</span>
|
||||
<span v-else>生成中...</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="py-4">
|
||||
@@ -94,7 +147,7 @@
|
||||
<i v-if="isGenerating" class="iconfont icon-loading animate-spin"></i>
|
||||
<i v-else class="iconfont icon-chuangzuo"></i>
|
||||
<span v-if="isGenerating">创作中...</span>
|
||||
<span v-else>立即生成({{ dallPower }}算力)</span>
|
||||
<span v-else>立即生成({{ imagePower }}算力)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -192,14 +245,9 @@
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
|
||||
<el-tooltip content="复制提示词" placement="top">
|
||||
<el-button
|
||||
type="info"
|
||||
circle
|
||||
class="copy-prompt"
|
||||
:data-clipboard-text="item.prompt"
|
||||
>
|
||||
<i class="iconfont icon-file"></i>
|
||||
<el-tooltip content="详情" placement="top">
|
||||
<el-button type="info" circle @click="showDetail(item)">
|
||||
<i class="iconfont icon-info"></i>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="删除" placement="top">
|
||||
@@ -210,6 +258,16 @@
|
||||
circle
|
||||
/>
|
||||
</el-tooltip>
|
||||
|
||||
<el-tooltip content="下载" placement="top">
|
||||
<el-button
|
||||
type="primary"
|
||||
circle
|
||||
:icon="Download"
|
||||
@click="downloadImage(item)"
|
||||
:loading="item.downloading"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -259,6 +317,86 @@
|
||||
v-if="previewURL !== ''"
|
||||
:url-list="[previewURL]"
|
||||
/>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<el-dialog
|
||||
v-model="detailDialogVisible"
|
||||
title="任务详情"
|
||||
width="600px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div class="detail-content" v-if="currentDetail">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="提示词">
|
||||
<div>
|
||||
<span>{{ currentDetail.prompt }}</span>
|
||||
<el-tooltip content="复制提示词" placement="top">
|
||||
<i
|
||||
class="iconfont icon-copy ml-2 cursor-pointer copy-prompt-detail"
|
||||
:data-clipboard-text="currentDetail.prompt"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item
|
||||
label="生成的图片"
|
||||
v-if="currentDetail.progress === 100 && currentDetail.img_url"
|
||||
>
|
||||
<el-image
|
||||
:src="getThumbURL(currentDetail.img_url, 200, 200)"
|
||||
:preview-src-list="[currentDetail.img_url]"
|
||||
fit="cover"
|
||||
style="width: 200px; height: 200px"
|
||||
/>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item
|
||||
label="参考图"
|
||||
v-if="currentDetail.params?.image && currentDetail.params.image.length > 0"
|
||||
>
|
||||
<div class="reference-images">
|
||||
<el-image
|
||||
v-for="(img, idx) in currentDetail.params.image"
|
||||
:key="idx"
|
||||
:src="getThumbURL(img, 100, 100)"
|
||||
:preview-src-list="currentDetail.params.image"
|
||||
:initial-index="idx"
|
||||
fit="cover"
|
||||
style="width: 100px; height: 100px; margin-right: 10px"
|
||||
/>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="生图模型">
|
||||
{{ currentDetail.params?.model_name || '-' }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="消耗算力">
|
||||
{{ currentDetail.power || 0 }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="图片比例">
|
||||
{{ currentDetail.params?.aspect_ratio || '-' }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="图片尺寸">
|
||||
{{ currentDetail.params?.size || '-' }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="创建时间">
|
||||
{{ dateFormat(currentDetail.created_at) }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item
|
||||
label="错误信息"
|
||||
v-if="currentDetail.progress === 101 && currentDetail.err_msg"
|
||||
>
|
||||
<el-text type="danger">{{ currentDetail.err_msg }}</el-text>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -267,20 +405,21 @@ import nodata from '@/assets/img/no-data.png'
|
||||
|
||||
import BackTop from '@/components/BackTop.vue'
|
||||
import ImageUpload from '@/components/ImageUpload.vue'
|
||||
import ReferenceInput from '@/components/ReferenceInput.vue'
|
||||
import TaskList from '@/components/TaskList.vue'
|
||||
import { checkSession } from '@/store/cache'
|
||||
import { useSharedStore } from '@/store/sharedata'
|
||||
import { showMessageError, showMessageOK } from '@/utils/dialog'
|
||||
import { httpGet, httpPost } from '@/utils/http'
|
||||
import { Delete } from '@element-plus/icons-vue'
|
||||
import { showMessageError } from '@/utils/dialog'
|
||||
import { downloadFile, httpGet, httpPost } from '@/utils/http'
|
||||
import { dateFormat, getThumbURL } from '@/utils/libs'
|
||||
import Clipboard from 'clipboard'
|
||||
import { Delete, Download, List } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { LazyImg, Waterfall } from 'vue-waterfall-plugin-next'
|
||||
import 'vue-waterfall-plugin-next/dist/style.css'
|
||||
|
||||
const listBoxHeight = ref(0)
|
||||
// const paramBoxHeight = ref(0)
|
||||
const isLogin = ref(false)
|
||||
const loading = ref(true)
|
||||
const isOver = ref(false)
|
||||
@@ -299,48 +438,33 @@ window.onresize = () => {
|
||||
|
||||
// 为了兼容 size 和 aspect_ratio 参数,label 对应的是 aspect_ratio 的值,size 是预估值
|
||||
const radioAspects = ref({
|
||||
'1:1': { value: '1:1', size: '1024x1024', label: '1:1(正方形,头像)', icon: 'icon-aspect_1_1' },
|
||||
'2:3': {
|
||||
value: '2:3',
|
||||
size: '512x768',
|
||||
label: '2:3(社交媒体,自拍)',
|
||||
icon: 'icon-aspect_2_3',
|
||||
},
|
||||
'4:3': {
|
||||
value: '4:3',
|
||||
size: '1024x768',
|
||||
label: '4:3(文章配图,插画)',
|
||||
icon: 'icon-aspect_4_3',
|
||||
},
|
||||
'9:16': {
|
||||
value: '9:16',
|
||||
size: '768x1366',
|
||||
label: '9:16(手机壁纸,人像)',
|
||||
icon: 'icon-aspect_9_16',
|
||||
},
|
||||
'16:9': {
|
||||
value: '16:9',
|
||||
size: '1366x768',
|
||||
label: '16:9(桌面壁纸,风景)',
|
||||
icon: 'icon-aspect_16_9',
|
||||
},
|
||||
'3:2': { value: '3:2', size: '768x512', label: '3:2(1248x832)', icon: 'icon-aspect_3_2' },
|
||||
'3:4': { value: '3:4', size: '960x1280', label: '3:4(864x1152)', icon: 'icon-aspect_3_4' },
|
||||
'4:5': { value: '4:5', size: '960x1280', label: '4:5(896x1120)', icon: 'icon-aspect_4_5' },
|
||||
'5:4': {
|
||||
value: '5:4',
|
||||
size: '1280x960',
|
||||
label: '5:4(1120x896)',
|
||||
icon: 'icon-aspect_5_4',
|
||||
},
|
||||
'21:9': { value: '21:9', size: '1344x576', label: '21:9(1536x658)', icon: 'icon-aspect_16_9' },
|
||||
'1:1': { value: '1:1', label: '方形', icon: 'icon-aspect_1_1' },
|
||||
'4:3': { value: '4:3', label: '横向', icon: 'icon-aspect_4_3' },
|
||||
'3:4': { value: '3:4', label: '竖向', icon: 'icon-aspect_3_4' },
|
||||
'16:9': { value: '16:9', label: '宽屏', icon: 'icon-aspect_16_9' },
|
||||
'9:16': { value: '9:16', label: '竖屏', icon: 'icon-aspect_9_16' },
|
||||
'3:2': { value: '3:2', label: '横向', icon: 'icon-aspect_3_2' },
|
||||
'2:3': { value: '2:3', label: '竖向', icon: 'icon-aspect_2_3' },
|
||||
'4:5': { value: '4:5', label: '竖向', icon: 'icon-aspect_4_5' },
|
||||
'5:4': { value: '5:4', label: '横向', icon: 'icon-aspect_5_4' },
|
||||
'21:9': { value: '21:9', label: '超宽', icon: 'icon-aspect_16_9' },
|
||||
})
|
||||
const radioAspectList = computed(() => Object.values(radioAspects.value))
|
||||
const params = ref({
|
||||
aspect_ratio: '1:1',
|
||||
size: '1024x1024',
|
||||
size: '1K',
|
||||
prompt: '',
|
||||
})
|
||||
|
||||
const selectedAspect = ref(params.value.aspect_ratio || '1:1')
|
||||
const sizeLevel = ref('1K')
|
||||
const sizeLevels = [
|
||||
{ value: '1K', label: '1K', desc: '≈1024px' },
|
||||
{ value: '2K', label: '2K', desc: '≈2048px' },
|
||||
{ value: '4K', label: '4K', desc: '≈4096px' },
|
||||
{ value: 'other', label: '其他', desc: '' },
|
||||
]
|
||||
|
||||
const finishedJobs = ref([])
|
||||
const runningJobs = ref([])
|
||||
const allowPulling = ref(true) // 是否允许轮询
|
||||
@@ -348,24 +472,18 @@ const downloadPulling = ref(false) // 下载轮询
|
||||
const tastPullHandler = ref(null)
|
||||
const downloadPullHandler = ref(null)
|
||||
const userPower = ref(0)
|
||||
const dallPower = ref(0)
|
||||
const clipboard = ref(null)
|
||||
const imagePower = ref(0)
|
||||
const userId = ref(0)
|
||||
const selectedModel = ref(null)
|
||||
const detailDialogVisible = ref(false)
|
||||
const currentDetail = ref(null)
|
||||
const clipboard = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
initData()
|
||||
clipboard.value = new Clipboard('.copy-prompt')
|
||||
clipboard.value.on('success', () => {
|
||||
showMessageOK('复制成功!')
|
||||
})
|
||||
|
||||
clipboard.value.on('error', () => {
|
||||
showMessageError('复制失败!')
|
||||
})
|
||||
|
||||
// 获取模型列表
|
||||
httpGet('/api/dall/models')
|
||||
httpGet('/api/image/models')
|
||||
.then((res) => {
|
||||
models.value = res.data
|
||||
selectedModel.value = models.value[0]
|
||||
@@ -375,21 +493,43 @@ onMounted(() => {
|
||||
.catch((e) => {
|
||||
showMessageError('获取模型列表失败:' + e.message)
|
||||
})
|
||||
|
||||
clipboard.value = new Clipboard('.copy-prompt-detail')
|
||||
clipboard.value.on('success', () => {
|
||||
ElMessage.success('复制成功!')
|
||||
})
|
||||
|
||||
clipboard.value.on('error', () => {
|
||||
ElMessage.error('复制失败!')
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clipboard.value.destroy()
|
||||
if (tastPullHandler.value) {
|
||||
clearInterval(tastPullHandler.value)
|
||||
}
|
||||
if (downloadPullHandler.value) {
|
||||
clearInterval(downloadPullHandler.value)
|
||||
}
|
||||
if (clipboard.value) {
|
||||
clipboard.value.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
// 改变图片比例
|
||||
const changeAspectRatio = (value) => {
|
||||
params.value.size = radioAspects.value[value].size
|
||||
const changeAspect = (value) => {
|
||||
selectedAspect.value = value
|
||||
params.value.aspect_ratio = value
|
||||
}
|
||||
|
||||
const changeSizeLevel = (level) => {
|
||||
sizeLevel.value = level
|
||||
if (level === 'other') {
|
||||
if (['1K', '2K', '4K'].includes(params.value.size)) {
|
||||
params.value.size = ''
|
||||
}
|
||||
return
|
||||
}
|
||||
params.value.size = level
|
||||
}
|
||||
|
||||
const initData = () => {
|
||||
@@ -425,7 +565,7 @@ const fetchRunningJobs = () => {
|
||||
return
|
||||
}
|
||||
// 获取运行中的任务
|
||||
httpGet(`/api/dall/jobs?finish=false`)
|
||||
httpGet(`/api/image/jobs?finish=false`)
|
||||
.then((res) => {
|
||||
// 如果任务有更新,则更新已完成任务列表
|
||||
if (res.data.items && res.data.items.length !== runningJobs.value.length) {
|
||||
@@ -455,7 +595,7 @@ const fetchFinishJobs = () => {
|
||||
loading.value = true
|
||||
page.value = page.value + 1
|
||||
|
||||
httpGet(`/api/dall/jobs?finish=true&page=${page.value}&page_size=${pageSize.value}`)
|
||||
httpGet(`/api/image/jobs?finish=true&page=${page.value}&page_size=${pageSize.value}`)
|
||||
.then((res) => {
|
||||
if (res.data.items.length < pageSize.value) {
|
||||
isOver.value = true
|
||||
@@ -465,7 +605,7 @@ const fetchFinishJobs = () => {
|
||||
let needPulling = false
|
||||
for (let i = 0; i < imageList.length; i++) {
|
||||
if (imageList[i]['img_url']) {
|
||||
imageList[i]['img_thumb'] = imageList[i]['img_url'] + '?imageView2/4/w/300/h/0/q/75'
|
||||
imageList[i]['img_thumb'] = getThumbURL(imageList[i]['img_url'], 300, 0)
|
||||
} else if (imageList[i].progress === 100) {
|
||||
needPulling = true
|
||||
imageList[i]['img_thumb'] = waterfallOptions.loadProps.loading
|
||||
@@ -488,15 +628,12 @@ const fetchFinishJobs = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// 创建绘图任务
|
||||
const promptRef = ref(null)
|
||||
const isGenerating = ref(false)
|
||||
const generate = () => {
|
||||
if (isGenerating.value) {
|
||||
return
|
||||
}
|
||||
if (params.value.prompt === '') {
|
||||
promptRef.value.focus()
|
||||
return ElMessage.error('请输入绘画提示词!')
|
||||
}
|
||||
|
||||
@@ -504,11 +641,29 @@ const generate = () => {
|
||||
store.setShowLoginDialog(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (!params.value.size) {
|
||||
return ElMessage.error('请选择或填写图片尺寸!')
|
||||
}
|
||||
|
||||
if (sizeLevel.value === 'other') {
|
||||
const val = params.value.size.trim()
|
||||
const presetLevels = ['1K', '2K', '4K']
|
||||
if (!val) {
|
||||
return ElMessage.error('请输入自定义图片尺寸!')
|
||||
}
|
||||
if (!presetLevels.includes(val)) {
|
||||
const sizePattern = /^\d+x\d+$/i
|
||||
if (!sizePattern.test(val)) {
|
||||
return ElMessage.error('自定义尺寸格式错误,请输入如 1024x1024')
|
||||
}
|
||||
}
|
||||
}
|
||||
isGenerating.value = true
|
||||
httpPost('/api/dall/image', params.value)
|
||||
httpPost('/api/image/image', params.value)
|
||||
.then(() => {
|
||||
ElMessage.success('任务执行成功!')
|
||||
userPower.value -= dallPower.value
|
||||
userPower.value -= imagePower.value
|
||||
// 追加任务列表
|
||||
runningJobs.value.push({
|
||||
prompt: params.value.prompt,
|
||||
@@ -532,7 +687,7 @@ const removeImage = (item) => {
|
||||
type: 'warning',
|
||||
})
|
||||
.then(() => {
|
||||
httpGet('/api/dall/remove', { id: item.id })
|
||||
httpGet('/api/image/remove', { id: item.id })
|
||||
.then(() => {
|
||||
ElMessage.success('任务删除成功')
|
||||
page.value = 0
|
||||
@@ -556,7 +711,7 @@ const publishImage = (item, action) => {
|
||||
if (action === false) {
|
||||
text = '取消发布'
|
||||
}
|
||||
httpGet('/api/dall/publish', { id: item.id, action: action })
|
||||
httpGet('/api/image/publish', { id: item.id, action: action })
|
||||
.then(() => {
|
||||
ElMessage.success(text + '成功')
|
||||
item.publish = action
|
||||
@@ -586,12 +741,60 @@ const generatePrompt = () => {
|
||||
}
|
||||
|
||||
const changeModel = (model) => {
|
||||
dallPower.value = model.power
|
||||
imagePower.value = model.power
|
||||
params.value.model_id = selectedModel.value.id
|
||||
}
|
||||
|
||||
// 下载图片
|
||||
const downloadImage = (item) => {
|
||||
downloadFile(item, 'img_url')
|
||||
}
|
||||
|
||||
// 显示详情
|
||||
const showDetail = (item) => {
|
||||
// 解析 params 字段
|
||||
let params = {}
|
||||
try {
|
||||
if (item.params) {
|
||||
if (typeof item.params === 'string') {
|
||||
params = JSON.parse(item.params)
|
||||
} else {
|
||||
params = item.params
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析 params 失败:', e)
|
||||
}
|
||||
|
||||
currentDetail.value = {
|
||||
...item,
|
||||
params: params,
|
||||
}
|
||||
detailDialogVisible.value = true
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use '../assets/css/image-dall.scss' as *;
|
||||
@use '../assets/css/image.scss' as *;
|
||||
@use '../assets/css/custom-scroll.scss' as *;
|
||||
|
||||
.detail-content {
|
||||
:deep(.el-descriptions__label) {
|
||||
min-width: 110px;
|
||||
}
|
||||
|
||||
.prompt-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.reference-images {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss"></style>
|
||||
+423
-246
@@ -272,29 +272,7 @@
|
||||
格式图片;
|
||||
</div>
|
||||
<div class="param-line">
|
||||
<div class="img-inline">
|
||||
<div class="img-list-box">
|
||||
<div class="img-item" v-for="imgURL in imgList" :key="imgURL">
|
||||
<el-image :src="imgURL" fit="cover" />
|
||||
<el-button
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
@click="removeUploadImage(imgURL)"
|
||||
circle
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<el-upload
|
||||
class="img-uploader"
|
||||
:auto-upload="true"
|
||||
:show-file-list="false"
|
||||
:http-request="uploadImg"
|
||||
>
|
||||
<el-icon class="uploader-icon">
|
||||
<Plus />
|
||||
</el-icon>
|
||||
</el-upload>
|
||||
</div>
|
||||
<ImageUpload v-model="imgList" :max-count="5" :multiple="true" />
|
||||
</div>
|
||||
|
||||
<div class="param-line" style="padding-top: 10px">
|
||||
@@ -397,55 +375,15 @@
|
||||
<div class="text">
|
||||
请上传两张以上的图片,最多不超过五张,超过五张图片请使用图生图功能
|
||||
</div>
|
||||
<div class="img-inline">
|
||||
<div class="img-list-box">
|
||||
<div class="img-item" v-for="imgURL in imgList" :key="imgURL">
|
||||
<el-image :src="imgURL" fit="cover" />
|
||||
<el-button
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
@click="removeUploadImage(imgURL)"
|
||||
circle
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<el-upload
|
||||
class="img-uploader"
|
||||
:auto-upload="true"
|
||||
:show-file-list="false"
|
||||
:http-request="uploadImg"
|
||||
>
|
||||
<el-icon class="uploader-icon">
|
||||
<Plus />
|
||||
</el-icon>
|
||||
</el-upload>
|
||||
<div class="param-line">
|
||||
<ImageUpload v-model="imgList" :max-count="5" :multiple="true" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="换脸" name="swapFace">
|
||||
<div class="text">请上传两张有脸部的图片,用左边图片的脸替换右边图片的脸</div>
|
||||
<div class="img-inline">
|
||||
<div class="img-list-box">
|
||||
<div class="img-item" v-for="imgURL in imgList" :key="imgURL">
|
||||
<el-image :src="imgURL" fit="cover" />
|
||||
<el-button
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
@click="removeUploadImage(imgURL)"
|
||||
circle
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<el-upload
|
||||
class="img-uploader"
|
||||
:auto-upload="true"
|
||||
:show-file-list="false"
|
||||
:http-request="uploadImg"
|
||||
>
|
||||
<el-icon class="uploader-icon">
|
||||
<Plus />
|
||||
</el-icon>
|
||||
</el-upload>
|
||||
<div class="param-line">
|
||||
<ImageUpload v-model="imgList" :max-count="2" :multiple="true" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
@@ -460,60 +398,23 @@
|
||||
注意:只有于 niji6 和 v6
|
||||
模型支持一致性功能,如果选择其他模型此功能将会生成失败。
|
||||
</div>
|
||||
<div class="param-line">
|
||||
<el-form-item label="角色一致性:" prop="cref">
|
||||
<el-input
|
||||
<div class="param-line cref-two-cols">
|
||||
<div class="cref-col">
|
||||
<label class="cref-label">角色一致性</label>
|
||||
<ImageUpload
|
||||
v-model="params.cref"
|
||||
placeholder="请输入图片URL或者上传图片"
|
||||
style="
|
||||
--el-input-focus-border-color: #b0a0f8;
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
"
|
||||
size="small"
|
||||
>
|
||||
<template #append>
|
||||
<el-upload
|
||||
:auto-upload="true"
|
||||
:show-file-list="false"
|
||||
@click="beforeUpload('cref')"
|
||||
:http-request="uploadImg"
|
||||
>
|
||||
<el-icon class="uploader-icon">
|
||||
<UploadFilled />
|
||||
</el-icon>
|
||||
</el-upload>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="param-line">
|
||||
<el-form-item label="风格一致性:" prop="sref">
|
||||
<el-input
|
||||
:max-count="1"
|
||||
class="cref-upload-inner"
|
||||
/>
|
||||
</div>
|
||||
<div class="cref-col">
|
||||
<label class="cref-label">风格一致性</label>
|
||||
<ImageUpload
|
||||
v-model="params.sref"
|
||||
placeholder="请输入图片URL或者上传图片"
|
||||
style="
|
||||
--el-input-focus-border-color: #b0a0f8;
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
"
|
||||
size="small"
|
||||
>
|
||||
<template #append>
|
||||
<el-upload
|
||||
:auto-upload="true"
|
||||
:show-file-list="false"
|
||||
@click="beforeUpload('sref')"
|
||||
:http-request="uploadImg"
|
||||
>
|
||||
<el-icon class="uploader-icon">
|
||||
<UploadFilled />
|
||||
</el-icon>
|
||||
</el-upload>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
:max-count="1"
|
||||
class="cref-upload-inner"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="param-line" style="padding-top: 10px">
|
||||
@@ -600,16 +501,10 @@
|
||||
</el-tabs>
|
||||
|
||||
<el-row class="text-info">
|
||||
<el-text type="primary"
|
||||
>每次绘图消耗
|
||||
<el-text type="warning">{{ mjPower }}算力;</el-text>
|
||||
U/V 操作消耗<el-text type="warning"
|
||||
>{{ mjActionPower }}算力;</el-text
|
||||
> </el-text
|
||||
>
|
||||
<el-text type="primary"
|
||||
>当前可用算力:<el-text type="warning">{{ power }}</el-text></el-text
|
||||
>
|
||||
<el-text type="primary">
|
||||
绘图 {{ mjPower }} 算力;U/V {{ mjUpscalePower }};融图 {{ mjBlendPower }};换脸 {{ mjSwapFacePower }};局部重绘 {{ mjModalPower }} 算力;
|
||||
当前可用:<el-text type="warning">{{ power }}</el-text>
|
||||
</el-text>
|
||||
</el-row>
|
||||
|
||||
<div class="submit-btn">
|
||||
@@ -695,24 +590,36 @@
|
||||
class="px-4 pt-2 pb-4 border-t border-t-gray-800"
|
||||
v-if="item.progress === 100"
|
||||
>
|
||||
<div class="opt" v-if="item['can_opt']">
|
||||
<el-row :gutter="8" class="mb-3">
|
||||
<el-col :span="6" v-for="i in 4" :key="'u' + i">
|
||||
<div class="opt" v-if="item['can_opt'] || item['can_modal']">
|
||||
<template v-if="item['can_opt']">
|
||||
<el-row :gutter="8" class="mb-3">
|
||||
<el-col :span="6" v-for="i in 4" :key="'u' + i">
|
||||
<button
|
||||
class="w-full h-6 rounded bg-gray-500 text-xs text-white shadow-md transition-all duration-300 hover:bg-gray-600"
|
||||
@click="upscale(i, item)"
|
||||
>
|
||||
U{{ i }}
|
||||
</button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="8" class="mb-3">
|
||||
<el-col :span="6" v-for="i in 4" :key="'v' + i">
|
||||
<button
|
||||
class="w-full h-6 rounded bg-gray-500 text-xs text-white shadow-md transition-all duration-300 hover:bg-gray-600"
|
||||
@click="variation(i, item)"
|
||||
>
|
||||
V{{ i }}
|
||||
</button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
<el-row v-if="item['can_modal']" :gutter="8" class="mb-3">
|
||||
<el-col :span="24">
|
||||
<button
|
||||
class="w-full h-6 rounded bg-gray-500 text-xs text-white shadow-md transition-all duration-300 hover:bg-gray-600"
|
||||
@click="upscale(i, item)"
|
||||
class="w-full h-6 rounded bg-purple-600 text-xs text-white shadow-md transition-all duration-300 hover:bg-purple-700"
|
||||
@click="openModalDialog(item)"
|
||||
>
|
||||
U{{ i }}
|
||||
</button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="8" class="mb-3">
|
||||
<el-col :span="6" v-for="i in 4" :key="'v' + i">
|
||||
<button
|
||||
class="w-full h-6 rounded bg-gray-500 text-xs text-white shadow-md transition-all duration-300 hover:bg-gray-600"
|
||||
@click="variation(i, item)"
|
||||
>
|
||||
V{{ i }}
|
||||
局部重绘
|
||||
</button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@@ -733,14 +640,9 @@
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
|
||||
<el-tooltip content="复制提示词" placement="top">
|
||||
<el-button
|
||||
type="info"
|
||||
circle
|
||||
class="copy-prompt-mj"
|
||||
:data-clipboard-text="item.prompt"
|
||||
>
|
||||
<i class="iconfont icon-file"></i>
|
||||
<el-tooltip content="任务详情" placement="top">
|
||||
<el-button type="info" circle @click="showDetail(item)">
|
||||
<i class="iconfont icon-info"></i>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="删除" placement="top">
|
||||
@@ -748,24 +650,6 @@
|
||||
<i class="iconfont icon-remove"></i>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-popover
|
||||
placement="top"
|
||||
title="提示词"
|
||||
:width="240"
|
||||
trigger="hover"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button type="primary" circle>
|
||||
<i class="iconfont icon-prompt text-white"></i>
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<div class="mj-list-item-prompt">
|
||||
<span>{{ item.prompt }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -815,24 +699,134 @@
|
||||
v-if="previewURL !== ''"
|
||||
:url-list="[previewURL]"
|
||||
/>
|
||||
|
||||
<!-- 局部重绘弹窗 -->
|
||||
<el-dialog
|
||||
v-model="modalVisible"
|
||||
title="局部重绘"
|
||||
class="modal-inpaint-dialog"
|
||||
destroy-on-close
|
||||
@closed="onModalClosed"
|
||||
>
|
||||
<div class="modal-inpaint-box">
|
||||
<p class="text-sm text-gray-600 mb-2">下方为原图,请在图上涂抹需要重绘的区域(半透明红色即蒙版),可调整画笔大小或清除后重画。蒙版按原图尺寸导出,与涂抹区域一致。</p>
|
||||
<div class="relative inline-block rounded overflow-hidden border border-gray-200 bg-gray-100 modal-inpaint-wrap" ref="modalCanvasWrapRef">
|
||||
<img
|
||||
:src="modalItem?.img_url"
|
||||
ref="modalImgRef"
|
||||
class="block max-h-[75vh] w-auto max-w-full"
|
||||
crossorigin="anonymous"
|
||||
@load="onModalImageLoad"
|
||||
/>
|
||||
<!-- 半透明蒙版层:与显示图同尺寸,用户在此绘制 -->
|
||||
<canvas
|
||||
ref="modalCanvasRef"
|
||||
class="absolute left-0 top-0 cursor-crosshair pointer-events-auto"
|
||||
:width="modalCanvasSize.w"
|
||||
:height="modalCanvasSize.h"
|
||||
@mousedown="modalDrawStart"
|
||||
@mousemove="modalDrawMove"
|
||||
@mouseup="modalDrawEnd"
|
||||
@mouseleave="modalDrawEnd"
|
||||
/>
|
||||
<!-- 用于导出的黑白蒙版:原图 naturalWidth × naturalHeight -->
|
||||
<canvas
|
||||
ref="modalMaskCanvasRef"
|
||||
class="hidden"
|
||||
:width="modalNaturalSize.w"
|
||||
:height="modalNaturalSize.h"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 mt-3">
|
||||
<span class="text-sm">画笔大小:</span>
|
||||
<el-slider v-model="modalBrushSize" :min="4" :max="40" :step="2" style="width: 120px" />
|
||||
<el-button size="small" @click="modalClearMask">清除蒙版</el-button>
|
||||
</div>
|
||||
<el-form-item label="重绘提示词" required class="mt-3">
|
||||
<el-input
|
||||
v-model="modalPrompt"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="描述你希望在该区域生成的内容"
|
||||
maxlength="1024"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="modalVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="modalSubmitting" @click="submitModal">
|
||||
提交(消耗 {{ mjModalPower }} 算力)
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 任务详情弹窗 -->
|
||||
<el-dialog
|
||||
v-model="detailDialogVisible"
|
||||
title="任务详情"
|
||||
width="600px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div class="detail-content" v-if="currentDetail">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="任务类型">
|
||||
{{ currentDetail.type === 'image' ? '绘图' : currentDetail.type === 'upscale' ? '放大' : currentDetail.type === 'variation' ? '变换' : currentDetail.type === 'blend' ? '融图' : currentDetail.type === 'swapFace' ? '换脸' : currentDetail.type === 'modal' ? '局部重绘' : currentDetail.type || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="提示词">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="break-all">{{ currentDetail.prompt }}</span>
|
||||
<el-tooltip content="复制提示词" placement="top">
|
||||
<i
|
||||
class="iconfont icon-copy cursor-pointer shrink-0"
|
||||
@click="copyPrompt(currentDetail.prompt)"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item
|
||||
label="生成的图片"
|
||||
v-if="currentDetail.progress === 100 && currentDetail.img_url"
|
||||
>
|
||||
<el-image
|
||||
:src="getThumbURL(currentDetail.img_url, 200, 200)"
|
||||
:preview-src-list="[currentDetail.img_url]"
|
||||
fit="cover"
|
||||
style="width: 200px; height: 200px"
|
||||
/>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="消耗算力">
|
||||
{{ currentDetail.power ?? 0 }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">
|
||||
{{ dateFormat(currentDetail.created_at) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item
|
||||
label="错误信息"
|
||||
v-if="currentDetail.progress === 101 && currentDetail.err_msg"
|
||||
>
|
||||
<el-text type="danger">{{ currentDetail.err_msg }}</el-text>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import nodata from '@/assets/img/no-data.png'
|
||||
import BackTop from '@/components/BackTop.vue'
|
||||
import ImageUpload from '@/components/ImageUpload.vue'
|
||||
import TaskList from '@/components/TaskList.vue'
|
||||
import { checkSession, getSystemInfo } from '@/store/cache'
|
||||
import { getSessionId } from '@/store/session'
|
||||
import { useSharedStore } from '@/store/sharedata'
|
||||
import { closeLoading, showLoading, showMessageError } from '@/utils/dialog'
|
||||
import { httpGet, httpPost } from '@/utils/http'
|
||||
import { copyObj, removeArrayItem } from '@/utils/libs'
|
||||
import { Delete, InfoFilled, Plus, UploadFilled } from '@element-plus/icons-vue'
|
||||
import Clipboard from 'clipboard'
|
||||
import Compressor from 'compressorjs'
|
||||
import { copyObj, dateFormat, getThumbURL } from '@/utils/libs'
|
||||
import { InfoFilled } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { LazyImg, Waterfall } from 'vue-waterfall-plugin-next'
|
||||
import 'vue-waterfall-plugin-next/dist/style.css'
|
||||
@@ -952,21 +946,11 @@ const power = ref(0)
|
||||
const userId = ref(0)
|
||||
const isLogin = ref(false)
|
||||
|
||||
const clipboard = ref(null)
|
||||
onMounted(() => {
|
||||
initData()
|
||||
clipboard.value = new Clipboard('.copy-prompt-mj')
|
||||
clipboard.value.on('success', () => {
|
||||
ElMessage.success('复制成功!')
|
||||
})
|
||||
|
||||
clipboard.value.on('error', () => {
|
||||
ElMessage.error('复制失败!')
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clipboard.value.destroy()
|
||||
if (tastPullHandler.value) {
|
||||
clearInterval(tastPullHandler.value)
|
||||
}
|
||||
@@ -1004,10 +988,20 @@ const initData = () => {
|
||||
|
||||
const mjPower = ref(1)
|
||||
const mjActionPower = ref(1)
|
||||
const mjUpscalePower = ref(1)
|
||||
const mjBlendPower = ref(1)
|
||||
const mjSwapFacePower = ref(1)
|
||||
const mjModalPower = ref(1)
|
||||
getSystemInfo()
|
||||
.then((res) => {
|
||||
mjPower.value = res.data['mj_power']
|
||||
mjActionPower.value = res.data['mj_action_power']
|
||||
const d = res.data || {}
|
||||
const fallback = d['mj_action_power'] || 1
|
||||
mjPower.value = d['mj_power'] || 1
|
||||
mjActionPower.value = fallback
|
||||
mjUpscalePower.value = d['mj_upscale_power'] > 0 ? d['mj_upscale_power'] : fallback
|
||||
mjBlendPower.value = d['mj_blend_power'] > 0 ? d['mj_blend_power'] : fallback
|
||||
mjSwapFacePower.value = d['mj_swap_face_power'] > 0 ? d['mj_swap_face_power'] : fallback
|
||||
mjModalPower.value = d['mj_modal_power'] > 0 ? d['mj_modal_power'] : fallback
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('获取系统配置失败:' + e.message)
|
||||
@@ -1032,11 +1026,10 @@ const fetchRunningJobs = () => {
|
||||
type: 'error',
|
||||
duration: 0,
|
||||
})
|
||||
if (jobs[i].type === 'image') {
|
||||
power.value += mjPower.value
|
||||
} else {
|
||||
power.value += mjActionPower.value
|
||||
}
|
||||
const refund = (jobs[i].power != null && jobs[i].power > 0)
|
||||
? jobs[i].power
|
||||
: (jobs[i].type === 'image' ? mjPower.value : mjUpscalePower.value)
|
||||
power.value += refund
|
||||
}
|
||||
_jobs.push(jobs[i])
|
||||
}
|
||||
@@ -1073,9 +1066,9 @@ const fetchFinishJobs = () => {
|
||||
for (let i = 0; i < jobs.length; i++) {
|
||||
if (jobs[i]['img_url'] !== '') {
|
||||
if (jobs[i].type === 'upscale' || jobs[i].type === 'swapFace') {
|
||||
jobs[i]['img_thumb'] = jobs[i]['img_url'] + '?imageView2/1/w/480/h/600/q/75'
|
||||
jobs[i]['img_thumb'] = getThumbURL(jobs[i]['img_url'], 480, 600)
|
||||
} else {
|
||||
jobs[i]['img_thumb'] = jobs[i]['img_url'] + '?imageView2/1/w/480/h/480/q/75'
|
||||
jobs[i]['img_thumb'] = getThumbURL(jobs[i]['img_url'], 480, 480)
|
||||
}
|
||||
} else {
|
||||
if (jobs[i].progress === 100) {
|
||||
@@ -1091,6 +1084,10 @@ const fetchFinishJobs = () => {
|
||||
if (jobs[i].type !== 'upscale' && jobs[i].progress === 100) {
|
||||
jobs[i]['can_opt'] = true
|
||||
}
|
||||
// 所有已完成且有图的任务均支持局部重绘
|
||||
if (jobs[i].progress === 100 && jobs[i].img_url) {
|
||||
jobs[i]['can_modal'] = true
|
||||
}
|
||||
}
|
||||
|
||||
if (jobs.length < pageSize.value) {
|
||||
@@ -1122,49 +1119,6 @@ const changeModel = (item) => {
|
||||
params.value.model = item.value
|
||||
}
|
||||
|
||||
const imgKey = ref('')
|
||||
const beforeUpload = (key) => {
|
||||
imgKey.value = key
|
||||
}
|
||||
|
||||
// 图片上传
|
||||
const uploadImg = (file) => {
|
||||
if (!isLogin.value) {
|
||||
store.setShowLoginDialog(true)
|
||||
return
|
||||
}
|
||||
|
||||
// 压缩图片并上传
|
||||
new Compressor(file.file, {
|
||||
quality: 0.6,
|
||||
success(result) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', result, result.name)
|
||||
showLoading('图片上传中...')
|
||||
// 执行上传操作
|
||||
httpPost('/api/upload', formData)
|
||||
.then((res) => {
|
||||
if (imgKey.value === '') {
|
||||
imgList.value.push(res.data.url)
|
||||
} else {
|
||||
// 单张图片上传
|
||||
params.value[imgKey.value] = res.data.url
|
||||
imgKey.value = ''
|
||||
}
|
||||
ElMessage.success('上传成功')
|
||||
closeLoading()
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('上传失败:' + e.message)
|
||||
closeLoading()
|
||||
})
|
||||
},
|
||||
error(err) {
|
||||
console.log(err.message)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 创建绘图任务
|
||||
const promptRef = ref(null)
|
||||
const isGenerating = ref(false)
|
||||
@@ -1198,10 +1152,16 @@ const generate = () => {
|
||||
params.value.session_id = getSessionId()
|
||||
params.value.img_arr = imgList.value
|
||||
isGenerating.value = true
|
||||
const deductPower =
|
||||
params.value.task_type === 'blend'
|
||||
? mjBlendPower.value
|
||||
: params.value.task_type === 'swapFace'
|
||||
? mjSwapFacePower.value
|
||||
: mjPower.value
|
||||
httpPost('/api/mj/image', params.value)
|
||||
.then(() => {
|
||||
ElMessage.success('绘画任务推送成功,请耐心等待任务执行...')
|
||||
power.value -= mjPower.value
|
||||
power.value -= deductPower
|
||||
taskPulling.value = true
|
||||
runningJobs.value.push({
|
||||
progress: 0,
|
||||
@@ -1237,7 +1197,7 @@ const send = (url, index, item) => {
|
||||
})
|
||||
.then(() => {
|
||||
ElMessage.success('任务推送成功,请耐心等待任务执行...')
|
||||
power.value -= mjActionPower.value
|
||||
power.value -= mjUpscalePower.value
|
||||
taskPulling.value = true
|
||||
runningJobs.value.push({
|
||||
progress: 0,
|
||||
@@ -1248,6 +1208,188 @@ const send = (url, index, item) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 局部重绘弹窗
|
||||
const modalVisible = ref(false)
|
||||
const modalItem = ref(null)
|
||||
const modalPrompt = ref('')
|
||||
const modalCanvasRef = ref(null)
|
||||
const modalMaskCanvasRef = ref(null)
|
||||
const modalImgRef = ref(null)
|
||||
const modalCanvasWrapRef = ref(null)
|
||||
const modalCanvasSize = ref({ w: 0, h: 0 }) // 显示层尺寸(与当前显示图一致)
|
||||
const modalNaturalSize = ref({ w: 0, h: 0 }) // 原图尺寸,蒙版 canvas 使用
|
||||
const modalBrushSize = ref(16)
|
||||
const modalSubmitting = ref(false)
|
||||
const modalDrawing = ref(false)
|
||||
let modalCtx = null // 显示层:半透明绘制,底图可见
|
||||
let modalMaskCtx = null // 导出层:黑底白字,用于 API
|
||||
|
||||
const openModalDialog = (item) => {
|
||||
modalItem.value = item
|
||||
modalPrompt.value = ''
|
||||
modalCanvasSize.value = { w: 0, h: 0 }
|
||||
modalNaturalSize.value = { w: 0, h: 0 }
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
const onModalImageLoad = () => {
|
||||
nextTick(() => {
|
||||
const img = modalImgRef.value
|
||||
if (!img || !modalItem.value) return
|
||||
const displayW = img.offsetWidth || Math.min(img.naturalWidth || 400, 960)
|
||||
const displayH = img.offsetHeight || Math.min(img.naturalHeight || 400, 720)
|
||||
const naturalW = img.naturalWidth || displayW
|
||||
const naturalH = img.naturalHeight || displayH
|
||||
if (displayW <= 0 || displayH <= 0) return
|
||||
modalCanvasSize.value = { w: displayW, h: displayH }
|
||||
modalNaturalSize.value = { w: naturalW, h: naturalH }
|
||||
nextTick(() => {
|
||||
const canvas = modalCanvasRef.value
|
||||
const maskCanvas = modalMaskCanvasRef.value
|
||||
if (!canvas || !maskCanvas) return
|
||||
modalCtx = canvas.getContext('2d')
|
||||
modalMaskCtx = maskCanvas.getContext('2d')
|
||||
if (!modalCtx || !modalMaskCtx) return
|
||||
// 显示层:与显示图同尺寸,不填黑,绘制时用半透明红色
|
||||
modalCtx.lineCap = 'round'
|
||||
modalCtx.lineJoin = 'round'
|
||||
// 导出层:原图尺寸 naturalW×naturalH,黑底,后续绘制白色蒙版
|
||||
modalMaskCtx.fillStyle = '#000000'
|
||||
modalMaskCtx.fillRect(0, 0, naturalW, naturalH)
|
||||
modalMaskCtx.strokeStyle = '#ffffff'
|
||||
modalMaskCtx.lineCap = 'round'
|
||||
modalMaskCtx.lineJoin = 'round'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 将显示坐标换算到原图坐标,用于在蒙版 canvas 上绘制
|
||||
const displayToNatural = (displayX, displayY) => {
|
||||
const ds = modalCanvasSize.value
|
||||
const ns = modalNaturalSize.value
|
||||
if (ds.w <= 0 || ds.h <= 0) return { x: 0, y: 0, scale: 1 }
|
||||
const scaleX = ns.w / ds.w
|
||||
const scaleY = ns.h / ds.h
|
||||
return {
|
||||
x: displayX * scaleX,
|
||||
y: displayY * scaleY,
|
||||
scale: Math.max(scaleX, scaleY),
|
||||
}
|
||||
}
|
||||
|
||||
const modalDrawStart = (e) => {
|
||||
if (!modalCtx || !modalMaskCtx) return
|
||||
modalDrawing.value = true
|
||||
const size = modalBrushSize.value
|
||||
const x = e.offsetX
|
||||
const y = e.offsetY
|
||||
const { x: maskX, y: maskY, scale } = displayToNatural(x, y)
|
||||
const maskLineWidth = size * scale
|
||||
// 显示层:半透明红色,方便对照底图
|
||||
modalCtx.strokeStyle = 'rgba(255, 80, 80, 0.55)'
|
||||
modalCtx.lineWidth = size
|
||||
modalCtx.beginPath()
|
||||
modalCtx.moveTo(x, y)
|
||||
// 导出层:原图坐标系,白色
|
||||
modalMaskCtx.strokeStyle = '#ffffff'
|
||||
modalMaskCtx.lineWidth = maskLineWidth
|
||||
modalMaskCtx.beginPath()
|
||||
modalMaskCtx.moveTo(maskX, maskY)
|
||||
}
|
||||
|
||||
const modalDrawMove = (e) => {
|
||||
if (!modalDrawing.value || !modalCtx || !modalMaskCtx) return
|
||||
const size = modalBrushSize.value
|
||||
const x = e.offsetX
|
||||
const y = e.offsetY
|
||||
const { x: maskX, y: maskY, scale } = displayToNatural(x, y)
|
||||
const maskLineWidth = size * scale
|
||||
modalCtx.lineWidth = size
|
||||
modalCtx.lineTo(x, y)
|
||||
modalCtx.stroke()
|
||||
modalMaskCtx.lineWidth = maskLineWidth
|
||||
modalMaskCtx.lineTo(maskX, maskY)
|
||||
modalMaskCtx.stroke()
|
||||
}
|
||||
|
||||
const modalDrawEnd = () => {
|
||||
modalDrawing.value = false
|
||||
}
|
||||
|
||||
const modalClearMask = () => {
|
||||
const canvas = modalCanvasRef.value
|
||||
const maskCanvas = modalMaskCanvasRef.value
|
||||
if (!canvas || !maskCanvas || !modalCtx || !modalMaskCtx) return
|
||||
modalCtx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
modalMaskCtx.fillStyle = '#000000'
|
||||
modalMaskCtx.fillRect(0, 0, maskCanvas.width, maskCanvas.height)
|
||||
}
|
||||
|
||||
const getModalMaskBase64 = () => {
|
||||
const maskCanvas = modalMaskCanvasRef.value
|
||||
if (!maskCanvas || modalNaturalSize.value.w === 0) return ''
|
||||
const dataUrl = maskCanvas.toDataURL('image/png')
|
||||
return dataUrl.replace(/^data:image\/\w+;base64,/, '')
|
||||
}
|
||||
|
||||
const submitModal = () => {
|
||||
if (!modalPrompt.value.trim()) {
|
||||
ElMessage.warning('请填写重绘提示词')
|
||||
return
|
||||
}
|
||||
if (!modalItem.value) return
|
||||
const taskId = modalItem.value.task_id || modalItem.value.message_id
|
||||
if (!taskId || !modalItem.value.channel_id) {
|
||||
ElMessage.warning('缺少原图信息,无法提交局部重绘')
|
||||
return
|
||||
}
|
||||
modalSubmitting.value = true
|
||||
const maskBase64 = getModalMaskBase64()
|
||||
const body = {
|
||||
task_id: taskId,
|
||||
channel_id: modalItem.value.channel_id,
|
||||
prompt: modalPrompt.value.trim(),
|
||||
}
|
||||
if (maskBase64) body.mask_base64 = maskBase64
|
||||
httpPost('/api/mj/modal', body)
|
||||
.then(() => {
|
||||
ElMessage.success('任务推送成功,请耐心等待任务执行...')
|
||||
power.value -= mjModalPower.value
|
||||
taskPulling.value = true
|
||||
runningJobs.value.push({ progress: 0 })
|
||||
modalVisible.value = false
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('任务推送失败:' + e.message)
|
||||
})
|
||||
.finally(() => {
|
||||
modalSubmitting.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const onModalClosed = () => {
|
||||
modalItem.value = null
|
||||
modalPrompt.value = ''
|
||||
modalCtx = null
|
||||
modalMaskCtx = null
|
||||
}
|
||||
|
||||
// 任务详情弹窗
|
||||
const detailDialogVisible = ref(false)
|
||||
const currentDetail = ref(null)
|
||||
const showDetail = (item) => {
|
||||
currentDetail.value = item
|
||||
detailDialogVisible.value = true
|
||||
}
|
||||
const copyPrompt = (text) => {
|
||||
if (!text) return
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
ElMessage.success('复制成功')
|
||||
}).catch(() => {
|
||||
ElMessage.error('复制失败')
|
||||
})
|
||||
}
|
||||
|
||||
const removeImage = (item) => {
|
||||
ElMessageBox.confirm('此操作将会删除任务和图片,继续操作码?', '删除提示', {
|
||||
confirmButtonText: '确认',
|
||||
@@ -1305,11 +1447,6 @@ const tabChange = (tab) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 删除已上传图片
|
||||
const removeUploadImage = (url) => {
|
||||
imgList.value = removeArrayItem(imgList.value, url)
|
||||
}
|
||||
|
||||
const promptGenerating = ref(false)
|
||||
const generatePrompt = () => {
|
||||
if (params.value.prompt === '') {
|
||||
@@ -1328,7 +1465,47 @@ const generatePrompt = () => {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
<style lang="scss" scoped>
|
||||
@use '../assets/css/image-mj.scss' as *;
|
||||
@use '../assets/css/custom-scroll.scss' as *;
|
||||
|
||||
.modal-inpaint-dialog {
|
||||
:deep(.el-dialog) {
|
||||
width: min(960px, 90vw);
|
||||
}
|
||||
}
|
||||
.modal-inpaint-wrap {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* 一致性参数:两列布局,每列上 label 下上传 */
|
||||
.cref-two-cols {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.cref-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.cref-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-regular, #606266);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cref-upload-inner {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.cref-two-cols {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,772 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-sd">
|
||||
<div class="inner custom-scroll">
|
||||
<div class="sd-box">
|
||||
<h2>Stable Diffusion 创作中心</h2>
|
||||
|
||||
<div class="sd-params">
|
||||
<el-form :model="params" label-width="80px" label-position="left">
|
||||
<div class="param-line" style="padding-top: 10px">
|
||||
<el-form-item label="采样方法">
|
||||
<template #default>
|
||||
<div class="form-item-inner">
|
||||
<el-select v-model="params.sampler" style="width: 150px">
|
||||
<el-option
|
||||
v-for="item in samplers"
|
||||
:label="item"
|
||||
:value="item"
|
||||
:key="item"
|
||||
/>
|
||||
</el-select>
|
||||
<el-tooltip
|
||||
content="出图效果比较好的一般是 Euler 和 DPM 系列算法"
|
||||
raw-content
|
||||
placement="right"
|
||||
>
|
||||
<el-icon class="info-icon">
|
||||
<InfoFilled />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="param-line" style="padding-top: 10px">
|
||||
<el-form-item label="采样调度">
|
||||
<template #default>
|
||||
<div class="form-item-inner">
|
||||
<el-select v-model="params.scheduler" style="width: 150px">
|
||||
<el-option
|
||||
v-for="item in schedulers"
|
||||
:label="item"
|
||||
:value="item"
|
||||
:key="item"
|
||||
/>
|
||||
</el-select>
|
||||
<el-tooltip content="推荐自动或者 Karras" raw-content placement="right">
|
||||
<el-icon class="info-icon">
|
||||
<InfoFilled />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="param-line">
|
||||
<el-form-item label="图片尺寸">
|
||||
<template #default>
|
||||
<div class="form-item-inner">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-input v-model.number="params.width" placeholder="图片宽度" />
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-input v-model.number="params.height" placeholder="图片高度" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="param-line">
|
||||
<el-form-item label="迭代步数">
|
||||
<template #default>
|
||||
<div class="form-item-inner">
|
||||
<el-input v-model.number="params.steps" />
|
||||
<el-tooltip
|
||||
content="值越大则代表细节越多,同时也意味着出图速度越慢"
|
||||
raw-content
|
||||
placement="right"
|
||||
>
|
||||
<el-icon class="info-icon">
|
||||
<InfoFilled />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="param-line">
|
||||
<el-form-item label="引导系数">
|
||||
<template #default>
|
||||
<div class="form-item-inner">
|
||||
<el-input v-model.number="params.cfg_scale" />
|
||||
<el-tooltip
|
||||
content="提示词引导系数,图像在多大程度上服从提示词<br/> 较低值会产生更有创意的结果"
|
||||
raw-content
|
||||
placement="right"
|
||||
>
|
||||
<el-icon class="info-icon">
|
||||
<InfoFilled />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="param-line">
|
||||
<el-form-item label="随机因子">
|
||||
<template #default>
|
||||
<div class="form-item-inner">
|
||||
<el-input v-model.number="params.seed" />
|
||||
<el-tooltip
|
||||
content="随机数种子,相同的种子会得到相同的结果<br/> 设置为 -1 则每次随机生成种子"
|
||||
raw-content
|
||||
placement="right"
|
||||
>
|
||||
<el-icon class="info-icon">
|
||||
<InfoFilled />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
|
||||
<el-tooltip content="使用随机数" raw-content placement="right">
|
||||
<el-icon @click="params.seed = -1" class="info-icon">
|
||||
<Orange />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="param-line">
|
||||
<el-form-item label="高清修复">
|
||||
<template #default>
|
||||
<div class="form-item-inner">
|
||||
<el-switch
|
||||
v-model="params.hd_fix"
|
||||
style="--el-switch-on-color: #47fff1"
|
||||
size="large"
|
||||
/>
|
||||
<el-tooltip
|
||||
content="先以较小的分辨率生成图像,接着方法图像<br />然后在不更改构图的情况下再修改细节"
|
||||
raw-content
|
||||
placement="right"
|
||||
>
|
||||
<el-icon style="margin-left: 10px; top: 12px">
|
||||
<InfoFilled />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div v-show="params.hd_fix">
|
||||
<div class="param-line">
|
||||
<el-form-item label="重绘幅度">
|
||||
<template #default>
|
||||
<div class="form-item-inner">
|
||||
<el-slider
|
||||
v-model.number="params.hd_redraw_rate"
|
||||
:max="1"
|
||||
:step="0.1"
|
||||
style="width: 180px; --el-slider-main-bg-color: #47fff1"
|
||||
/>
|
||||
<el-tooltip
|
||||
content="决定算法对图像内容的影响程度<br />较大的值将得到越有创意的图像"
|
||||
raw-content
|
||||
placement="right"
|
||||
>
|
||||
<el-icon class="info-icon">
|
||||
<InfoFilled />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="param-line">
|
||||
<el-form-item label="放大算法">
|
||||
<template #default>
|
||||
<div class="form-item-inner">
|
||||
<el-select v-model="params.hd_scale_alg" style="width: 176px">
|
||||
<el-option
|
||||
v-for="item in scaleAlg"
|
||||
:label="item"
|
||||
:value="item"
|
||||
:key="item"
|
||||
/>
|
||||
</el-select>
|
||||
<el-tooltip
|
||||
content="高清修复放大算法,主流算法有Latent和ESRGAN_4x"
|
||||
raw-content
|
||||
placement="right"
|
||||
>
|
||||
<el-icon class="info-icon">
|
||||
<InfoFilled />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="param-line">
|
||||
<el-form-item label="放大倍数">
|
||||
<template #default>
|
||||
<div class="form-item-inner">
|
||||
<el-input v-model.number="params.hd_scale" />
|
||||
<el-tooltip
|
||||
content="随机数种子,相同的种子会得到相同的结果<br/> 设置为 -1 则每次随机生成种子"
|
||||
raw-content
|
||||
placement="right"
|
||||
>
|
||||
<el-icon class="info-icon">
|
||||
<InfoFilled />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="param-line">
|
||||
<el-form-item label="迭代步数">
|
||||
<template #default>
|
||||
<div class="form-item-inner">
|
||||
<el-input v-model.number="params.hd_steps" />
|
||||
<el-tooltip
|
||||
content="重绘迭代步数,如果设置为0,则设置跟原图相同的迭代步数"
|
||||
raw-content
|
||||
placement="right"
|
||||
>
|
||||
<el-icon class="info-icon">
|
||||
<InfoFilled />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="param-line">
|
||||
<el-input
|
||||
v-model="params.prompt"
|
||||
:autosize="{ minRows: 4, maxRows: 6 }"
|
||||
type="textarea"
|
||||
ref="promptRef"
|
||||
maxlength="1024"
|
||||
show-word-limit
|
||||
placeholder="请在此输入绘画提示词,您也可以点击下面的提示词助手生成绘画提示词"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-2 pr-2">
|
||||
<el-button @click="generatePrompt" type="primary" :loading="promptGenerating">
|
||||
<span v-if="!promptGenerating">
|
||||
<i class="iconfont icon-chuangzuo"></i>
|
||||
生成专业绘画指令
|
||||
</span>
|
||||
<span v-else>生成中...</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="param-line pt">
|
||||
<span>反向提示词:</span>
|
||||
<el-tooltip content="不希望出现的元素,下面给了默认的起手式" placement="right">
|
||||
<el-icon class="info-icon">
|
||||
<InfoFilled />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div class="param-line">
|
||||
<el-input
|
||||
v-model="params.neg_prompt"
|
||||
:autosize="{ minRows: 4, maxRows: 6 }"
|
||||
type="textarea"
|
||||
placeholder="反向提示词"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="text-info">
|
||||
<el-row :gutter="10">
|
||||
<el-text type="primary"
|
||||
>单次绘图消耗
|
||||
<el-text type="warning">{{ sdPower }}算力,</el-text>
|
||||
</el-text>
|
||||
<el-text type="primary"
|
||||
>当前可用 <el-text type="warning"> {{ power }}算力</el-text></el-text
|
||||
>
|
||||
</el-row>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="py-4">
|
||||
<button
|
||||
class="w-full py-3 bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-xl disabled:from-gray-400 disabled:to-gray-400 disabled:cursor-not-allowed hover:from-blue-600 hover:to-purple-700 transition-all duration-200 flex items-center justify-center space-x-2 text-base"
|
||||
@click="generate"
|
||||
type="button"
|
||||
>
|
||||
<i v-if="isGenerating" class="iconfont icon-loading animate-spin"></i>
|
||||
<i v-else class="iconfont icon-chuangzuo"></i>
|
||||
<span>{{ isGenerating ? '创作中...' : '立即生成' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-list-box pl-6 pr-6 pb-4 pt-4 h-dvh">
|
||||
<div class="task-list-inner" :style="{ height: listBoxHeight + 'px' }">
|
||||
<div class="job-list-box">
|
||||
<h2 class="text-xl">任务列表</h2>
|
||||
<task-list :list="runningJobs" />
|
||||
<template v-if="finishedJobs.length > 0">
|
||||
<h2 class="text-xl">创作记录</h2>
|
||||
<div class="finish-job-list mt-3">
|
||||
<div v-if="finishedJobs.length > 0">
|
||||
<Waterfall
|
||||
:list="finishedJobs"
|
||||
:row-key="waterfallOptions.rowKey"
|
||||
:gutter="waterfallOptions.gutter"
|
||||
:has-around-gutter="waterfallOptions.hasAroundGutter"
|
||||
:width="waterfallOptions.width"
|
||||
:breakpoints="waterfallOptions.breakpoints"
|
||||
:img-selector="waterfallOptions.imgSelector"
|
||||
:background-color="waterfallOptions.backgroundColor"
|
||||
:animation-effect="waterfallOptions.animationEffect"
|
||||
:animation-duration="waterfallOptions.animationDuration"
|
||||
:animation-delay="waterfallOptions.animationDelay"
|
||||
:animation-cancel="waterfallOptions.animationCancel"
|
||||
:lazyload="waterfallOptions.lazyload"
|
||||
:load-props="waterfallOptions.loadProps"
|
||||
:cross-origin="waterfallOptions.crossOrigin"
|
||||
:align="waterfallOptions.align"
|
||||
:is-loading="loading"
|
||||
:is-over="isOver"
|
||||
@afterRender="loading = false"
|
||||
>
|
||||
<template #default="{ item, url }">
|
||||
<div
|
||||
class="bg-gray-900 rounded-lg shadow-md overflow-hidden transition-all duration-300 ease-linear hover:shadow-md hover:shadow-purple-800 group"
|
||||
>
|
||||
<div class="overflow-hidden rounded-lg">
|
||||
<LazyImg
|
||||
:url="url"
|
||||
v-if="item.progress === 100"
|
||||
class="cursor-pointer transition-all duration-300 ease-linear group-hover:scale-105"
|
||||
@click="showTask(item)"
|
||||
/>
|
||||
<el-image v-else-if="item.progress === 101">
|
||||
<template #error>
|
||||
<div class="image-slot">
|
||||
<div class="err-msg-container">
|
||||
<div class="title">任务失败</div>
|
||||
<div class="opt">
|
||||
<el-popover
|
||||
title="错误详情"
|
||||
trigger="click"
|
||||
:width="250"
|
||||
:content="item['err_msg']"
|
||||
placement="top"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button type="info">详情</el-button>
|
||||
</template>
|
||||
</el-popover>
|
||||
<el-button type="danger" @click="removeImage(item)"
|
||||
>删除</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
</div>
|
||||
<div
|
||||
class="px-4 pt-2 pb-4 border-t border-t-gray-800"
|
||||
v-if="item.progress === 100"
|
||||
>
|
||||
<div
|
||||
class="pt-3 flex justify-center items-center border-t border-t-gray-600 border-opacity-50"
|
||||
>
|
||||
<div class="flex">
|
||||
<el-tooltip content="取消分享" placement="top" v-if="item.publish">
|
||||
<el-button
|
||||
type="warning"
|
||||
@click="publishImage(item, false)"
|
||||
circle
|
||||
>
|
||||
<i class="iconfont icon-cancel-share"></i>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="分享" placement="top" v-else>
|
||||
<el-button
|
||||
type="success"
|
||||
@click="publishImage(item, true)"
|
||||
circle
|
||||
>
|
||||
<i class="iconfont icon-share-bold"></i>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
|
||||
<el-tooltip content="复制提示词" placement="top">
|
||||
<el-button
|
||||
type="info"
|
||||
circle
|
||||
class="copy-prompt"
|
||||
:data-clipboard-text="item.prompt"
|
||||
>
|
||||
<i class="iconfont icon-file"></i>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="删除" placement="top">
|
||||
<el-button
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
@click="removeImage(item)"
|
||||
circle
|
||||
/>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Waterfall>
|
||||
|
||||
<div class="flex justify-center py-10">
|
||||
<img
|
||||
:src="waterfallOptions.loadProps.loading"
|
||||
class="max-w-[50px] max-h-[50px]"
|
||||
v-if="loading"
|
||||
/>
|
||||
<div v-else>
|
||||
<button
|
||||
class="px-5 py-2 rounded-full bg-purple-700 text-md text-white cursor-pointer hover:bg-purple-800 transition-all duration-300"
|
||||
@click="fetchFinishJobs"
|
||||
v-if="!isOver"
|
||||
>
|
||||
加载更多
|
||||
</button>
|
||||
<div class="no-more-data" v-else>
|
||||
<span class="text-gray-500 mr-2">没有更多数据了</span>
|
||||
<i class="iconfont icon-face"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty :image-size="100" v-else :image="nodata" description="暂无记录" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- end finish job list-->
|
||||
</div>
|
||||
</div>
|
||||
<back-top :right="30" :bottom="30" />
|
||||
</div>
|
||||
<!-- end task list box -->
|
||||
</div>
|
||||
|
||||
<!-- 任务详情弹框 -->
|
||||
<sd-task-view
|
||||
v-model="showTaskDialog"
|
||||
:data="item"
|
||||
@drawSame="copyParams"
|
||||
@close="showTaskDialog = false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import nodata from '@/assets/img/no-data.png'
|
||||
import { Delete, InfoFilled, Orange } from '@element-plus/icons-vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import BackTop from '@/components/BackTop.vue'
|
||||
import SdTaskView from '@/components/SdTaskView.vue'
|
||||
import TaskList from '@/components/TaskList.vue'
|
||||
import { checkSession, getSystemInfo } from '@/store/cache'
|
||||
import { getSessionId } from '@/store/session'
|
||||
import { useSharedStore } from '@/store/sharedata'
|
||||
import { showMessageError } from '@/utils/dialog'
|
||||
import { httpGet, httpPost } from '@/utils/http'
|
||||
import Clipboard from 'clipboard'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { LazyImg, Waterfall } from 'vue-waterfall-plugin-next'
|
||||
import 'vue-waterfall-plugin-next/dist/style.css'
|
||||
|
||||
const listBoxHeight = ref(0)
|
||||
// const paramBoxHeight = ref(0)
|
||||
const showTaskDialog = ref(false)
|
||||
const item = ref({})
|
||||
const isLogin = ref(false)
|
||||
const loading = ref(true)
|
||||
const store = useSharedStore()
|
||||
const waterfallOptions = store.waterfallOptions
|
||||
|
||||
const resizeElement = function () {
|
||||
listBoxHeight.value = window.innerHeight - 80
|
||||
// paramBoxHeight.value = window.innerHeight - 200
|
||||
}
|
||||
resizeElement()
|
||||
window.onresize = () => {
|
||||
resizeElement()
|
||||
}
|
||||
const samplers = [
|
||||
'Euler a',
|
||||
'DPM++ 2S a',
|
||||
'DPM++ 2M',
|
||||
'DPM++ SDE',
|
||||
'DPM++ 2M SDE',
|
||||
'UniPC',
|
||||
'Restart',
|
||||
]
|
||||
const schedulers = ['Automatic', 'Karras', 'Exponential', 'Uniform']
|
||||
const scaleAlg = ['Latent', 'ESRGAN_4x', 'R-ESRGAN 4x+', 'SwinIR_4x', 'LDSR']
|
||||
const params = ref({
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
sampler: samplers[0],
|
||||
scheduler: schedulers[0],
|
||||
seed: -1,
|
||||
steps: 20,
|
||||
cfg_scale: 7,
|
||||
hd_fix: false,
|
||||
hd_redraw_rate: 0.7,
|
||||
hd_scale: 2,
|
||||
hd_scale_alg: scaleAlg[0],
|
||||
hd_steps: 0,
|
||||
prompt: '',
|
||||
neg_prompt:
|
||||
'nsfw, paintings,low quality,easynegative,ng_deepnegative ,lowres,bad anatomy,bad hands,bad feet',
|
||||
})
|
||||
|
||||
const runningJobs = ref([])
|
||||
const finishedJobs = ref([])
|
||||
const allowPulling = ref(true) // 是否允许轮询
|
||||
const tastPullHandler = ref(null)
|
||||
const router = useRouter()
|
||||
// 检查是否有画同款的参数
|
||||
const _params = router.currentRoute.value.params['copyParams']
|
||||
if (_params) {
|
||||
params.value = JSON.parse(_params)
|
||||
}
|
||||
const power = ref(0)
|
||||
const sdPower = ref(0) // 画一张 SD 图片消耗算力
|
||||
|
||||
const userId = ref(0)
|
||||
const clipboard = ref(null)
|
||||
onMounted(() => {
|
||||
initData()
|
||||
clipboard.value = new Clipboard('.copy-prompt-sd')
|
||||
clipboard.value.on('success', () => {
|
||||
ElMessage.success('复制成功!')
|
||||
})
|
||||
|
||||
clipboard.value.on('error', () => {
|
||||
ElMessage.error('复制失败!')
|
||||
})
|
||||
|
||||
getSystemInfo()
|
||||
.then((res) => {
|
||||
sdPower.value = res.data.sd_power
|
||||
params.value.neg_prompt = res.data.sd_neg_prompt
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('获取系统配置失败:' + e.message)
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clipboard.value.destroy()
|
||||
if (tastPullHandler.value) {
|
||||
clearInterval(tastPullHandler.value)
|
||||
}
|
||||
})
|
||||
|
||||
const initData = () => {
|
||||
checkSession()
|
||||
.then((user) => {
|
||||
power.value = user['power']
|
||||
userId.value = user.id
|
||||
isLogin.value = true
|
||||
page.value = 0
|
||||
fetchRunningJobs()
|
||||
fetchFinishJobs()
|
||||
|
||||
tastPullHandler.value = setInterval(() => {
|
||||
if (allowPulling.value) {
|
||||
fetchRunningJobs()
|
||||
}
|
||||
}, 5000)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const fetchRunningJobs = () => {
|
||||
if (!isLogin.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// 获取运行中的任务
|
||||
httpGet(`/api/sd/jobs?finish=0`)
|
||||
.then((res) => {
|
||||
if (runningJobs.value.length !== res.data.items.length) {
|
||||
page.value = 0
|
||||
fetchFinishJobs()
|
||||
}
|
||||
if (runningJobs.value.length === 0) {
|
||||
allowPulling.value = false
|
||||
}
|
||||
runningJobs.value = res.data.items
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('获取任务失败:' + e.message)
|
||||
})
|
||||
}
|
||||
|
||||
const page = ref(0)
|
||||
const pageSize = ref(20)
|
||||
const isOver = ref(false)
|
||||
// 获取已完成的任务
|
||||
const fetchFinishJobs = () => {
|
||||
if (!isLogin.value || isOver.value === true) {
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
page.value = page.value + 1
|
||||
|
||||
httpGet(`/api/sd/jobs?finish=1&page=${page.value}&page_size=${pageSize.value}`)
|
||||
.then((res) => {
|
||||
if (res.data.items.length < pageSize.value) {
|
||||
isOver.value = true
|
||||
loading.value = false
|
||||
}
|
||||
const imageList = res.data.items
|
||||
for (let i = 0; i < imageList.length; i++) {
|
||||
imageList[i]['img_thumb'] = imageList[i]['img_url'] + '?imageView2/4/w/300/h/0/q/75'
|
||||
}
|
||||
if (page.value === 1) {
|
||||
finishedJobs.value = imageList
|
||||
} else {
|
||||
finishedJobs.value = finishedJobs.value.concat(imageList)
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('获取任务失败:' + e.message)
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
// 创建绘图任务
|
||||
const promptRef = ref(null)
|
||||
const isGenerating = ref(false)
|
||||
const generate = () => {
|
||||
if (isGenerating.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (params.value.prompt === '') {
|
||||
promptRef.value.focus()
|
||||
return ElMessage.error('请输入绘画提示词!')
|
||||
}
|
||||
|
||||
if (!isLogin.value) {
|
||||
store.setShowLoginDialog(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (!params.value.seed) {
|
||||
params.value.seed = -1
|
||||
}
|
||||
params.value.session_id = getSessionId()
|
||||
isGenerating.value = true
|
||||
httpPost('/api/sd/image', params.value)
|
||||
.then(() => {
|
||||
ElMessage.success('绘画任务推送成功,请耐心等待任务执行...')
|
||||
power.value -= sdPower.value
|
||||
allowPulling.value = true
|
||||
runningJobs.value.push({
|
||||
progress: 0,
|
||||
})
|
||||
isOver.value = false
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('任务推送失败:' + e.message)
|
||||
})
|
||||
.finally(() => {
|
||||
isGenerating.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const showTask = (row) => {
|
||||
item.value = row
|
||||
showTaskDialog.value = true
|
||||
}
|
||||
|
||||
const copyParams = (row) => {
|
||||
params.value = row.params
|
||||
showTaskDialog.value = false
|
||||
}
|
||||
|
||||
const removeImage = (item) => {
|
||||
ElMessageBox.confirm('此操作将会删除任务和图片,继续操作码?', '删除提示', {
|
||||
confirmButtonText: '确认',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
.then(() => {
|
||||
httpGet('/api/sd/remove', { id: item.id })
|
||||
.then(() => {
|
||||
ElMessage.success('任务删除成功')
|
||||
page.value = 0
|
||||
isOver.value = false
|
||||
fetchFinishJobs()
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('任务删除失败:' + e.message)
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
// 发布图片到作品墙
|
||||
const publishImage = (item, action) => {
|
||||
let text = '图片发布'
|
||||
if (action === false) {
|
||||
text = '取消发布'
|
||||
}
|
||||
httpGet('/api/sd/publish', { id: item.id, action: action })
|
||||
.then(() => {
|
||||
ElMessage.success(text + '成功')
|
||||
item.publish = action
|
||||
page.value = 0
|
||||
isOver.value = false
|
||||
item.publish = action
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error(text + '失败:' + e.message)
|
||||
})
|
||||
}
|
||||
|
||||
const promptGenerating = ref(false)
|
||||
const generatePrompt = () => {
|
||||
if (params.value.prompt === '') {
|
||||
return showMessageError('请输入原始提示词')
|
||||
}
|
||||
promptGenerating.value = true
|
||||
httpPost('/api/prompt/image', { prompt: params.value.prompt })
|
||||
.then((res) => {
|
||||
params.value.prompt = res.data
|
||||
promptGenerating.value = false
|
||||
})
|
||||
.catch((e) => {
|
||||
showMessageError('生成提示词失败:' + e.message)
|
||||
promptGenerating.value = false
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@use '../assets/css/image-sd.scss' as *;
|
||||
@use '../assets/css/custom-scroll.scss' as *;
|
||||
</style>
|
||||
@@ -101,7 +101,7 @@ import QRCode from 'qrcode'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
const inviteURL = ref('')
|
||||
const qrImg = ref('/images/wx.png')
|
||||
const qrImg = ref('')
|
||||
const invitePower = ref(0)
|
||||
const hits = ref(0)
|
||||
const regNum = ref(0)
|
||||
|
||||
@@ -88,6 +88,7 @@ onMounted(() => {
|
||||
setUserToken(token.value)
|
||||
handleRegisterSuccess()
|
||||
}
|
||||
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -236,7 +236,13 @@ const generateAI = () => {
|
||||
model_id: modelID.value,
|
||||
})
|
||||
.then((res) => {
|
||||
text.value = res.data
|
||||
const raw = res?.data
|
||||
if (raw == null || String(raw).trim() === '') {
|
||||
ElMessage.error('生成结果为空,请重试或更换模型')
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
text.value = typeof raw === 'string' ? raw : String(raw)
|
||||
content.value = processContent(text.value)
|
||||
const model = getModelById(modelID.value)
|
||||
loginUser.value.power -= model.power
|
||||
|
||||
+387
-197
@@ -1,160 +1,292 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="member custom-scroll"
|
||||
class="custom-scroll min-h-full bg-slate-50 dark:bg-[#0f1117]"
|
||||
v-loading="loading"
|
||||
element-loading-background="rgba(255,255,255,.3)"
|
||||
element-loading-background="rgba(255,255,255,.6)"
|
||||
:element-loading-text="loadingText"
|
||||
>
|
||||
<div class="inner">
|
||||
<el-card class="profile-card">
|
||||
<el-row class="user-opt" :gutter="16">
|
||||
<el-col :span="24">
|
||||
<el-button class="profile-btn email" @click="showBindEmailDialog = true">
|
||||
<i class="iconfont icon-email"></i> 绑定邮箱
|
||||
</el-button>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-button class="profile-btn mobile" @click="showBindMobileDialog = true">
|
||||
<i class="iconfont icon-mobile"></i> 绑定手机
|
||||
</el-button>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-button class="profile-btn password" @click="showPasswordDialog = true">
|
||||
<i class="iconfont icon-password"></i> 修改密码
|
||||
</el-button>
|
||||
</el-col>
|
||||
<el-divider />
|
||||
<el-col :span="24">
|
||||
<el-button class="profile-btn redeem" @click="showRedeemVerifyDialog = true">
|
||||
<i class="iconfont icon-redeem"></i> 卡密兑换
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
<div class="profile-bg"></div>
|
||||
<div class="mx-auto max-w-7xl space-y-6 px-4 py-8">
|
||||
<!-- Page header:左占位 + 居中标题副标题 + 右上「AI 生成」(与截图对齐) -->
|
||||
<div class="flex items-start justify-between gap-2 pt-1">
|
||||
<div class="w-12 shrink-0 sm:w-16" aria-hidden="true" />
|
||||
<div class="min-w-0 flex-1 text-center">
|
||||
<h1 class="m-0 text-[2rem] font-bold leading-snug text-indigo-600 dark:text-indigo-400">
|
||||
会员中心
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-slate-500 dark:text-slate-400">
|
||||
升级算力套餐,让创作效率持续提升
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="product-box">
|
||||
<el-row v-if="list.length > 0" :gutter="24" class="list-box">
|
||||
<el-col
|
||||
v-for="item in list"
|
||||
:key="item"
|
||||
:xs="24"
|
||||
:sm="12"
|
||||
:md="8"
|
||||
:lg="6"
|
||||
class="product-col"
|
||||
<!-- 中间两列:左用户信息 | 右账号操作(截图 1:1) -->
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 md:items-stretch">
|
||||
<!-- 左:用户资料 + 可用积分 -->
|
||||
<div
|
||||
class="flex h-full min-h-0 flex-col rounded-2xl border border-gray-100 bg-white p-6 shadow-sm dark:border-[#2d3448] dark:bg-[#1e2433] dark:shadow-none"
|
||||
>
|
||||
<div
|
||||
v-if="isLogin && user"
|
||||
class="flex w-full flex-1 items-center justify-between gap-5 min-h-0"
|
||||
>
|
||||
<div class="product-item">
|
||||
<div class="product-header">
|
||||
<div class="image-container">
|
||||
<el-image :src="vipImg" fit="cover" />
|
||||
<div class="image-overlay">
|
||||
<div class="vip-badge">热销</div>
|
||||
<div class="flex items-start gap-4">
|
||||
<el-upload
|
||||
class="shrink-0 cursor-pointer"
|
||||
:auto-upload="true"
|
||||
:show-file-list="false"
|
||||
:http-request="afterRead"
|
||||
accept=".png,.jpg,.jpeg,.bmp"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<el-tooltip content="点击上传头像" placement="top" v-if="user.avatar">
|
||||
<el-avatar
|
||||
:src="user.avatar"
|
||||
shape="circle"
|
||||
:size="56"
|
||||
class="block transition-opacity hover:opacity-[0.85]"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<div v-else class="flex flex-col items-center gap-1">
|
||||
<div
|
||||
class="flex h-14 w-14 cursor-pointer items-center justify-center rounded-full bg-indigo-50 text-indigo-600 ring-1 ring-inset ring-indigo-100 transition-colors hover:bg-indigo-100 dark:bg-indigo-500/15 dark:text-indigo-400 dark:ring-indigo-500/30 dark:hover:bg-indigo-500/20"
|
||||
>
|
||||
<el-icon class="text-2xl"><Plus /></el-icon>
|
||||
</div>
|
||||
<span class="text-[10px] text-gray-400 dark:text-slate-500">用户头像</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="product-title">
|
||||
<h3 class="name">{{ item.name }}</h3>
|
||||
<p class="description">算力值:{{ item.power }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="product-content">
|
||||
<div class="price-section">
|
||||
<div class="price-info">
|
||||
<span class="currency">¥</span>
|
||||
<span class="price-value">{{ item.price }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="features-list" v-if="item.features">
|
||||
<div class="feature-item" v-for="feature in item.features" :key="feature">
|
||||
<i class="iconfont icon-check"></i>
|
||||
<span>{{ feature }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="product-actions">
|
||||
<div class="payment-buttons">
|
||||
<button class="payment-btn wechat-btn" @click="wxPay(item)">
|
||||
<i class="iconfont icon-wechat-pay"></i>
|
||||
<span>微信支付</span>
|
||||
</button>
|
||||
<button class="payment-btn alipay-btn" @click="alipay(item)">
|
||||
<i class="iconfont icon-alipay"></i>
|
||||
<span>支付宝</span>
|
||||
</button>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1 pt-0.5">
|
||||
<span
|
||||
class="truncate text-base font-bold text-slate-900 hover:underline dark:text-slate-100"
|
||||
:class="{ 'cursor-pointer': isLogin }"
|
||||
title="点击修改用户名与昵称"
|
||||
@click="isLogin && openNicknameDialog()"
|
||||
>{{ displayName }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-empty description="暂无数据" v-else :image="nodata" />
|
||||
<div class="box-card">
|
||||
<h2 class="headline">消费账单</h2>
|
||||
<div
|
||||
class="flex shrink-0 flex-col !items-start rounded-xl border border-indigo-200/80 bg-indigo-50/90 px-4 py-3.5 dark:border-indigo-500/35 dark:bg-indigo-500/10 min-w-[200px]"
|
||||
>
|
||||
<span class="text-sm font-medium text-indigo-600 dark:text-indigo-400"
|
||||
>可用积分</span
|
||||
>
|
||||
<span
|
||||
class="text-2xl font-bold tabular-nums text-indigo-600 dark:text-indigo-400"
|
||||
>{{ userScore }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex min-h-[120px] flex-1 items-center justify-center">
|
||||
<span class="text-sm text-slate-500 dark:text-slate-500">请先登录以查看账户详情</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="user-order">
|
||||
<user-order v-if="isLogin" :key="userOrderKey" />
|
||||
<!-- 右:快捷操作(与左侧卡片等高) -->
|
||||
<div
|
||||
class="flex h-full min-h-0 flex-col rounded-2xl border border-gray-100 bg-white p-6 shadow-sm dark:border-[#2d3448] dark:bg-[#1e2433] dark:shadow-none"
|
||||
>
|
||||
<div class="flex min-h-0 flex-1 w-full items-center justify-center">
|
||||
<div class="grid w-full grid-cols-2 items-center gap-3 sm:grid-cols-5 sm:gap-3">
|
||||
<button
|
||||
v-for="act in accountActions"
|
||||
:key="act.key"
|
||||
type="button"
|
||||
class="group flex h-[64px] max-w-[100px] w-full cursor-pointer flex-col items-center justify-center gap-1.5 self-center rounded-xl border border-gray-200 bg-white px-2 py-2 transition-colors hover:border-indigo-500 hover:bg-indigo-50/80 dark:border-[#2d3448] dark:bg-[#1e2433] dark:hover:border-indigo-400 dark:hover:bg-indigo-500/12"
|
||||
@click="handleAccountAction(act.key)"
|
||||
>
|
||||
<i
|
||||
class="iconfont text-[1.375rem] text-slate-500 transition-colors group-hover:text-indigo-600 dark:text-slate-400 dark:group-hover:text-indigo-400"
|
||||
:class="act.icon"
|
||||
></i>
|
||||
<span
|
||||
class="text-center text-[0.8125rem] font-medium leading-tight text-slate-800 transition-colors group-hover:text-indigo-600 dark:text-slate-300 dark:group-hover:text-indigo-400"
|
||||
>{{ act.label }}</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 精选套餐 -->
|
||||
<section
|
||||
class="rounded-2xl border border-gray-100 bg-white p-6 shadow-sm dark:border-[#2d3448] dark:bg-[#1e2433] dark:shadow-none"
|
||||
>
|
||||
<div class="mb-6">
|
||||
<h2 class="m-0 text-lg font-bold text-slate-900 dark:text-slate-100">精选套餐</h2>
|
||||
<p class="mt-1.5 text-sm text-slate-500 dark:text-slate-400">
|
||||
多档算力套餐覆盖高频使用场景,灵活支付立即生效。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="list.length > 0"
|
||||
class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
>
|
||||
<div
|
||||
class="relative flex flex-col gap-4 rounded-2xl border border-gray-100 bg-white p-5 shadow-sm transition-[border-color,box-shadow] hover:border-indigo-300 hover:shadow-md dark:border-[#2d3448] dark:bg-[#1e2433] dark:shadow-none dark:hover:border-indigo-500/50 dark:hover:shadow-lg dark:hover:shadow-black/20"
|
||||
v-for="item in list"
|
||||
:key="item.id || item.name"
|
||||
>
|
||||
<div
|
||||
class="absolute right-3 top-3 inline-flex items-center rounded-full border border-indigo-200/80 bg-indigo-50 px-2.5 py-0.5 text-[0.6875rem] font-semibold tracking-wide text-indigo-600 dark:border-indigo-500/30 dark:bg-indigo-500/15 dark:text-indigo-400"
|
||||
>
|
||||
热销
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<el-image
|
||||
class="h-14 w-14 shrink-0 overflow-hidden rounded-xl border border-gray-100 dark:border-[#2d3448]"
|
||||
:src="vipImg"
|
||||
fit="cover"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<h3 class="m-0 text-[0.9375rem] font-bold text-slate-900 dark:text-slate-100">
|
||||
{{ item.name }}
|
||||
</h3>
|
||||
<p class="mt-0.5 text-sm text-slate-500 dark:text-slate-400">
|
||||
算力值:{{ item.power }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-0.5 text-indigo-600 dark:text-indigo-400">
|
||||
<span class="text-base font-semibold">¥</span>
|
||||
<span class="text-[2rem] font-extrabold leading-none">{{ item.price }}</span>
|
||||
</div>
|
||||
<div v-if="item.features" class="flex flex-col gap-2">
|
||||
<div
|
||||
class="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400"
|
||||
v-for="feature in item.features"
|
||||
:key="feature"
|
||||
>
|
||||
<i
|
||||
class="iconfont icon-check shrink-0 text-sm text-green-500 dark:text-green-400"
|
||||
></i>
|
||||
<span>{{ feature }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-auto grid grid-cols-2 gap-2">
|
||||
<el-button
|
||||
class="h-10 w-full text-[0.8125rem] font-medium"
|
||||
type="success"
|
||||
@click="wxPay(item)"
|
||||
>
|
||||
<i class="iconfont icon-wechat-pay mr-1"></i>
|
||||
<span>微信支付</span>
|
||||
</el-button>
|
||||
<el-button
|
||||
class="h-10 w-full text-[0.8125rem] font-medium"
|
||||
color="#1677FF"
|
||||
@click="alipay(item)"
|
||||
>
|
||||
<i class="iconfont icon-alipay mr-1"></i>
|
||||
<span>支付宝</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无数据" :image="nodata" />
|
||||
</section>
|
||||
|
||||
<!-- 消费账单 -->
|
||||
<section
|
||||
class="rounded-2xl border border-gray-100 bg-white p-6 shadow-sm dark:border-[#2d3448] dark:bg-[#1e2433] dark:shadow-none"
|
||||
>
|
||||
<div class="mb-6">
|
||||
<h2 class="m-0 text-lg font-bold text-slate-900 dark:text-slate-100">消费账单</h2>
|
||||
<p class="mt-1.5 text-sm text-slate-500 dark:text-slate-400">
|
||||
实时同步最新订单状态,轻松掌握消费明细。
|
||||
</p>
|
||||
</div>
|
||||
<div class="min-h-[80px] rounded-xl border border-gray-100 dark:border-[#2d3448]">
|
||||
<user-order v-if="isLogin" :key="userOrderKey" />
|
||||
<div v-else class="py-6 text-center text-sm text-slate-500 dark:text-slate-500">
|
||||
登录后可查看完整订单记录
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog
|
||||
v-model="showNicknameDialog"
|
||||
title="修改用户名与昵称"
|
||||
width="400px"
|
||||
:close-on-click-modal="true"
|
||||
@close="showNicknameDialog = false"
|
||||
>
|
||||
<el-form label-width="70px">
|
||||
<el-form-item label="用户名">
|
||||
<el-input
|
||||
v-model="usernameInput"
|
||||
placeholder="请输入用户名"
|
||||
maxlength="30"
|
||||
show-word-limit
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="昵称">
|
||||
<el-input
|
||||
v-model="nicknameInput"
|
||||
placeholder="请输入昵称"
|
||||
maxlength="20"
|
||||
show-word-limit
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showNicknameDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveNickname">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<password-dialog
|
||||
v-if="isLogin"
|
||||
:show="showPasswordDialog"
|
||||
@hide="showPasswordDialog = false"
|
||||
/>
|
||||
|
||||
<el-dialog
|
||||
v-model="showBindMobileDialog"
|
||||
title="绑定手机"
|
||||
width="400px"
|
||||
:close-on-click-modal="true"
|
||||
@close="showBindMobileDialog = false"
|
||||
>
|
||||
<bind-mobile @hide="showBindMobileDialog = false" />
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="showBindEmailDialog"
|
||||
title="绑定邮箱"
|
||||
width="400px"
|
||||
:close-on-click-modal="true"
|
||||
@close="showBindEmailDialog = false"
|
||||
>
|
||||
<bind-email @hide="showBindEmailDialog = false" />
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="showRedeemVerifyDialog"
|
||||
title="卡密兑换"
|
||||
width="450px"
|
||||
:close-on-click-modal="true"
|
||||
@close="showRedeemVerifyDialog = false"
|
||||
>
|
||||
<redeem-verify @hide="redeemCallback" />
|
||||
</el-dialog>
|
||||
</div>
|
||||
|
||||
<password-dialog
|
||||
v-if="isLogin"
|
||||
:show="showPasswordDialog"
|
||||
@hide="showPasswordDialog = false"
|
||||
/>
|
||||
|
||||
<!-- 绑定手机弹窗 -->
|
||||
<el-dialog
|
||||
v-model="showBindMobileDialog"
|
||||
title="绑定手机"
|
||||
width="400px"
|
||||
:close-on-click-modal="true"
|
||||
@close="showBindMobileDialog = false"
|
||||
>
|
||||
<bind-mobile @hide="showBindMobileDialog = false" />
|
||||
</el-dialog>
|
||||
|
||||
<!-- 绑定邮箱弹窗 -->
|
||||
<el-dialog
|
||||
v-model="showBindEmailDialog"
|
||||
title="绑定邮箱"
|
||||
width="400px"
|
||||
:close-on-click-modal="true"
|
||||
@close="showBindEmailDialog = false"
|
||||
>
|
||||
<bind-email @hide="showBindEmailDialog = false" />
|
||||
</el-dialog>
|
||||
|
||||
<!-- 卡密兑换弹窗 -->
|
||||
<el-dialog
|
||||
v-model="showRedeemVerifyDialog"
|
||||
title="卡密兑换"
|
||||
width="450px"
|
||||
:close-on-click-modal="true"
|
||||
@close="showRedeemVerifyDialog = false"
|
||||
>
|
||||
<redeem-verify @hide="redeemCallback" />
|
||||
</el-dialog>
|
||||
</div>
|
||||
|
||||
<!--支付二维码-->
|
||||
<el-dialog
|
||||
v-model="showQrCode"
|
||||
:show-close="true"
|
||||
style="width: 334px; height: 368px"
|
||||
class="pay-dialog"
|
||||
>
|
||||
<el-dialog v-model="showQrCode" :show-close="true" class="!w-[334px] !h-[368px]">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-center text-base">
|
||||
<span style="color: var(--el-text-color-regular)">{{ title }}</span>
|
||||
<div class="flex items-center justify-center text-base text-slate-600 dark:text-slate-400">
|
||||
<span>{{ title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="qr-container">
|
||||
<el-image :src="qrImg" style="height: 300px; width: 300px" />
|
||||
<div ref="qrContainerRef" class="flex items-center justify-center">
|
||||
<el-image :src="qrImg" class="h-[300px] w-[300px]" />
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
@@ -172,18 +304,21 @@ import { useSharedStore } from '@/store/sharedata'
|
||||
import { closeLoading, showLoading } from '@/utils/dialog'
|
||||
import { httpGet, httpPost } from '@/utils/http'
|
||||
import { isMobile } from '@/utils/libs'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import Compressor from 'compressorjs'
|
||||
import QRCode from 'qrcode'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const list = ref([])
|
||||
const vipImg = ref('/images/menu/member.png')
|
||||
const enableReward = ref(false) // 是否启用众筹功能
|
||||
const rewardImg = ref('/images/reward.png')
|
||||
const showPasswordDialog = ref(false)
|
||||
const showBindMobileDialog = ref(false)
|
||||
const showBindEmailDialog = ref(false)
|
||||
const showRedeemVerifyDialog = ref(false)
|
||||
const showNicknameDialog = ref(false)
|
||||
const nicknameInput = ref('')
|
||||
const usernameInput = ref('')
|
||||
const user = ref(null)
|
||||
const isLogin = ref(false)
|
||||
const orderTimeout = ref(1800)
|
||||
@@ -197,11 +332,42 @@ const qrImg = ref('')
|
||||
const title = ref('')
|
||||
const handler = ref(null)
|
||||
|
||||
/** 右侧账号操作按钮(文案 + iconfont 类名 + 点击 key) */
|
||||
const accountActions = [
|
||||
{ key: 'nickname', label: '修改资料', icon: 'icon-user-fill' },
|
||||
{ key: 'email', label: '绑定邮箱', icon: 'icon-email' },
|
||||
{ key: 'mobile', label: '绑定手机', icon: 'icon-mobile' },
|
||||
{ key: 'password', label: '修改密码', icon: 'icon-password' },
|
||||
{ key: 'redeem', label: '卡密兑换', icon: 'icon-redeem' },
|
||||
]
|
||||
|
||||
const displayName = computed(() => {
|
||||
if (!user.value) {
|
||||
return '未登录'
|
||||
}
|
||||
return user.value.nickname || user.value.username || user.value.email || '未设置'
|
||||
})
|
||||
|
||||
const userScore = computed(() => {
|
||||
if (!user.value || typeof user.value.power === 'undefined' || user.value.power === null) {
|
||||
return '--'
|
||||
}
|
||||
return user.value.power
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
checkSession()
|
||||
.then((_user) => {
|
||||
user.value = _user
|
||||
isLogin.value = true
|
||||
// 获取最新用户信息
|
||||
httpGet('/api/user/profile')
|
||||
.then((res) => {
|
||||
user.value = { ...user.value, ...res.data }
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('获取用户信息失败:' + e.message)
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
store.setShowLoginDialog(true)
|
||||
@@ -218,8 +384,6 @@ onMounted(() => {
|
||||
|
||||
getSystemInfo()
|
||||
.then((res) => {
|
||||
rewardImg.value = res.data['reward_img']
|
||||
enableReward.value = res.data['enabled_reward']
|
||||
if (res.data['order_pay_timeout'] > 0) {
|
||||
orderTimeout.value = res.data['order_pay_timeout']
|
||||
}
|
||||
@@ -229,6 +393,85 @@ onMounted(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// 修改昵称
|
||||
const openNicknameDialog = () => {
|
||||
nicknameInput.value = user.value?.nickname || ''
|
||||
usernameInput.value = user.value?.username || ''
|
||||
showNicknameDialog.value = true
|
||||
}
|
||||
|
||||
const handleAccountAction = (key) => {
|
||||
switch (key) {
|
||||
case 'nickname':
|
||||
openNicknameDialog()
|
||||
break
|
||||
case 'email':
|
||||
showBindEmailDialog.value = true
|
||||
break
|
||||
case 'mobile':
|
||||
showBindMobileDialog.value = true
|
||||
break
|
||||
case 'password':
|
||||
showPasswordDialog.value = true
|
||||
break
|
||||
case 'redeem':
|
||||
showRedeemVerifyDialog.value = true
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const saveNickname = () => {
|
||||
httpPost('/api/user/profile/update', {
|
||||
nickname: nicknameInput.value,
|
||||
username: usernameInput.value,
|
||||
})
|
||||
.then(() => {
|
||||
if (user.value) {
|
||||
user.value.nickname = nicknameInput.value
|
||||
user.value.username = usernameInput.value
|
||||
}
|
||||
ElMessage.success({ message: '保存成功', duration: 500 })
|
||||
showNicknameDialog.value = false
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('保存失败:' + e.message)
|
||||
})
|
||||
}
|
||||
|
||||
// 头像上传处理函数
|
||||
const afterRead = (file) => {
|
||||
// 压缩图片并上传
|
||||
new Compressor(file.file, {
|
||||
quality: 0.6,
|
||||
success(result) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', result, result.name)
|
||||
// 执行上传操作
|
||||
httpPost('/api/upload', formData)
|
||||
.then((res) => {
|
||||
user.value.avatar = res.data.url
|
||||
// 自动保存用户信息
|
||||
httpPost('/api/user/profile/update', { avatar: res.data.url })
|
||||
.then(() => {
|
||||
ElMessage.success({ message: '头像上传成功', duration: 500 })
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('保存失败:' + e.message)
|
||||
})
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('图片上传失败:' + e.message)
|
||||
})
|
||||
},
|
||||
error(err) {
|
||||
console.log(err.message)
|
||||
ElMessage.error('图片处理失败')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const selectedPid = ref(0)
|
||||
const wxPay = (product) => {
|
||||
selectedPid.value = product.id
|
||||
@@ -286,7 +529,10 @@ const queryOrder = async (orderNo) => {
|
||||
ElMessage.success('支付成功')
|
||||
showQrCode.value = false
|
||||
// 更新用户积分
|
||||
user.value.scores += res.data.credit
|
||||
if (user.value) {
|
||||
const currentScore = Number(user.value.scores) || 0
|
||||
user.value.scores = currentScore + Number(res.data.credit || 0)
|
||||
}
|
||||
} else {
|
||||
handler.value = setTimeout(() => queryOrder(orderNo), 3000)
|
||||
}
|
||||
@@ -308,59 +554,3 @@ onUnmounted(() => {
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use '@/assets/css/custom-scroll.scss' as *;
|
||||
@use '@/assets/css/member.scss' as *;
|
||||
|
||||
// 支付弹窗样式优化
|
||||
.pay-dialog {
|
||||
.qr-container {
|
||||
text-align: center;
|
||||
position: relative;
|
||||
|
||||
.qr-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.success-text {
|
||||
background: #67c23a;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 支付按钮样式
|
||||
.pay-way {
|
||||
.row {
|
||||
margin: 0;
|
||||
|
||||
.col {
|
||||
padding: 0 5px;
|
||||
|
||||
button {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,748 @@
|
||||
<template>
|
||||
<div class="grid gap-4 p-4 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1.4fr)]">
|
||||
<div>
|
||||
<el-card shadow="never" class="h-full">
|
||||
<template #header>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold">智能 PPT 生成</span>
|
||||
<span class="text-xs text-gray-400">粘贴你的大纲,或上传材料一键生成分镜和配图</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form label-position="top">
|
||||
<!-- 格式 -->
|
||||
<el-form-item label="格式">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div
|
||||
class="flex cursor-pointer flex-col gap-1 rounded-lg border border-gray-200 p-3 transition-colors duration-200"
|
||||
:class="
|
||||
form.mode === 'detailed'
|
||||
? 'border-[var(--el-color-primary)] ring-1 ring-[var(--el-color-primary)]'
|
||||
: 'hover:border-gray-300'
|
||||
"
|
||||
@click="form.mode = 'detailed'"
|
||||
>
|
||||
<span class="text-sm font-semibold">详细演示文稿</span>
|
||||
<span class="text-xs text-gray-400"
|
||||
>一整套包含全文和详情的演示文稿,适合邮件发送或单独阅读。</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="flex cursor-pointer flex-col gap-1 rounded-lg border border-gray-200 p-3 transition-colors duration-200"
|
||||
:class="
|
||||
form.mode === 'slides'
|
||||
? 'border-[var(--el-color-primary)] ring-1 ring-[var(--el-color-primary)]'
|
||||
: 'hover:border-gray-300'
|
||||
"
|
||||
@click="form.mode = 'slides'"
|
||||
>
|
||||
<span class="text-sm font-semibold">演示用幻灯片</span>
|
||||
<span class="text-xs text-gray-400"
|
||||
>简洁直观的幻灯片,附带要点,为演讲提供支持。</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 选择语言 -->
|
||||
<el-form-item label="选择语言">
|
||||
<el-select v-model="form.language" placeholder="请选择语言" class="w-full">
|
||||
<el-option label="中文" value="zh-CN" />
|
||||
<el-option label="English" value="en" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<div class="flex items-center gap-5">
|
||||
<!-- 时长 + PPT 页数 -->
|
||||
<el-form-item label="时长">
|
||||
<div class="flex gap-2">
|
||||
<div
|
||||
class="cursor-pointer rounded-md border border-gray-200 px-5 py-0.5 transition-colors duration-200"
|
||||
:class="
|
||||
form.duration === 'short'
|
||||
? 'border-[var(--el-color-primary)] bg-[var(--el-color-primary)] text-white'
|
||||
: 'hover:border-gray-300'
|
||||
"
|
||||
@click="setDuration('short')"
|
||||
>
|
||||
短
|
||||
</div>
|
||||
<div
|
||||
class="cursor-pointer rounded-md border border-gray-200 px-5 py-0.5 transition-colors duration-200"
|
||||
:class="
|
||||
form.duration === 'default'
|
||||
? 'border-[var(--el-color-primary)] bg-[var(--el-color-primary)] text-white'
|
||||
: 'hover:border-gray-300'
|
||||
"
|
||||
@click="setDuration('default')"
|
||||
>
|
||||
默认
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="PPT 页数">
|
||||
<el-input-number v-model="form.pages" :min="3" :max="30" class="w-full" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<!-- PPT素材来源(二选一) -->
|
||||
<CustomSlideTabs v-model="materialMode">
|
||||
<CustomTabPane name="paste" label="粘贴大纲" width="40%">
|
||||
<el-form-item label="把你的大纲或者内容粘贴在这里" required>
|
||||
<el-input
|
||||
v-model="form.content"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder="添加一份概略提纲,或指定受众、风格和重点,例如:为新手用户创建一套演示文稿,采用大胆活泼的风格,注重分步说明。"
|
||||
@input="onPasteInput"
|
||||
/>
|
||||
</el-form-item>
|
||||
</CustomTabPane>
|
||||
|
||||
<CustomTabPane name="upload" label="上传材料" width="60%">
|
||||
<el-form-item label="上传 PPT 材料(支持 PDF/Word/TXT/Markdown 格式)">
|
||||
<FileUpload
|
||||
v-model="materialFileUrl"
|
||||
:max-count="1"
|
||||
:max-size="10"
|
||||
accept=".pdf,.doc,.docx,.txt,.md,.markdown"
|
||||
/>
|
||||
</el-form-item>
|
||||
</CustomTabPane>
|
||||
</CustomSlideTabs>
|
||||
<el-form-item label="风格与设计要求(可选)">
|
||||
<el-input
|
||||
v-model="form.prompt"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="例如:极简商务风、卡通手绘风"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<div class="mt-3 flex justify-end">
|
||||
<el-button type="primary" :loading="creating" @click="createTask" size="large">
|
||||
<i class="iconfont icon-chuangzuo mr-1"></i>
|
||||
{{ creating ? '任务创建中...' : '生成' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<div class="mt-4 text-xs text-gray-500">
|
||||
<p>小提示:</p>
|
||||
<ul class="mt-1 list-disc pl-[18px]">
|
||||
<li class="mb-0.5">建议使用分层级的大纲(如 Markdown 标题)让分镜更加清晰</li>
|
||||
<li class="mb-0.5">页数会在生成分镜时作为约束,保证故事线完整</li>
|
||||
</ul>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<div v-loading="loadingList">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-base font-semibold">生成任务列表</span>
|
||||
<el-button link type="primary" @click="fetchTaskList" :loading="loadingList"
|
||||
>刷新</el-button
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty v-if="!taskList.length" description="暂无任务,在左侧创建任务吧~" />
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="job in taskList"
|
||||
:key="job.task_id"
|
||||
class="group flex w-full gap-4 overflow-hidden rounded-2xl border border-slate-200/70 bg-white p-4 shadow-sm shadow-slate-900/[0.04] transition-all duration-200 hover:border-slate-300/90 hover:shadow-md hover:shadow-slate-900/[0.06]"
|
||||
>
|
||||
<div
|
||||
class="relative h-20 w-[8.75rem] max-w-[28vw] shrink-0 overflow-hidden rounded-xl bg-slate-100"
|
||||
>
|
||||
<el-image
|
||||
v-if="job.thumb"
|
||||
:src="pptListThumb(job.thumb)"
|
||||
fit="cover"
|
||||
class="h-full w-full cursor-pointer"
|
||||
loading="lazy"
|
||||
@click="openDetail(job.task_id)"
|
||||
/>
|
||||
<div
|
||||
v-else-if="isJobGenerating(job)"
|
||||
class="ppt-list-gen-placeholder flex h-full w-full cursor-pointer flex-col items-center justify-center gap-0.5 px-1"
|
||||
@click="openDetail(job.task_id)"
|
||||
>
|
||||
<el-icon class="is-loading text-[var(--el-color-primary)]"><Loading /></el-icon>
|
||||
<span class="text-center text-[10px] leading-tight text-slate-500">正在生成中</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-full w-full items-center justify-center px-2 text-center text-[11px] leading-snug text-slate-400"
|
||||
>
|
||||
暂无预览
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex min-w-0 w-full flex-1 flex-col gap-2">
|
||||
<!-- 第一行:标题 + 时间 -->
|
||||
<div class="flex min-w-0 w-full items-start justify-between gap-3">
|
||||
<h3
|
||||
class="line-clamp-2 min-w-0 flex-1 text-[15px] font-medium leading-snug tracking-tight text-slate-900"
|
||||
>
|
||||
{{ job.title || job.task_id }}
|
||||
</h3>
|
||||
<time
|
||||
class="shrink-0 whitespace-nowrap text-xs tabular-nums text-slate-400"
|
||||
:datetime="String(job.created_at)"
|
||||
>
|
||||
{{ formatTime(job.created_at) }}
|
||||
</time>
|
||||
</div>
|
||||
<!-- 第二行:状态/页数 与 操作 同一行,100% 宽、两端对齐 -->
|
||||
<div
|
||||
class="flex w-full min-w-0 items-center justify-between gap-3 text-xs"
|
||||
@click.stop
|
||||
>
|
||||
<div class="flex min-w-0 shrink items-center gap-x-3 text-slate-500">
|
||||
<el-tag :type="statusTagType(job.status)" size="small" effect="plain">
|
||||
{{ statusText(job.status) }}
|
||||
</el-tag>
|
||||
<span class="tabular-nums text-slate-600"
|
||||
>{{ job.completed_slides }} / {{ job.total_slides }} 页</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-shrink-0 items-center justify-end gap-x-3 sm:gap-x-4">
|
||||
<el-popover placement="bottom-start" :width="340" trigger="click">
|
||||
<template #default>
|
||||
<div
|
||||
class="max-h-60 overflow-y-auto whitespace-pre-wrap break-words text-sm text-slate-700"
|
||||
>
|
||||
{{ job.prompt || '(无补充提示)' }}
|
||||
</div>
|
||||
</template>
|
||||
<template #reference>
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
link
|
||||
class="!px-0 !text-xs font-normal"
|
||||
>
|
||||
查看提示词
|
||||
</el-button>
|
||||
</template>
|
||||
</el-popover>
|
||||
<el-button
|
||||
size="small"
|
||||
link
|
||||
class="!px-0 !text-xs font-normal text-slate-600"
|
||||
@click="openContentDialog(job.task_id)"
|
||||
>
|
||||
查看大纲
|
||||
</el-button>
|
||||
<el-tooltip
|
||||
v-if="canResumeJob(job)"
|
||||
content="从中断处继续生成缺页配图(处理中不可重复点击)"
|
||||
placement="top"
|
||||
>
|
||||
<span class="inline-flex">
|
||||
<el-button
|
||||
size="small"
|
||||
link
|
||||
type="primary"
|
||||
class="!px-0 !text-xs font-normal"
|
||||
:disabled="job.status === 'processing'"
|
||||
:loading="resumeLoadingId === job.task_id"
|
||||
@click="resumeTask(job.task_id)"
|
||||
>
|
||||
继续生成
|
||||
</el-button>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
<el-dropdown
|
||||
v-if="job.status === 'completed' && job.completed_slides > 0"
|
||||
trigger="click"
|
||||
@command="(cmd) => onListExportCommand(cmd, job)"
|
||||
:disabled="exportLoadingKey === `${job.task_id}:list`"
|
||||
>
|
||||
<span class="inline-flex items-center">
|
||||
<el-button
|
||||
size="small"
|
||||
link
|
||||
type="primary"
|
||||
class="!px-0 !text-xs font-normal"
|
||||
:loading="exportLoadingKey === `${job.task_id}:list`"
|
||||
>
|
||||
导出
|
||||
<el-icon class="ml-0.5 text-[10px]"><ArrowDown /></el-icon>
|
||||
</el-button>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="pdf">导出 PDF</el-dropdown-item>
|
||||
<el-dropdown-item command="pptx">导出 PPTX</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<el-popconfirm
|
||||
v-if="['completed', 'failed'].includes(job.status)"
|
||||
confirm-button-text="确认"
|
||||
cancel-button-text="取消"
|
||||
title="确定删除该任务及生成的图片吗?"
|
||||
@confirm="deleteTask(job.task_id)"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button type="danger" link size="small" class="!px-0 !text-xs font-normal">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="job.status === 'failed' && job.error_message"
|
||||
class="rounded-lg bg-red-50/80 px-2.5 py-1.5 text-xs leading-relaxed text-red-700"
|
||||
>
|
||||
{{ job.error_message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="pagination.total > pagination.page_size" class="mt-4 flex justify-end">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.page_size"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:total="pagination.total"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@current-change="fetchTaskList"
|
||||
@size-change="
|
||||
() => {
|
||||
pagination.page = 1
|
||||
fetchTaskList()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<PPTPreviewDialog
|
||||
v-model="previewVisible"
|
||||
:task-id="previewTaskId"
|
||||
endpoint-base="/api/v1/tasks"
|
||||
:editable="true"
|
||||
@updated="fetchTaskList"
|
||||
@closed="onPreviewClosed"
|
||||
/>
|
||||
|
||||
<!-- 大纲全文(懒加载) -->
|
||||
<el-dialog
|
||||
v-model="contentDialogVisible"
|
||||
title="PPT 大纲内容"
|
||||
width="min(640px, 92vw)"
|
||||
class="content-outline-dialog"
|
||||
destroy-on-close
|
||||
@closed="onContentDialogClosed"
|
||||
>
|
||||
<div v-if="contentDialogLoading" class="flex items-center justify-center py-12 text-gray-400">
|
||||
<el-icon class="is-loading mr-2"><Loading /></el-icon>
|
||||
加载中...
|
||||
</div>
|
||||
<el-input
|
||||
v-else
|
||||
:model-value="contentDialogText"
|
||||
type="textarea"
|
||||
:rows="18"
|
||||
readonly
|
||||
class="font-mono text-sm"
|
||||
/>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import axios from 'axios'
|
||||
import { getThumbURL, replaceImg } from '@/utils/libs'
|
||||
import { httpDownload, httpGet, httpPost } from '@/utils/http'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ArrowDown, Loading } from '@element-plus/icons-vue'
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import FileUpload from '@/components/FileUpload.vue'
|
||||
import CustomSlideTabs from '@/components/ui/CustomSlideTabs.vue'
|
||||
import CustomTabPane from '@/components/ui/CustomTabPane.vue'
|
||||
import PPTPreviewDialog from '@/components/ppt/PPTPreviewDialog.vue'
|
||||
|
||||
const form = ref({
|
||||
content: '',
|
||||
prompt: '',
|
||||
mode: 'detailed', // detailed | slides
|
||||
language: 'zh-CN',
|
||||
duration: 'default', // short | default
|
||||
pages: 10,
|
||||
})
|
||||
|
||||
const materialFileUrl = ref('')
|
||||
const materialMode = ref('paste')
|
||||
|
||||
watch(
|
||||
() => materialFileUrl.value,
|
||||
(val) => {
|
||||
// 当用户完成上传后,切换到“上传模式”,并清空粘贴内容,保证二选一。
|
||||
if (val) {
|
||||
materialMode.value = 'upload'
|
||||
form.value.content = ''
|
||||
} else if (materialMode.value === 'upload') {
|
||||
// 清空文件后回到粘贴模式,避免卡住。
|
||||
materialMode.value = 'paste'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => materialMode.value,
|
||||
(val) => {
|
||||
if (val === 'paste') {
|
||||
// 切到粘贴模式则清空已上传文件,确保二选一。
|
||||
if (materialFileUrl.value) materialFileUrl.value = ''
|
||||
} else if (val === 'upload') {
|
||||
// 切到上传模式则清空粘贴内容,确保二选一。
|
||||
if ((form.value.content || '').trim()) form.value.content = ''
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const onPasteInput = () => {
|
||||
const v = (form.value.content || '').trim()
|
||||
// 当用户开始粘贴时,清空已上传文件,保证二选一。
|
||||
if (v) {
|
||||
materialMode.value = 'paste'
|
||||
materialFileUrl.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const setDuration = (d) => {
|
||||
form.value.duration = d
|
||||
form.value.pages = d === 'short' ? 6 : 10
|
||||
}
|
||||
|
||||
const creating = ref(false)
|
||||
const taskList = ref([])
|
||||
const loadingList = ref(false)
|
||||
const pagination = ref({ page: 1, page_size: 20, total: 0 })
|
||||
let listPollTimer = null
|
||||
|
||||
const previewVisible = ref(false)
|
||||
const previewTaskId = ref('')
|
||||
|
||||
/** 与 Image.vue 一致:缩略图 URL(App.vue 已 initThumbTemplate) */
|
||||
function pptThumb(url, width, height = 0) {
|
||||
if (!url) return ''
|
||||
return getThumbURL(replaceImg(url), width, height)
|
||||
}
|
||||
const pptListThumb = (url) => pptThumb(url, 280, 0)
|
||||
|
||||
/** 列表行 / 详情 导出中:`${taskId}:list` | `${taskId}:detail` */
|
||||
const exportLoadingKey = ref('')
|
||||
/** 继续生成请求中:当前 task_id */
|
||||
const resumeLoadingId = ref('')
|
||||
|
||||
const canResumeJob = (job) => {
|
||||
if (!job || !job.task_id) return false
|
||||
const st = job.status
|
||||
if (st === 'completed' || st === 'processing') return false
|
||||
const total = Number(job.total_slides) || 0
|
||||
const done = Number(job.completed_slides) || 0
|
||||
return total > 0 && done < total
|
||||
}
|
||||
|
||||
/** 列表缩略图:排队/生成中且无封面图时显示「正在生成」动效 */
|
||||
const isJobGenerating = (job) => {
|
||||
if (!job) return false
|
||||
return ['pending', 'processing'].includes(job.status)
|
||||
}
|
||||
|
||||
const safeExportFileBase = (title, taskId) => {
|
||||
const raw = (title && String(title).trim()) || taskId || 'export'
|
||||
return String(raw)
|
||||
.replace(/[/\\:*?"<>|]/g, '_')
|
||||
.slice(0, 120)
|
||||
}
|
||||
|
||||
const runPptExportDownload = async (taskId, format, baseName, loadingSlot) => {
|
||||
const ext = format === 'pdf' ? '.pdf' : '.pptx'
|
||||
const url = `/api/v1/tasks/${encodeURIComponent(taskId)}/export?format=${encodeURIComponent(format)}`
|
||||
exportLoadingKey.value = `${taskId}:${loadingSlot}`
|
||||
try {
|
||||
const response = await httpDownload(url)
|
||||
if (response.status !== 200) {
|
||||
let msg = '导出失败'
|
||||
try {
|
||||
const text = await response.data.text()
|
||||
const j = JSON.parse(text)
|
||||
msg = j.message || j.msg || msg
|
||||
} catch {
|
||||
msg = '导出失败'
|
||||
}
|
||||
throw new Error(msg)
|
||||
}
|
||||
const blob = new Blob([response.data])
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(blob)
|
||||
link.download = `${safeExportFileBase(baseName, taskId)}${ext}`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(link.href)
|
||||
ElMessage.success('已开始下载')
|
||||
} catch (e) {
|
||||
const msg = typeof e === 'string' ? e : e?.message || e?.response?.data?.message || '未知错误'
|
||||
ElMessage.error('导出失败:' + msg)
|
||||
} finally {
|
||||
exportLoadingKey.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const onListExportCommand = (cmd, job) => {
|
||||
if (cmd !== 'pdf' && cmd !== 'pptx') return
|
||||
runPptExportDownload(job.task_id, cmd, job.title || job.task_id, 'list')
|
||||
}
|
||||
|
||||
const resumeTask = async (taskId) => {
|
||||
if (!taskId || resumeLoadingId.value) return
|
||||
resumeLoadingId.value = taskId
|
||||
try {
|
||||
await httpPost(`/api/v1/tasks/${encodeURIComponent(taskId)}/resume`, {})
|
||||
ElMessage.success('已继续生成,请稍候…')
|
||||
fetchTaskList()
|
||||
startListPolling()
|
||||
} catch (e) {
|
||||
const msg = typeof e === 'string' ? e : e?.message || e?.response?.data?.message || '未知错误'
|
||||
ElMessage.error('继续生成失败:' + msg)
|
||||
} finally {
|
||||
resumeLoadingId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const statusText = (status) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return '排队中'
|
||||
case 'processing':
|
||||
return '生成中'
|
||||
case 'completed':
|
||||
return '已完成'
|
||||
case 'failed':
|
||||
return '已失败'
|
||||
default:
|
||||
return status || '未知'
|
||||
}
|
||||
}
|
||||
|
||||
const statusTagType = (status) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'info'
|
||||
case 'processing':
|
||||
return 'warning'
|
||||
case 'completed':
|
||||
return 'success'
|
||||
case 'failed':
|
||||
return 'danger'
|
||||
default:
|
||||
return 'info'
|
||||
}
|
||||
}
|
||||
|
||||
const createTask = () => {
|
||||
creating.value = true
|
||||
|
||||
const hasFile = !!materialFileUrl.value
|
||||
const content = (form.value.content || '').trim()
|
||||
if (!hasFile && !content) {
|
||||
ElMessage.warning('请先填写演示文稿大纲,或上传 PPT 材料文件')
|
||||
creating.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (hasFile) {
|
||||
httpPost('/api/v1/tasks/generate-slides/from-file', {
|
||||
file_url: materialFileUrl.value,
|
||||
prompt: form.value.prompt || '',
|
||||
language: form.value.language || 'zh-CN',
|
||||
pages: form.value.pages || 10,
|
||||
mode: form.value.mode || 'slides',
|
||||
})
|
||||
.then(() => {
|
||||
ElMessage.success('任务创建成功,正在生成 PPT...')
|
||||
fetchTaskList()
|
||||
startListPolling()
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('创建任务失败:' + (e?.message || 'unknown error'))
|
||||
})
|
||||
.finally(() => {
|
||||
creating.value = false
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
httpPost('/api/v1/tasks/generate-slides', {
|
||||
content: content,
|
||||
prompt: form.value.prompt,
|
||||
language: form.value.language || 'zh-CN',
|
||||
pages: form.value.pages || 10,
|
||||
mode: form.value.mode || 'slides',
|
||||
})
|
||||
.then(() => {
|
||||
ElMessage.success('任务创建成功,正在生成 PPT...')
|
||||
fetchTaskList()
|
||||
startListPolling()
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('创建任务失败:' + e.message)
|
||||
})
|
||||
.finally(() => {
|
||||
creating.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const fetchTaskList = () => {
|
||||
loadingList.value = true
|
||||
httpGet('/api/v1/tasks', {
|
||||
page: pagination.value.page,
|
||||
page_size: pagination.value.page_size,
|
||||
})
|
||||
.then((res) => {
|
||||
const data = res.data || {}
|
||||
taskList.value = data.jobs || []
|
||||
pagination.value.total = data.total ?? 0
|
||||
const hasActive = (data.jobs || []).some((j) => ['pending', 'processing'].includes(j.status))
|
||||
if (hasActive) {
|
||||
startListPolling()
|
||||
} else {
|
||||
stopListPolling()
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('获取任务列表失败:' + e.message)
|
||||
})
|
||||
.finally(() => {
|
||||
loadingList.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const deleteTask = async (taskId) => {
|
||||
try {
|
||||
await axios.delete(`/api/v1/tasks/${taskId}`)
|
||||
ElMessage.success('删除成功')
|
||||
fetchTaskList()
|
||||
if (previewVisible.value && previewTaskId.value === taskId) {
|
||||
previewVisible.value = false
|
||||
onPreviewClosed()
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e?.message || e?.response?.data?.message || '删除失败'
|
||||
ElMessage.error('删除失败:' + msg)
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (ts) => {
|
||||
if (!ts) return ''
|
||||
const d = new Date(ts * 1000)
|
||||
return d.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
const startListPolling = () => {
|
||||
stopListPolling()
|
||||
listPollTimer = setInterval(() => fetchTaskList(), 3000)
|
||||
}
|
||||
|
||||
const stopListPolling = () => {
|
||||
if (listPollTimer) {
|
||||
clearInterval(listPollTimer)
|
||||
listPollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const openDetail = (taskId) => {
|
||||
previewTaskId.value = taskId
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
const onPreviewClosed = () => {
|
||||
previewTaskId.value = ''
|
||||
}
|
||||
|
||||
const contentDialogVisible = ref(false)
|
||||
const contentDialogLoading = ref(false)
|
||||
const contentDialogText = ref('')
|
||||
|
||||
const openContentDialog = (taskId) => {
|
||||
contentDialogVisible.value = true
|
||||
contentDialogLoading.value = true
|
||||
contentDialogText.value = ''
|
||||
httpGet(`/api/v1/tasks/${taskId}`)
|
||||
.then((res) => {
|
||||
const data = res?.data || {}
|
||||
contentDialogText.value = data.content || ''
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('加载大纲失败:' + e.message)
|
||||
contentDialogVisible.value = false
|
||||
})
|
||||
.finally(() => {
|
||||
contentDialogLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const onContentDialogClosed = () => {
|
||||
contentDialogText.value = ''
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchTaskList()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopListPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 减少预览弹窗内容区顶部留白,使右侧缩略图与主图对齐更紧凑 */
|
||||
.detail-dialog :deep(.el-dialog__body) {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.content-outline-dialog :deep(.el-textarea__inner) {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
/* 未出图占位:固定 16:9 区域 + 微光 + 与 el-icon Loading 的旋转动画一致 */
|
||||
@keyframes ppt-shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ppt-gen-surface {
|
||||
background: linear-gradient(105deg, #e4e7ec 0%, #ebeef3 35%, #f4f6f9 50%, #e4e7ec 100%);
|
||||
background-size: 220% 100%;
|
||||
animation: ppt-shimmer 2.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ppt-gen-surface--thumb {
|
||||
animation-duration: 1.6s;
|
||||
}
|
||||
|
||||
.ppt-list-gen-placeholder {
|
||||
background: linear-gradient(105deg, #e4e7ec 0%, #ebeef3 40%, #f4f6f9 55%, #e4e7ec 100%);
|
||||
background-size: 220% 100%;
|
||||
animation: ppt-shimmer 2.2s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -335,8 +335,8 @@
|
||||
</div>
|
||||
<!-- 标签 -->
|
||||
<div class="song-tags">
|
||||
<span v-if="item.major_model_version" class="model-tag">
|
||||
{{ item.major_model_version }}
|
||||
<span v-if="item.params.model" class="model-tag">
|
||||
{{ item.params.model }}
|
||||
</span>
|
||||
<span v-if="item.type === 4" class="upload-tag">
|
||||
<i class="iconfont icon-upload mr-1"></i>用户上传
|
||||
|
||||
+364
-660
File diff suppressed because it is too large
Load Diff
@@ -102,7 +102,7 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="API KEY:" prop="value">
|
||||
<el-input v-model="item.value" autocomplete="off" />
|
||||
<el-input v-model="item.value" type="password" show-password autocomplete="off" />
|
||||
</el-form-item>
|
||||
<el-form-item label="API URL:" prop="api_url">
|
||||
<el-input
|
||||
@@ -157,8 +157,7 @@ const title = ref('')
|
||||
const types = ref([
|
||||
{ label: '对话', value: 'chat' },
|
||||
{ label: 'Midjourney', value: 'mj' },
|
||||
{ label: 'Stable-Diffusion', value: 'sd' },
|
||||
{ label: 'DALL-E', value: 'dalle' },
|
||||
{ label: 'AI图像生成', value: 'image' },
|
||||
{ label: 'Suno文生歌', value: 'suno' },
|
||||
{ label: 'Luma视频', value: 'luma' },
|
||||
{ label: '可灵视频', value: 'keling' },
|
||||
|
||||
+11
-166
@@ -5,16 +5,6 @@
|
||||
</div>
|
||||
<el-row>
|
||||
<el-table :data="tableData" border>
|
||||
<el-table-column type="expand">
|
||||
<template #default="props">
|
||||
<div>
|
||||
<el-table :data="props.row.context" :border="childBorder">
|
||||
<el-table-column label="对话应用" prop="role" width="120" />
|
||||
<el-table-column label="对话内容" prop="content" />
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="应用名称" prop="name">
|
||||
<template #default="scope">
|
||||
<span class="sort" :data-id="scope.row.id">
|
||||
@@ -24,7 +14,6 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="应用类型" prop="type_name" />
|
||||
<el-table-column label="应用标识" prop="key" />
|
||||
<el-table-column label="绑定模型" prop="model_name" />
|
||||
<el-table-column label="启用状态">
|
||||
<template #default="scope">
|
||||
@@ -72,10 +61,6 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="应用标志:" prop="key">
|
||||
<el-input v-model="role.key" autocomplete="off" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="应用图标:" prop="icon">
|
||||
<el-input v-model="role.icon">
|
||||
<template #append>
|
||||
@@ -96,85 +81,13 @@
|
||||
<el-input v-model="role.hello_msg" autocomplete="off" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="上下文信息:" prop="context">
|
||||
<template #default>
|
||||
<el-table :data="role.context" :border="childBorder" size="small">
|
||||
<el-table-column label="对话应用" width="120">
|
||||
<template #default="scope">
|
||||
<el-select v-model="scope.row.role" placeholder="Role">
|
||||
<el-option
|
||||
v-for="value in messageRoles"
|
||||
:key="value"
|
||||
:label="value"
|
||||
:value="value"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="对话内容">
|
||||
<template #header>
|
||||
<div class="context-msg-key">
|
||||
<span>对话内容</span>
|
||||
<span class="fr">
|
||||
<el-button type="primary" @click="addContext" size="small">
|
||||
<el-icon>
|
||||
<Plus />
|
||||
</el-icon>
|
||||
增加一行
|
||||
</el-button>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #default="scope">
|
||||
<div class="context-msg-content">
|
||||
<el-input
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
v-model="scope.row.content"
|
||||
autocomplete="off"
|
||||
v-loading="isGenerating"
|
||||
/>
|
||||
<span class="remove-item">
|
||||
<el-tooltip effect="dark" content="删除当前行" placement="right">
|
||||
<el-button circle type="danger" size="small">
|
||||
<el-icon @click="removeContext(scope.$index)"><Delete /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
|
||||
<el-popover placement="right" :width="400" trigger="click">
|
||||
<template #reference>
|
||||
<el-button type="primary" circle size="small" class="icon-btn">
|
||||
<i class="iconfont icon-linggan"></i>
|
||||
</el-button>
|
||||
</template>
|
||||
<el-input
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
v-model="metaPrompt"
|
||||
autocomplete="off"
|
||||
placeholder="请您输入要 AI实现的目标,任务或者需要AI扮演的角色?"
|
||||
/>
|
||||
<el-row class="text-line">
|
||||
<el-text type="info" size="small">使用 AI 生成 System 预设指令</el-text>
|
||||
<el-button
|
||||
class="generate-btn"
|
||||
size="small"
|
||||
@click="generatePrompt(scope.row)"
|
||||
color="#5865f2"
|
||||
:disabled="isGenerating"
|
||||
>
|
||||
<i class="iconfont icon-chuangzuo"></i>
|
||||
<span>立即生成</span>
|
||||
</el-button>
|
||||
</el-row>
|
||||
</el-popover>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<el-form-item label="预设提示词:" prop="system_prompt">
|
||||
<el-input
|
||||
v-model="role.system_prompt"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder="请输入系统预设提示词,将作为该应用的系统指令"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="启用状态">
|
||||
@@ -193,10 +106,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { showMessageError } from '@/utils/dialog'
|
||||
import { httpGet, httpPost } from '@/utils/http'
|
||||
import { copyObj, removeArrayItem } from '@/utils/libs'
|
||||
import { Delete, Plus } from '@element-plus/icons-vue'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import Compressor from 'compressorjs'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Sortable } from 'sortablejs'
|
||||
@@ -207,14 +119,13 @@ const parentBorder = ref(true)
|
||||
const childBorder = ref(true)
|
||||
const tableData = ref([])
|
||||
const sortedTableData = ref([])
|
||||
const role = ref({ context: [] })
|
||||
const role = ref({ system_prompt: '' })
|
||||
const formRef = ref(null)
|
||||
const optTitle = ref('')
|
||||
const loading = ref(true)
|
||||
|
||||
const rules = reactive({
|
||||
name: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
key: [{ required: true, message: '请输入应用标识', trigger: 'blur' }],
|
||||
name: [{ required: true, message: '请输入应用名称', trigger: 'blur' }],
|
||||
icon: [{ required: true, message: '请输入应用图标', trigger: 'blur' }],
|
||||
sort: [
|
||||
{ required: true, message: '请输入排序数字', trigger: 'blur' },
|
||||
@@ -225,7 +136,6 @@ const rules = reactive({
|
||||
|
||||
const appTypes = ref([])
|
||||
const models = ref([])
|
||||
const messageRoles = ref(['system', 'user', 'assistant'])
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
|
||||
@@ -320,7 +230,7 @@ const rowEdit = function (index, row) {
|
||||
|
||||
const addRole = function () {
|
||||
optTitle.value = '添加新应用'
|
||||
role.value = { context: [] }
|
||||
role.value = { system_prompt: '', enable: true }
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
@@ -353,17 +263,6 @@ const removeRole = function (row) {
|
||||
})
|
||||
}
|
||||
|
||||
const addContext = function () {
|
||||
if (!role.value.context) {
|
||||
role.value.context = []
|
||||
}
|
||||
role.value.context.push({ role: '', content: '' })
|
||||
}
|
||||
|
||||
const removeContext = function (index) {
|
||||
role.value.context.splice(index, 1)
|
||||
}
|
||||
|
||||
// 图片上传
|
||||
const uploadImg = (file) => {
|
||||
// 压缩图片并上传
|
||||
@@ -387,24 +286,6 @@ const uploadImg = (file) => {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const isGenerating = ref(false)
|
||||
const metaPrompt = ref('')
|
||||
const generatePrompt = (row) => {
|
||||
if (metaPrompt.value === '') {
|
||||
return showMessageError('请输入元提示词')
|
||||
}
|
||||
isGenerating.value = true
|
||||
httpPost('/api/prompt/meta', { prompt: metaPrompt.value })
|
||||
.then((res) => {
|
||||
row.content = res.data
|
||||
isGenerating.value = false
|
||||
})
|
||||
.catch((e) => {
|
||||
showMessageError('生成失败:' + e.message)
|
||||
isGenerating.value = false
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -428,32 +309,6 @@ const generatePrompt = (row) => {
|
||||
}
|
||||
}
|
||||
|
||||
.context-msg-key {
|
||||
.fr {
|
||||
float: right;
|
||||
|
||||
.el-icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.context-msg-content {
|
||||
display: flex;
|
||||
|
||||
.remove-item {
|
||||
display: flex;
|
||||
padding: 10px;
|
||||
flex-flow: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.icon-btn {
|
||||
margin: 10px 0 0 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.el-input--small {
|
||||
width: 30px;
|
||||
|
||||
@@ -476,14 +331,4 @@ const generatePrompt = (row) => {
|
||||
justify-content: right;
|
||||
}
|
||||
}
|
||||
|
||||
.text-line {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-top: 10px;
|
||||
.iconfont {
|
||||
margin-right: 5px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -47,11 +47,11 @@
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="密码:" prop="password">
|
||||
<el-input v-model="item.password" type="password" autocomplete="off" />
|
||||
<el-input v-model="item.password" type="password" show-password autocomplete="off" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="重复密码:" prop="repass">
|
||||
<el-input v-model="item.repass" type="password" autocomplete="off" />
|
||||
<el-input v-model="item.repass" type="password" show-password autocomplete="off" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
|
||||
+102
-26
@@ -1,12 +1,13 @@
|
||||
<template>
|
||||
<div class="container user-list" v-loading="loading">
|
||||
<div class="handle-box">
|
||||
<div class="handle-box flex">
|
||||
<el-input v-model="query.username" placeholder="账号" class="handle-input mr10"></el-input>
|
||||
<el-input v-model="query.mobile" placeholder="手机" class="handle-input mr10"></el-input>
|
||||
<el-input v-model="query.email" placeholder="邮箱" class="handle-input mr10"></el-input>
|
||||
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
|
||||
<el-button type="success" :icon="Plus" @click="addUser">新增用户</el-button>
|
||||
<el-button type="danger" :icon="Delete" @click="multipleDelete">删除</el-button>
|
||||
<el-button type="warning" @click="showImportDialog = true">导入用户</el-button>
|
||||
</div>
|
||||
|
||||
<el-row>
|
||||
@@ -121,7 +122,13 @@
|
||||
<el-input v-model="user.email" autocomplete="off" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="add" label="密码:" prop="password">
|
||||
<el-input v-model="user.password" autocomplete="off" placeholder="8-16位" />
|
||||
<el-input
|
||||
v-model="user.password"
|
||||
type="password"
|
||||
show-password
|
||||
autocomplete="off"
|
||||
placeholder="8-16位"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="剩余算力:" prop="power">
|
||||
<el-input v-model.number="user.power" autocomplete="off" placeholder="0" />
|
||||
@@ -138,17 +145,6 @@
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="应用权限" prop="chat_roles">
|
||||
<el-select
|
||||
v-model="user.chat_roles"
|
||||
multiple
|
||||
:filterable="true"
|
||||
placeholder="选择 AI 应用"
|
||||
>
|
||||
<el-option v-for="item in roles" :key="item.key" :label="item.name" :value="item.key" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="模型权限" prop="chat_models">
|
||||
<el-select
|
||||
v-model="user.chat_models"
|
||||
@@ -184,7 +180,7 @@
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="新密码:">
|
||||
<el-input v-model="pass.password" autocomplete="off" />
|
||||
<el-input v-model="pass.password" type="password" show-password autocomplete="off" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
@@ -203,12 +199,51 @@
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="showImportDialog" title="批量导入用户" width="50%">
|
||||
<div class="mb-2 text-sm text-gray-500 flex items-center justify-between">
|
||||
<span>仅支持 .xlsx 格式的 Excel 文件。请先下载模板,按要求填写后再上传。</span>
|
||||
<el-button type="primary" link @click="downloadTemplate">下载模板</el-button>
|
||||
</div>
|
||||
<el-upload
|
||||
class="upload-demo"
|
||||
drag
|
||||
:auto-upload="true"
|
||||
:show-file-list="false"
|
||||
:before-upload="beforeImportUpload"
|
||||
:http-request="doImport"
|
||||
accept=".xlsx"
|
||||
>
|
||||
<el-icon class="el-icon--upload"><DocumentCopy /></el-icon>
|
||||
<div class="el-upload__text">将文件拖到此处,或 <em>点击上传</em></div>
|
||||
<template #tip>
|
||||
<div class="el-upload__tip text-gray-500 text-sm">
|
||||
只支持扩展名为 .xlsx 的 Excel 文件。
|
||||
</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
|
||||
<div v-if="importResult" class="mt-4 text-sm">
|
||||
<p>导入成功:{{ importResult.success }} 条,失败:{{ importResult.failed }} 条。</p>
|
||||
<ul v-if="importResult.errors && importResult.errors.length" class="mt-2 text-red-500">
|
||||
<li v-for="err in importResult.errors" :key="err.row">
|
||||
第 {{ err.row }} 行:{{ err.error }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="showImportDialog = false">关闭</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { closeLoading, showLoading, showMessageError, showMessageOK } from '@/utils/dialog'
|
||||
import { httpGet, httpPost } from '@/utils/http'
|
||||
import { httpDownload, httpGet, httpPost } from '@/utils/http'
|
||||
import { dateFormat, disabledDate } from '@/utils/libs'
|
||||
import { Delete, DocumentCopy, Plus, Search } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
@@ -221,12 +256,13 @@ const query = ref({ username: '', page: 1, page_size: 15 })
|
||||
const title = ref('添加用户')
|
||||
const vipImg = ref('/images/menu/member.png')
|
||||
const add = ref(true)
|
||||
const user = ref({ chat_roles: [], chat_models: [] })
|
||||
const user = ref({ chat_models: [] })
|
||||
const pass = ref({ username: '', password: '', id: 0 })
|
||||
const roles = ref([])
|
||||
const models = ref([])
|
||||
const showUserEditDialog = ref(false)
|
||||
const showResetPassDialog = ref(false)
|
||||
const showImportDialog = ref(false)
|
||||
const importResult = ref(null)
|
||||
const rules = reactive({
|
||||
username: [{ required: true, message: '请输入账号', trigger: 'blur' }],
|
||||
password: [
|
||||
@@ -250,15 +286,6 @@ const userEditFormRef = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
fetchUserList(users.value.page, users.value.page_size)
|
||||
// 获取角色列表
|
||||
httpGet('/api/admin/role/list')
|
||||
.then((res) => {
|
||||
roles.value = res.data
|
||||
})
|
||||
.catch(() => {
|
||||
ElMessage.error('获取聊天角色失败')
|
||||
})
|
||||
|
||||
httpGet('/api/admin/model/list')
|
||||
.then((res) => {
|
||||
models.value = res.data
|
||||
@@ -437,6 +464,55 @@ const copyUsername = (username) => {
|
||||
ElMessage.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 下载导入模板
|
||||
const downloadTemplate = () => {
|
||||
httpDownload('/api/admin/user/import/template')
|
||||
.then((response) => {
|
||||
const blob = new Blob([response.data])
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.setAttribute('download', 'user_import_template.xlsx')
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('下载模板失败:' + (e.message || '未知错误'))
|
||||
})
|
||||
}
|
||||
|
||||
// 导入前校验
|
||||
const beforeImportUpload = (file) => {
|
||||
const ext = file.name.slice(file.name.lastIndexOf('.')).toLowerCase()
|
||||
if (ext !== '.xlsx') {
|
||||
ElMessage.error('只支持 .xlsx 格式的 Excel 文件')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 执行导入
|
||||
const doImport = (option) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', option.file)
|
||||
|
||||
httpPost('/api/admin/user/import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
.then((res) => {
|
||||
importResult.value = res.data
|
||||
ElMessage.success('导入完成')
|
||||
fetchUserList(users.value.page, users.value.page_size)
|
||||
option.onSuccess(res)
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('导入失败:' + (e.message || '未知错误'))
|
||||
option.onError(e)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
|
||||
@@ -46,56 +46,54 @@
|
||||
|
||||
<!-- 搜索筛选 -->
|
||||
<el-card class="filter-card" shadow="never">
|
||||
<el-form :model="queryForm" ref="queryFormRef" :inline="true" label-width="80px">
|
||||
<el-form-item label="用户ID">
|
||||
<el-input
|
||||
v-model="queryForm.user_id"
|
||||
placeholder="请输入用户ID"
|
||||
clearable
|
||||
style="width: 150px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="任务类型">
|
||||
<el-select
|
||||
v-model="queryForm.type"
|
||||
placeholder="请选择任务类型"
|
||||
clearable
|
||||
style="width: 150px"
|
||||
@change="handleQuery"
|
||||
>
|
||||
<el-option label="文生图" value="text_to_image" />
|
||||
<el-option label="图生图" value="image_to_image" />
|
||||
<el-option label="图像编辑" value="image_edit" />
|
||||
<el-option label="图像特效" value="image_effects" />
|
||||
<el-option label="文生视频" value="text_to_video" />
|
||||
<el-option label="图生视频" value="image_to_video" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="任务状态">
|
||||
<el-select
|
||||
v-model="queryForm.status"
|
||||
placeholder="请选择状态"
|
||||
clearable
|
||||
style="width: 120px"
|
||||
@change="handleQuery"
|
||||
>
|
||||
<el-option label="等待中" value="in_queue" />
|
||||
<el-option label="处理中" value="generating" />
|
||||
<el-option label="已完成" value="success" />
|
||||
<el-option label="失败" value="failed" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<div class="flex items-center gap-2">
|
||||
<el-input
|
||||
v-model="queryForm.user_id"
|
||||
placeholder="请输入用户ID"
|
||||
clearable
|
||||
style="width: 150px"
|
||||
/>
|
||||
<el-select
|
||||
v-model="queryForm.type"
|
||||
placeholder="请选择任务类型"
|
||||
clearable
|
||||
style="width: 150px"
|
||||
@change="handleQuery"
|
||||
>
|
||||
<el-option label="文生图" value="text_to_image" />
|
||||
<el-option label="图生图" value="image_to_image" />
|
||||
<el-option label="图像编辑" value="image_edit" />
|
||||
<el-option label="图像特效" value="image_effects" />
|
||||
<el-option label="文生视频" value="text_to_video" />
|
||||
<el-option label="图生视频" value="image_to_video" />
|
||||
</el-select>
|
||||
|
||||
<el-select
|
||||
v-model="queryForm.status"
|
||||
placeholder="请选择状态"
|
||||
clearable
|
||||
style="width: 120px"
|
||||
@change="handleQuery"
|
||||
>
|
||||
<el-option label="等待中" value="in_queue" />
|
||||
<el-option label="处理中" value="generating" />
|
||||
<el-option label="已完成" value="success" />
|
||||
<el-option label="失败" value="failed" />
|
||||
</el-select>
|
||||
|
||||
<div>
|
||||
<el-button type="primary" @click="handleQuery" :loading="loading">
|
||||
<i class="iconfont icon-search mr-1" />
|
||||
搜索
|
||||
</el-button>
|
||||
<i class="iconfont icon-search mr-1" />
|
||||
搜索
|
||||
</el-button>
|
||||
</div>
|
||||
<div>
|
||||
<el-button type="danger" @click="handleBatchDelete" :disabled="!multipleSelection.length">
|
||||
<i class="iconfont icon-remove mr-1" />
|
||||
批量删除
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<i class="iconfont icon-remove mr-1" />
|
||||
批量删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 任务列表 -->
|
||||
@@ -266,7 +264,6 @@ const pagination = reactive({
|
||||
const taskList = ref([])
|
||||
const loading = ref(false)
|
||||
const multipleSelection = ref([])
|
||||
const queryFormRef = ref(null)
|
||||
|
||||
// 统计信息
|
||||
const stats = reactive({
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<!-- 统计信息 -->
|
||||
<el-row :gutter="20" class="stats-row">
|
||||
<el-col :span="4">
|
||||
<el-card class="stat-card">
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ stats.totalTasks }}</div>
|
||||
<div class="stat-label">总任务数</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card class="stat-card">
|
||||
<div class="stat-item">
|
||||
<div class="stat-number info">{{ stats.pendingTasks }}</div>
|
||||
<div class="stat-label">排队中</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card class="stat-card">
|
||||
<div class="stat-item">
|
||||
<div class="stat-number warning">{{ stats.processingTasks }}</div>
|
||||
<div class="stat-label">生成中</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card class="stat-card">
|
||||
<div class="stat-item">
|
||||
<div class="stat-number success">{{ stats.completedTasks }}</div>
|
||||
<div class="stat-label">已完成</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card class="stat-card">
|
||||
<div class="stat-item">
|
||||
<div class="stat-number danger">{{ stats.failedTasks }}</div>
|
||||
<div class="stat-label">失败</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 搜索筛选 -->
|
||||
<el-card class="filter-card" shadow="never">
|
||||
<div class="flex items-center gap-2">
|
||||
<el-input v-model="queryForm.user_id" placeholder="用户ID" clearable style="width: 150px" />
|
||||
<el-select
|
||||
v-model="queryForm.status"
|
||||
placeholder="任务状态"
|
||||
clearable
|
||||
style="width: 150px"
|
||||
@change="handleQuery"
|
||||
>
|
||||
<el-option label="排队中" value="pending" />
|
||||
<el-option label="生成中" value="processing" />
|
||||
<el-option label="已完成" value="completed" />
|
||||
<el-option label="失败" value="failed" />
|
||||
</el-select>
|
||||
|
||||
<el-button type="primary" @click="handleQuery" :loading="loading">
|
||||
<i class="iconfont icon-search mr-1" />
|
||||
搜索
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 任务列表 -->
|
||||
<el-card class="table-card">
|
||||
<el-table :data="taskList" v-loading="loading" border>
|
||||
<el-table-column prop="task_id" label="任务ID" min-width="200" />
|
||||
<el-table-column prop="user_id" label="用户ID" width="100" />
|
||||
<el-table-column prop="status" label="状态" width="110">
|
||||
<template #default="scope">
|
||||
<el-tag :type="getStatusColor(scope.row.status)" size="small">
|
||||
{{ getStatusName(scope.row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="total_slides" label="总页数" width="90" />
|
||||
<el-table-column prop="completed_slides" label="已完成页数" width="110" />
|
||||
<el-table-column
|
||||
prop="error_message"
|
||||
label="错误信息"
|
||||
min-width="200"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column prop="created_at" label="创建时间" width="180">
|
||||
<template #default="scope">
|
||||
{{ formatDateTime(scope.row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updated_at" label="更新时间" width="180">
|
||||
<template #default="scope">
|
||||
{{ formatDateTime(scope.row.updated_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="scope">
|
||||
<div class="flex items-center gap-3">
|
||||
<el-button link type="primary" @click="openPreview(scope.row.task_id)">预览</el-button>
|
||||
<el-dropdown
|
||||
v-if="scope.row.status === 'completed' && scope.row.completed_slides > 0"
|
||||
trigger="click"
|
||||
@command="(cmd) => onExportCommand(cmd, scope.row)"
|
||||
:disabled="exportLoadingKey === scope.row.task_id"
|
||||
>
|
||||
<span class="inline-flex items-center">
|
||||
<el-button link type="primary" :loading="exportLoadingKey === scope.row.task_id">
|
||||
导出
|
||||
</el-button>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="pdf">导出 PDF</el-dropdown-item>
|
||||
<el-dropdown-item command="pptx">导出 PPTX</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-container">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.size"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="pagination.total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<PPTPreviewDialog
|
||||
v-model="previewVisible"
|
||||
:task-id="previewTaskId"
|
||||
endpoint-base="/api/admin/ppt/jobs"
|
||||
:editable="false"
|
||||
:show-export="false"
|
||||
@closed="onPreviewClosed"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { httpDownload, httpGet } from '@/utils/http'
|
||||
import { formatDateTime } from '@/utils/libs'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import PPTPreviewDialog from '@/components/ppt/PPTPreviewDialog.vue'
|
||||
|
||||
// 查询条件
|
||||
const queryForm = reactive({
|
||||
user_id: '',
|
||||
status: '',
|
||||
})
|
||||
|
||||
// 分页
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
size: 20,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
// 列表数据与状态
|
||||
const taskList = ref([])
|
||||
const loading = ref(false)
|
||||
const exportLoadingKey = ref('')
|
||||
const previewVisible = ref(false)
|
||||
const previewTaskId = ref('')
|
||||
|
||||
// 统计数据
|
||||
const stats = reactive({
|
||||
totalTasks: 0,
|
||||
completedTasks: 0,
|
||||
processingTasks: 0,
|
||||
failedTasks: 0,
|
||||
pendingTasks: 0,
|
||||
})
|
||||
|
||||
const getStatusName = (status) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return '排队中'
|
||||
case 'processing':
|
||||
return '生成中'
|
||||
case 'completed':
|
||||
return '已完成'
|
||||
case 'failed':
|
||||
return '失败'
|
||||
default:
|
||||
return status || '未知'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'info'
|
||||
case 'processing':
|
||||
return 'warning'
|
||||
case 'completed':
|
||||
return 'success'
|
||||
case 'failed':
|
||||
return 'danger'
|
||||
default:
|
||||
return 'info'
|
||||
}
|
||||
}
|
||||
|
||||
const fetchStats = () => {
|
||||
httpGet('/api/admin/ppt/stats')
|
||||
.then((res) => {
|
||||
Object.assign(stats, res.data || {})
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('获取统计信息失败:' + e.message)
|
||||
})
|
||||
}
|
||||
|
||||
const fetchJobs = () => {
|
||||
loading.value = true
|
||||
const params = {
|
||||
page: pagination.page,
|
||||
page_size: pagination.size,
|
||||
}
|
||||
if (queryForm.user_id) {
|
||||
params.user_id = queryForm.user_id
|
||||
}
|
||||
if (queryForm.status) {
|
||||
params.status = queryForm.status
|
||||
}
|
||||
|
||||
httpGet('/api/admin/ppt/jobs', params)
|
||||
.then((res) => {
|
||||
const data = res.data || {}
|
||||
taskList.value = data.jobs || []
|
||||
pagination.total = data.total || 0
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('获取任务列表失败:' + e.message)
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const safeExportFileBase = (title, taskId) => {
|
||||
const raw = (title && String(title).trim()) || taskId || 'export'
|
||||
return String(raw)
|
||||
.replace(/[/\\:*?"<>|]/g, '_')
|
||||
.slice(0, 120)
|
||||
}
|
||||
|
||||
const runExportDownload = async (taskId, format, baseName) => {
|
||||
const ext = format === 'pdf' ? '.pdf' : '.pptx'
|
||||
exportLoadingKey.value = taskId
|
||||
try {
|
||||
const response = await httpDownload(
|
||||
`/api/admin/ppt/jobs/${encodeURIComponent(taskId)}/export?format=${encodeURIComponent(format)}`
|
||||
)
|
||||
if (response.status !== 200) {
|
||||
throw new Error('导出失败')
|
||||
}
|
||||
const blob = new Blob([response.data])
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(blob)
|
||||
link.download = `${safeExportFileBase(baseName, taskId)}${ext}`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(link.href)
|
||||
ElMessage.success('已开始下载')
|
||||
} catch (e) {
|
||||
const msg = typeof e === 'string' ? e : e?.message || e?.response?.data?.message || '未知错误'
|
||||
ElMessage.error('导出失败:' + msg)
|
||||
} finally {
|
||||
exportLoadingKey.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const onExportCommand = (cmd, job) => {
|
||||
if (cmd !== 'pdf' && cmd !== 'pptx') return
|
||||
runExportDownload(job.task_id, cmd, job.title || job.task_id)
|
||||
}
|
||||
|
||||
const openPreview = (taskId) => {
|
||||
previewTaskId.value = taskId
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
const onPreviewClosed = () => {
|
||||
previewTaskId.value = ''
|
||||
}
|
||||
|
||||
const handleQuery = () => {
|
||||
pagination.page = 1
|
||||
fetchJobs()
|
||||
fetchStats()
|
||||
}
|
||||
|
||||
const handleSizeChange = (size) => {
|
||||
pagination.size = size
|
||||
fetchJobs()
|
||||
}
|
||||
|
||||
const handleCurrentChange = (page) => {
|
||||
pagination.page = page
|
||||
fetchJobs()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchStats()
|
||||
fetchJobs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use '@/assets/css/main.scss' as *;
|
||||
|
||||
.stats-row {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 10px 0;
|
||||
}
|
||||
.stat-number {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
&.info {
|
||||
color: #409eff;
|
||||
}
|
||||
&.warning {
|
||||
color: #e6a23c;
|
||||
}
|
||||
&.success {
|
||||
color: #67c23a;
|
||||
}
|
||||
&.danger {
|
||||
color: #f56c6c;
|
||||
}
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
.pagination-container {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -123,24 +123,24 @@
|
||||
</div>
|
||||
<el-empty v-else />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="Stable-Diffusion" name="sd" v-loading="data.sd.loading">
|
||||
<el-tab-pane label="AI图像生成" name="image">
|
||||
<div class="handle-box">
|
||||
<el-input
|
||||
v-model="data.sd.query.username"
|
||||
v-model="data.image.query.username"
|
||||
placeholder="用户名"
|
||||
class="handle-input mr10"
|
||||
@keyup="search($event, 'sd')"
|
||||
@keyup="search($event, 'image')"
|
||||
clearable
|
||||
/>
|
||||
<el-input
|
||||
v-model="data.sd.query.prompt"
|
||||
v-model="data.image.query.prompt"
|
||||
placeholder="提示词"
|
||||
class="handle-input mr10"
|
||||
@keyup="search($event, 'sd')"
|
||||
@keyup="search($event, 'image')"
|
||||
clearable
|
||||
/>
|
||||
<el-date-picker
|
||||
v-model="data.sd.query.created_at"
|
||||
v-model="data.image.query.created_at"
|
||||
type="daterange"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
@@ -148,12 +148,12 @@
|
||||
value-format="YYYY-MM-DD"
|
||||
style="margin-right: 10px; width: 200px; position: relative; top: 3px"
|
||||
/>
|
||||
<el-button type="primary" :icon="Search" @click="fetchSdData">搜索</el-button>
|
||||
<el-button type="primary" :icon="Search" @click="fetchImageData">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="data.sd.items.length > 0">
|
||||
<div v-if="data.image.items.length > 0">
|
||||
<el-row>
|
||||
<el-table :data="data.sd.items" :row-key="(row) => row.id" table-layout="auto">
|
||||
<el-table :data="data.image.items" :row-key="(row) => row.id" table-layout="auto">
|
||||
<el-table-column prop="user_id" label="用户ID" />
|
||||
<el-table-column prop="progress" label="任务进度">
|
||||
<template #default="scope">
|
||||
@@ -213,7 +213,13 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="scope">
|
||||
<el-popconfirm title="确定要删除当前记录吗?" @confirm="remove(scope.row, 'sd')">
|
||||
<el-button size="small" type="primary" @click="showDetail(scope.row)" plain
|
||||
>详情</el-button
|
||||
>
|
||||
<el-popconfirm
|
||||
title="确定要删除当前记录吗?"
|
||||
@confirm="remove(scope.row, 'image')"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button size="small" type="danger">删除</el-button>
|
||||
</template>
|
||||
@@ -225,129 +231,14 @@
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-if="data.sd.total > 0"
|
||||
v-if="data.image.total > 0"
|
||||
background
|
||||
layout="total,prev, pager, next"
|
||||
:hide-on-single-page="true"
|
||||
v-model:current-page="data.sd.page"
|
||||
v-model:page-size="data.sd.pageSize"
|
||||
@current-change="fetchSdData()"
|
||||
:total="data.sd.total"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="DALL-E" name="dall">
|
||||
<div class="handle-box">
|
||||
<el-input
|
||||
v-model="data.dall.query.username"
|
||||
placeholder="用户名"
|
||||
class="handle-input mr10"
|
||||
@keyup="search($event, 'dall')"
|
||||
clearable
|
||||
/>
|
||||
<el-input
|
||||
v-model="data.dall.query.prompt"
|
||||
placeholder="提示词"
|
||||
class="handle-input mr10"
|
||||
@keyup="search($event, 'dall')"
|
||||
clearable
|
||||
/>
|
||||
<el-date-picker
|
||||
v-model="data.dall.query.created_at"
|
||||
type="daterange"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="margin-right: 10px; width: 200px; position: relative; top: 3px"
|
||||
/>
|
||||
<el-button type="primary" :icon="Search" @click="fetchDallData">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="data.dall.items.length > 0">
|
||||
<el-row>
|
||||
<el-table :data="data.dall.items" :row-key="(row) => row.id" table-layout="auto">
|
||||
<el-table-column prop="user_id" label="用户ID" />
|
||||
<el-table-column prop="progress" label="任务进度">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.progress <= 100">{{ scope.row.progress }}%</span>
|
||||
<el-tag v-else type="danger">已失败</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="power" label="消耗算力" />
|
||||
<el-table-column label="结果图片">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
size="small"
|
||||
type="success"
|
||||
@click="showImage(scope.row.img_url)"
|
||||
v-if="scope.row.img_url !== ''"
|
||||
plain
|
||||
>预览图片</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="提示词">
|
||||
<template #default="scope">
|
||||
<el-popover
|
||||
placement="top-start"
|
||||
title="绘画提示词"
|
||||
:width="300"
|
||||
trigger="hover"
|
||||
:content="scope.row.prompt"
|
||||
>
|
||||
<template #reference>
|
||||
<span>{{ substr(scope.row.prompt, 20) }}</span>
|
||||
</template>
|
||||
</el-popover>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间">
|
||||
<template #default="scope">
|
||||
<span>{{ dateFormat(scope.row['created_at']) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="失败原因">
|
||||
<template #default="scope">
|
||||
<el-popover
|
||||
placement="top-start"
|
||||
title="失败原因"
|
||||
:width="300"
|
||||
trigger="hover"
|
||||
:content="scope.row.err_msg"
|
||||
v-if="scope.row.progress === 101"
|
||||
>
|
||||
<template #reference>
|
||||
<el-text type="danger">{{ substr(scope.row.err_msg, 20) }}</el-text>
|
||||
</template>
|
||||
</el-popover>
|
||||
<span v-else>无</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="scope">
|
||||
<el-popconfirm title="确定要删除当前记录吗?" @confirm="remove(scope.row, 'dall')">
|
||||
<template #reference>
|
||||
<el-button size="small" type="danger">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-row>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-if="data.dall.total > 0"
|
||||
background
|
||||
layout="total,prev, pager, next"
|
||||
:hide-on-single-page="true"
|
||||
v-model:current-page="data.dall.page"
|
||||
v-model:page-size="data.dall.pageSize"
|
||||
@current-change="fetchDallData()"
|
||||
:total="data.dall.total"
|
||||
v-model:current-page="data.image.page"
|
||||
v-model:page-size="data.image.pageSize"
|
||||
@current-change="fetchImageData()"
|
||||
:total="data.image.total"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -366,12 +257,94 @@
|
||||
fit="cover"
|
||||
/>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<el-dialog
|
||||
v-model="showDetailDialog"
|
||||
title="任务详情"
|
||||
width="600px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div class="detail-content" v-if="currentDetail">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="提示词">
|
||||
<div class="prompt-text">
|
||||
{{ currentDetail.prompt }}
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="copyPrompt(currentDetail.prompt)"
|
||||
style="margin-left: 10px"
|
||||
>
|
||||
复制
|
||||
</el-button>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item
|
||||
label="生成的图片"
|
||||
v-if="currentDetail.progress === 100 && currentDetail.img_url"
|
||||
>
|
||||
<el-image
|
||||
:src="getThumbURL(currentDetail.img_url, 200, 200)"
|
||||
:preview-src-list="[currentDetail.img_url]"
|
||||
fit="cover"
|
||||
style="width: 200px; height: 200px"
|
||||
/>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item
|
||||
label="参考图"
|
||||
v-if="currentDetail.params?.image && currentDetail.params.image.length > 0"
|
||||
>
|
||||
<div class="reference-images">
|
||||
<el-image
|
||||
v-for="(img, idx) in currentDetail.params.image"
|
||||
:key="idx"
|
||||
:src="getThumbURL(img, 100, 100)"
|
||||
:preview-src-list="currentDetail.params.image"
|
||||
:initial-index="idx"
|
||||
fit="cover"
|
||||
style="width: 100px; height: 100px; margin-right: 10px"
|
||||
/>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="生图模型">
|
||||
{{ currentDetail.params?.model_name || '-' }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="消耗算力">
|
||||
{{ currentDetail.power || 0 }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="图片比例">
|
||||
{{ currentDetail.params?.aspect_ratio || '-' }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="图片尺寸">
|
||||
{{ currentDetail.params?.size || '-' }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="创建时间">
|
||||
{{ formatTime(currentDetail.created_at) }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item
|
||||
label="错误信息"
|
||||
v-if="currentDetail.progress === 101 && currentDetail.err_msg"
|
||||
>
|
||||
<el-text type="danger">{{ currentDetail.err_msg }}</el-text>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { httpGet, httpPost } from '@/utils/http'
|
||||
import { dateFormat, substr } from '@/utils/libs'
|
||||
import { dateFormat, getThumbURL, substr } from '@/utils/libs'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { onMounted, ref } from 'vue'
|
||||
@@ -386,15 +359,7 @@ const data = ref({
|
||||
pageSize: 15,
|
||||
loading: true,
|
||||
},
|
||||
sd: {
|
||||
items: [],
|
||||
query: { prompt: '', username: '', created_at: [], page: 1, page_size: 15 },
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 15,
|
||||
loading: true,
|
||||
},
|
||||
dall: {
|
||||
image: {
|
||||
items: [],
|
||||
query: { prompt: '', username: '', created_at: [], page: 1, page_size: 15 },
|
||||
total: 0,
|
||||
@@ -421,11 +386,8 @@ const handleChange = (tab) => {
|
||||
case 'mj':
|
||||
fetchMjData()
|
||||
break
|
||||
case 'sd':
|
||||
fetchSdData()
|
||||
break
|
||||
case 'dall':
|
||||
fetchDallData()
|
||||
case 'image':
|
||||
fetchImageData()
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -457,30 +419,11 @@ const fetchMjData = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const fetchSdData = () => {
|
||||
const d = data.value.sd
|
||||
const fetchImageData = () => {
|
||||
const d = data.value.image
|
||||
d.query.page = d.page
|
||||
d.query.page_size = d.pageSize
|
||||
httpPost('/api/admin/image/list/sd', d.query)
|
||||
.then((res) => {
|
||||
if (res.data) {
|
||||
d.items = res.data.items
|
||||
d.total = res.data.total
|
||||
d.page = res.data.page
|
||||
d.pageSize = res.data.page_size
|
||||
}
|
||||
d.loading = false
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('获取数据失败:' + e.message)
|
||||
})
|
||||
}
|
||||
|
||||
const fetchDallData = () => {
|
||||
const d = data.value.dall
|
||||
d.query.page = d.page
|
||||
d.query.page_size = d.pageSize
|
||||
httpPost('/api/admin/image/list/dall', d.query)
|
||||
httpPost('/api/admin/image/list/image', d.query)
|
||||
.then((res) => {
|
||||
if (res.data) {
|
||||
d.items = res.data.items
|
||||
@@ -512,6 +455,66 @@ const showImage = (url) => {
|
||||
showImageDialog.value = true
|
||||
imgURL.value = url
|
||||
}
|
||||
|
||||
// 详情弹窗
|
||||
const showDetailDialog = ref(false)
|
||||
const currentDetail = ref(null)
|
||||
|
||||
const showDetail = (row) => {
|
||||
// 解析 params 字段
|
||||
let params = {}
|
||||
try {
|
||||
if (row.params) {
|
||||
if (typeof row.params === 'string') {
|
||||
params = JSON.parse(row.params)
|
||||
} else {
|
||||
params = row.params
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析 params 失败:', e)
|
||||
}
|
||||
|
||||
currentDetail.value = {
|
||||
...row,
|
||||
params: params,
|
||||
}
|
||||
showDetailDialog.value = true
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timestamp) => {
|
||||
if (!timestamp) return '-'
|
||||
const date = new Date(timestamp * 1000)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
// 复制提示词
|
||||
const copyPrompt = async (text) => {
|
||||
if (!text) return
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
ElMessage.success('复制成功!')
|
||||
return
|
||||
}
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = text
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
ElMessage.success('复制成功!')
|
||||
} catch {
|
||||
ElMessage.error('复制失败!')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -544,4 +547,21 @@ const showImage = (url) => {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
:deep(.el-descriptions__label) {
|
||||
min-width: 110px;
|
||||
}
|
||||
|
||||
.prompt-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.reference-images {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,482 +0,0 @@
|
||||
<template>
|
||||
<div class="container media-page">
|
||||
<el-tabs v-model="activeName" @tab-change="handleChange">
|
||||
<el-tab-pane
|
||||
v-for="media in mediaTypes"
|
||||
:key="media.name"
|
||||
:label="media.label"
|
||||
:name="media.name"
|
||||
v-loading="data[media.name].loading"
|
||||
>
|
||||
<div class="handle-box">
|
||||
<el-input
|
||||
v-model="data[media.name].query.username"
|
||||
placeholder="用户名"
|
||||
class="handle-input mr10"
|
||||
@keyup="search($event, media.name)"
|
||||
clearable
|
||||
/>
|
||||
<el-input
|
||||
v-model="data[media.name].query.prompt"
|
||||
placeholder="提示词"
|
||||
class="handle-input mr10"
|
||||
@keyup="search($event, media.name)"
|
||||
clearable
|
||||
/>
|
||||
<el-date-picker
|
||||
v-model="data[media.name].query.created_at"
|
||||
type="daterange"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="margin-right: 10px; width: 200px; position: relative; top: 3px"
|
||||
/>
|
||||
<el-button type="primary" :icon="Search" @click="media.fetchData">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="data[media.name].items.length > 0">
|
||||
<el-row>
|
||||
<el-table :data="data[media.name].items" :row-key="(row) => row.id" table-layout="auto">
|
||||
<el-table-column prop="user_id" label="用户ID" />
|
||||
<el-table-column label="预览">
|
||||
<template #default="scope" v-if="media.previewComponent === 'MusicPreview'">
|
||||
<div class="container" v-if="scope.row.cover_url">
|
||||
<el-image :src="scope.row.cover_url" fit="cover" />
|
||||
<div class="duration">
|
||||
{{ formatTime(scope.row.duration) }}
|
||||
</div>
|
||||
<button
|
||||
class="play flex justify-center items-center"
|
||||
@click="playMusic(scope.row)"
|
||||
>
|
||||
<img src="/images/play.svg" alt="" />
|
||||
</button>
|
||||
</div>
|
||||
<el-image v-else src="/images/failed.jpg" style="height: 90px" fit="cover" />
|
||||
</template>
|
||||
<template #default="scope" v-if="media.previewComponent === 'VideoPreview'">
|
||||
<div class="container">
|
||||
<div v-if="scope.row.progress === 100">
|
||||
<video
|
||||
class="video"
|
||||
:src="replaceImg(scope.row.video_url)"
|
||||
preload="auto"
|
||||
loop="loop"
|
||||
muted="muted"
|
||||
>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
<button
|
||||
class="play flex justify-center items-center"
|
||||
@click="playVideo(scope.row)"
|
||||
>
|
||||
<img src="/images/play.svg" alt="" />
|
||||
</button>
|
||||
</div>
|
||||
<el-image
|
||||
:src="scope.row.cover_url"
|
||||
fit="cover"
|
||||
v-else-if="scope.row.progress > 100"
|
||||
/>
|
||||
<generating message="正在生成视频" v-else />
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="progress" label="任务进度">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.progress <= 100">{{ scope.row.progress }}%</span>
|
||||
<el-tag v-else type="danger">已失败</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="power" label="消耗算力" />
|
||||
<template v-if="media.previewComponent === 'MusicPreview'">
|
||||
<el-table-column prop="tags" label="风格" />
|
||||
<el-table-column prop="play_times" label="播放次数" />
|
||||
<el-table-column label="歌词">
|
||||
<template #default="scope">
|
||||
<el-button size="small" type="primary" plain @click="showLyric(scope.row)"
|
||||
>查看歌词</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</template>
|
||||
<el-table-column label="提示词" v-if="media.previewComponent === 'VideoPreview'">
|
||||
<template #default="scope">
|
||||
<el-popover
|
||||
placement="top-start"
|
||||
title="提示词"
|
||||
:width="300"
|
||||
trigger="hover"
|
||||
:content="scope.row.prompt"
|
||||
>
|
||||
<template #reference>
|
||||
<span>{{ substr(scope.row.prompt, 20) }}</span>
|
||||
</template>
|
||||
</el-popover>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间">
|
||||
<template #default="scope">
|
||||
<span>{{ dateFormat(scope.row['created_at']) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="失败原因">
|
||||
<template #default="scope">
|
||||
<el-popover
|
||||
v-if="scope.row.progress === 101"
|
||||
placement="top-start"
|
||||
title="失败原因"
|
||||
:width="300"
|
||||
trigger="hover"
|
||||
:content="scope.row.err_msg"
|
||||
>
|
||||
<template #reference>
|
||||
<el-text type="danger">{{ substr(scope.row.err_msg, 20) }}</el-text>
|
||||
</template>
|
||||
</el-popover>
|
||||
<span v-else>无</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="scope">
|
||||
<el-popconfirm
|
||||
title="确定要删除当前记录吗?"
|
||||
@confirm="remove(scope.row, media.name)"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button size="small" type="danger">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-row>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-if="data[media.name].total > 0"
|
||||
background
|
||||
layout="total,prev, pager, next"
|
||||
:hide-on-single-page="true"
|
||||
v-model:current-page="data[media.name].page"
|
||||
v-model:page-size="data[media.name].pageSize"
|
||||
@current-change="media.fetchData"
|
||||
:total="data[media.name].total"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<el-dialog v-model="showVideoDialog" title="视频预览">
|
||||
<video
|
||||
style="width: 100%; max-height: 90vh"
|
||||
:src="currentVideoUrl"
|
||||
preload="auto"
|
||||
:autoplay="true"
|
||||
loop="loop"
|
||||
muted="muted"
|
||||
>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
</el-dialog>
|
||||
|
||||
<div class="music-player" v-if="showPlayer">
|
||||
<music-player
|
||||
:songs="playList"
|
||||
ref="playerRef"
|
||||
:show-close="true"
|
||||
@close="showPlayer = false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="showLyricDialog" title="歌词">
|
||||
<div class="chat-line" v-html="lyrics"></div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import MusicPlayer from '@/components/MusicPlayer.vue'
|
||||
import Generating from '@/components/ui/Generating.vue'
|
||||
import { httpGet, httpPost } from '@/utils/http'
|
||||
import { dateFormat, formatTime, replaceImg, substr } from '@/utils/libs'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
|
||||
const data = ref({
|
||||
suno: {
|
||||
items: [],
|
||||
query: { prompt: '', username: '', created_at: [], page: 1, page_size: 15 },
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
loading: true,
|
||||
},
|
||||
luma: {
|
||||
items: [],
|
||||
query: { prompt: '', username: '', created_at: [], page: 1, page_size: 15 },
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
loading: true,
|
||||
},
|
||||
keling: {
|
||||
items: [],
|
||||
query: { prompt: '', username: '', created_at: [], page: 1, page_size: 15 },
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
loading: true,
|
||||
},
|
||||
})
|
||||
|
||||
const mediaTypes = [
|
||||
{
|
||||
name: 'suno',
|
||||
label: 'Suno音乐',
|
||||
fetchData: () => fetchSunoData(),
|
||||
previewComponent: 'MusicPreview',
|
||||
},
|
||||
{
|
||||
name: 'luma',
|
||||
label: 'Luma视频',
|
||||
fetchData: () => fetchLumaData(),
|
||||
previewComponent: 'VideoPreview',
|
||||
},
|
||||
{
|
||||
name: 'keling',
|
||||
label: '可灵视频',
|
||||
fetchData: () => fetchKelingData(),
|
||||
previewComponent: 'VideoPreview',
|
||||
},
|
||||
]
|
||||
const activeName = ref('suno')
|
||||
const playList = ref([])
|
||||
const playerRef = ref(null)
|
||||
const showPlayer = ref(false)
|
||||
const showLyricDialog = ref(false)
|
||||
const lyrics = ref('')
|
||||
const showVideoDialog = ref(false)
|
||||
const currentVideoUrl = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
fetchSunoData()
|
||||
})
|
||||
|
||||
const handleChange = (tab) => {
|
||||
switch (tab) {
|
||||
case 'suno':
|
||||
fetchSunoData()
|
||||
break
|
||||
case 'luma':
|
||||
fetchLumaData()
|
||||
break
|
||||
case 'keling':
|
||||
fetchKelingData()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索对话
|
||||
const search = (evt, tab) => {
|
||||
if (evt.keyCode === 13) {
|
||||
handleChange(tab)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取数据
|
||||
const fetchSunoData = () => {
|
||||
const d = data.value.suno
|
||||
d.query.page = d.page
|
||||
d.query.page_size = d.pageSize
|
||||
httpPost('/api/admin/media/suno', d.query)
|
||||
.then((res) => {
|
||||
if (res.data) {
|
||||
d.items = res.data.items
|
||||
d.total = res.data.total
|
||||
d.page = res.data.page
|
||||
d.pageSize = res.data.page_size
|
||||
}
|
||||
d.loading = false
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('获取数据失败:' + e.message)
|
||||
})
|
||||
}
|
||||
|
||||
const fetchLumaData = () => {
|
||||
const d = data.value.luma
|
||||
d.query.page = d.page
|
||||
d.query.page_size = d.pageSize
|
||||
d.query.type = 'luma'
|
||||
httpPost('/api/admin/media/videos', d.query)
|
||||
.then((res) => {
|
||||
if (res.data) {
|
||||
d.items = res.data.items
|
||||
d.total = res.data.total
|
||||
d.page = res.data.page
|
||||
d.pageSize = res.data.page_size
|
||||
}
|
||||
d.loading = false
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('获取数据失败:' + e.message)
|
||||
})
|
||||
}
|
||||
const fetchKelingData = () => {
|
||||
const d = data.value.keling
|
||||
d.query.page = d.page
|
||||
d.query.page_size = d.pageSize
|
||||
d.query.type = 'keling'
|
||||
httpPost('/api/admin/media/videos', d.query)
|
||||
.then((res) => {
|
||||
if (res.data) {
|
||||
d.items = res.data.items
|
||||
d.total = res.data.total
|
||||
d.page = res.data.page
|
||||
d.pageSize = res.data.page_size
|
||||
}
|
||||
d.loading = false
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('获取数据失败:' + e.message)
|
||||
})
|
||||
}
|
||||
|
||||
const remove = function (row, tab) {
|
||||
httpGet(`/api/admin/media/remove?id=${row.id}&tab=${tab}`)
|
||||
.then(() => {
|
||||
ElMessage.success('删除成功!')
|
||||
handleChange(tab)
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('删除失败:' + e.message)
|
||||
})
|
||||
.finally(() => {
|
||||
nextTick(() => {
|
||||
// data.value[tab].page = 1;
|
||||
// data.value[tab].pageSize = 10;
|
||||
const mediaType = mediaTypes.find((type) => type.name === tab)
|
||||
if (mediaType && mediaType.fetchData) {
|
||||
mediaType.fetchData()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const playMusic = (item) => {
|
||||
playList.value = [item]
|
||||
showPlayer.value = true
|
||||
nextTick(() => playerRef.value.play())
|
||||
}
|
||||
|
||||
const playVideo = (item) => {
|
||||
currentVideoUrl.value = replaceImg(item.video_url)
|
||||
showVideoDialog.value = true
|
||||
}
|
||||
|
||||
const md = MarkdownIt({
|
||||
breaks: true,
|
||||
html: true,
|
||||
linkify: true,
|
||||
})
|
||||
|
||||
const showLyric = (item) => {
|
||||
showLyricDialog.value = true
|
||||
lyrics.value = md.render(item.prompt)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.media-page {
|
||||
.handle-box {
|
||||
margin-bottom: 20px;
|
||||
.handle-input {
|
||||
max-width: 150px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.opt-box {
|
||||
padding-bottom: 10px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
.el-icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.el-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 160px;
|
||||
position: relative;
|
||||
|
||||
.video {
|
||||
width: 160px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.el-image {
|
||||
width: 160px;
|
||||
height: 90px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.duration {
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
right: 0;
|
||||
background-color: rgba(255, 255, 255, 0.7);
|
||||
padding: 0 3px;
|
||||
font-family: 'Input Sans';
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
border-radius: 0.125rem;
|
||||
}
|
||||
|
||||
.play {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background: rgba(100, 100, 100, 0.3);
|
||||
cursor: pointer;
|
||||
color: #ffffff;
|
||||
opacity: 0;
|
||||
transform: translate(-50%, 0px);
|
||||
transition: opacity 0.3s ease 0s;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.play {
|
||||
opacity: 1;
|
||||
//display block
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.music-player {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
z-index: 99999;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,450 @@
|
||||
<template>
|
||||
<div class="container suno-page">
|
||||
<div class="handle-box">
|
||||
<el-input
|
||||
v-model="query.title"
|
||||
placeholder="标题"
|
||||
class="handle-input mr10"
|
||||
@keyup="search"
|
||||
clearable
|
||||
/>
|
||||
<el-input
|
||||
v-model="query.prompt"
|
||||
placeholder="提示词"
|
||||
class="handle-input mr10"
|
||||
@keyup="search"
|
||||
clearable
|
||||
/>
|
||||
<el-date-picker
|
||||
v-model="query.created_at"
|
||||
type="daterange"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
|
||||
/>
|
||||
<el-button type="primary" class="ml-2" :icon="Search" @click="fetchData">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="items.length > 0" v-loading="loading">
|
||||
<el-row>
|
||||
<el-table :data="items" :row-key="(row) => row.id" table-layout="auto">
|
||||
<el-table-column prop="user_id" label="用户ID" width="100" />
|
||||
<el-table-column prop="title" label="标题" width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="text-ellipsis">{{ substr(row.title, 20) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预览" width="180">
|
||||
<template #default="scope">
|
||||
<div class="container" v-if="scope.row.cover_url">
|
||||
<el-image :src="scope.row.cover_url" fit="cover" />
|
||||
<div class="duration">
|
||||
{{ formatTime(scope.row.duration) }}
|
||||
</div>
|
||||
<button class="play flex justify-center items-center" @click="playMusic(scope.row)">
|
||||
<img src="/images/play.svg" alt="" />
|
||||
</button>
|
||||
</div>
|
||||
<el-image v-else-if="scope.row.progress === 101" src="/images/failed.jpg" style="height: 90px" fit="cover" />
|
||||
<div class="flex flex-col items-center justify-center h-[100px]" v-else>
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600 mx-auto"></div>
|
||||
<span class="text-xs text-purple-600 mt-2 block">生成中...</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="progress" label="任务进度" width="100">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.progress <= 100">{{ scope.row.progress }}%</span>
|
||||
<el-tag v-else type="danger">已失败</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="power" label="消耗算力" width="100" />
|
||||
<el-table-column prop="play_times" label="播放次数" width="100" />
|
||||
<el-table-column label="歌词" width="110">
|
||||
<template #default="scope">
|
||||
<el-button size="small" type="primary" plain @click="showLyric(scope.row)"
|
||||
>查看歌词</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="160">
|
||||
<template #default="scope">
|
||||
<span>{{ dateFormat(scope.row['created_at']) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="失败原因" width="180">
|
||||
<template #default="scope">
|
||||
<el-popover
|
||||
v-if="scope.row.progress === 101"
|
||||
placement="top-start"
|
||||
title="失败原因"
|
||||
:width="300"
|
||||
trigger="hover"
|
||||
:content="scope.row.err_msg"
|
||||
>
|
||||
<template #reference>
|
||||
<el-text type="danger">{{ substr(scope.row.err_msg, 20) }}</el-text>
|
||||
</template>
|
||||
</el-popover>
|
||||
<span v-else>无</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="scope">
|
||||
<el-button size="small" @click="showDetailDialog(scope.row)">详情</el-button>
|
||||
<el-popconfirm title="确定要删除当前记录吗?" @confirm="remove(scope.row)">
|
||||
<template #reference>
|
||||
<el-button size="small" type="danger">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-row>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-if="total > 0"
|
||||
background
|
||||
layout="total,prev, pager, next"
|
||||
:hide-on-single-page="true"
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
@current-change="fetchData"
|
||||
:total="total"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else />
|
||||
|
||||
<div class="music-player" v-if="showPlayer">
|
||||
<music-player
|
||||
:songs="playList"
|
||||
ref="playerRef"
|
||||
:show-close="true"
|
||||
@close="showPlayer = false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="showLyricDialog" title="歌词">
|
||||
<div class="chat-line" v-html="lyrics"></div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 详情对话框 -->
|
||||
<el-dialog v-model="detailDialogVisible" title="任务详情" width="800px">
|
||||
<div v-if="currentDetail" class="space-y-4">
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">任务ID</div>
|
||||
<div class="detail-value">{{ currentDetail.task_id }}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">用户ID</div>
|
||||
<div class="detail-value">{{ currentDetail.user_id }}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">风格</div>
|
||||
<div class="detail-value">{{ currentDetail.params.tags }}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">任务进度</div>
|
||||
<div class="detail-value">
|
||||
<span v-if="currentDetail.progress <= 100">{{ currentDetail.progress }}%</span>
|
||||
<el-tag v-else type="danger">已失败</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">算力消耗</div>
|
||||
<div class="detail-value">{{ currentDetail.power }}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">播放次数</div>
|
||||
<div class="detail-value">{{ currentDetail.play_times }}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">时长</div>
|
||||
<div class="detail-value">{{ formatTime(currentDetail.duration) }}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">创建时间</div>
|
||||
<div class="detail-value">{{ dateFormat(currentDetail.created_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-full">
|
||||
<div class="detail-label">歌词</div>
|
||||
<div class="detail-value" v-html="md.render(currentDetail.prompt)" />
|
||||
</div>
|
||||
|
||||
<div v-if="currentDetail.progress === 101 && currentDetail.err_msg" class="detail-full error">
|
||||
<div class="detail-label">错误信息</div>
|
||||
<div class="detail-value">{{ currentDetail.err_msg }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 音乐预览区域 - 仅成功任务显示 -->
|
||||
<div v-if="currentDetail.progress === 100 && currentDetail.cover_url" class="detail-preview">
|
||||
<div class="detail-label mb-2">音乐预览</div>
|
||||
<div class="preview-container">
|
||||
<el-image :src="currentDetail.cover_url" fit="cover" class="preview-image" />
|
||||
<div class="preview-info">
|
||||
<div class="preview-duration">{{ formatTime(currentDetail.duration) }}</div>
|
||||
<el-button type="primary" @click="playMusic(currentDetail)">播放</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import MusicPlayer from '@/components/MusicPlayer.vue'
|
||||
import { httpGet, httpPost } from '@/utils/http'
|
||||
import { dateFormat, formatTime, substr } from '@/utils/libs'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
|
||||
const items = ref([])
|
||||
const query = ref({ prompt: '', title: '', created_at: [], page: 1, page_size: 10 })
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const loading = ref(true)
|
||||
|
||||
const playList = ref([])
|
||||
const playerRef = ref(null)
|
||||
const showPlayer = ref(false)
|
||||
const showLyricDialog = ref(false)
|
||||
const lyrics = ref('')
|
||||
const detailDialogVisible = ref(false)
|
||||
const currentDetail = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
|
||||
// 搜索
|
||||
const search = (evt) => {
|
||||
if (evt.keyCode === 13) {
|
||||
fetchData()
|
||||
}
|
||||
}
|
||||
|
||||
// 获取数据
|
||||
const fetchData = () => {
|
||||
query.value.page = page.value
|
||||
query.value.page_size = pageSize.value
|
||||
httpPost('/api/admin/suno/list', query.value)
|
||||
.then((res) => {
|
||||
if (res.data) {
|
||||
items.value = res.data.items
|
||||
total.value = res.data.total
|
||||
page.value = res.data.page
|
||||
pageSize.value = res.data.page_size
|
||||
}
|
||||
loading.value = false
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('获取数据失败:' + e.message)
|
||||
})
|
||||
}
|
||||
|
||||
const remove = function (row) {
|
||||
httpGet(`/api/admin/suno/remove?id=${row.id}`)
|
||||
.then(() => {
|
||||
ElMessage.success('删除成功!')
|
||||
fetchData()
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('删除失败:' + e.message)
|
||||
})
|
||||
}
|
||||
|
||||
const playMusic = (item) => {
|
||||
playList.value = [item]
|
||||
showPlayer.value = true
|
||||
nextTick(() => playerRef.value.play())
|
||||
}
|
||||
|
||||
const md = MarkdownIt({
|
||||
breaks: true,
|
||||
html: true,
|
||||
linkify: true,
|
||||
})
|
||||
|
||||
const showLyric = (item) => {
|
||||
showLyricDialog.value = true
|
||||
lyrics.value = md.render(item.prompt)
|
||||
}
|
||||
|
||||
const showDetailDialog = (item) => {
|
||||
currentDetail.value = item
|
||||
detailDialogVisible.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.suno-page {
|
||||
.handle-box {
|
||||
margin-bottom: 20px;
|
||||
.handle-input {
|
||||
max-width: 150px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.el-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
:deep(.el-table__header) {
|
||||
th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 160px;
|
||||
position: relative;
|
||||
|
||||
.el-image {
|
||||
width: 160px;
|
||||
height: 90px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.duration {
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
right: 0;
|
||||
background-color: rgba(255, 255, 255, 0.7);
|
||||
padding: 0 3px;
|
||||
font-family: 'Input Sans';
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
border-radius: 0.125rem;
|
||||
}
|
||||
|
||||
.play {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background: rgba(100, 100, 100, 0.3);
|
||||
cursor: pointer;
|
||||
color: #ffffff;
|
||||
opacity: 0;
|
||||
transform: translate(-50%, 0px);
|
||||
transition: opacity 0.3s ease 0s;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.play {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.music-player {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
z-index: 99999;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.text-ellipsis {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.space-y-4 > * + * {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
padding: 0.75rem;
|
||||
background-color: #f9fafb;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.detail-full {
|
||||
padding: 0.75rem;
|
||||
background-color: #f9fafb;
|
||||
border-radius: 0.375rem;
|
||||
|
||||
&.error {
|
||||
background-color: #fef2f2;
|
||||
|
||||
.detail-label {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: #991b1b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail-preview {
|
||||
.mb-2 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
|
||||
.preview-image {
|
||||
width: 160px;
|
||||
height: 90px;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.preview-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
.preview-duration {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,363 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 p-6">
|
||||
<!-- 搜索筛选区域 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<el-input
|
||||
v-model="query.prompt"
|
||||
placeholder="提示词"
|
||||
style="width: 200px"
|
||||
clearable
|
||||
@keyup="search"
|
||||
/>
|
||||
<el-select v-model="query.type" placeholder="视频类型" style="width: 120px" clearable>
|
||||
<el-option v-for="provider in orderedProviders" :key="provider" :label="getProviderName(provider)" :value="provider" />
|
||||
</el-select>
|
||||
<el-select v-model="query.status" placeholder="任务状态" style="width: 120px" clearable>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="等待中" value="pending" />
|
||||
<el-option label="进行中" value="in_progress" />
|
||||
<el-option label="下载中" value="downloading" />
|
||||
<el-option label="成功" value="success" />
|
||||
<el-option label="失败" value="failed" />
|
||||
</el-select>
|
||||
<el-date-picker
|
||||
v-model="query.created_at"
|
||||
type="daterange"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 240px"
|
||||
/>
|
||||
<el-button type="primary" @click="fetchData">搜索</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 视频列表 - 卡片式布局 -->
|
||||
<div v-if="items.length > 0" v-loading="loading" class="space-y-4">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="bg-white rounded-lg shadow-sm p-4 hover:shadow-md transition-shadow cursor-pointer"
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<!-- 视频预览 -->
|
||||
<div class="w-40 h-24 flex-shrink-0 bg-gray-100 rounded-lg overflow-hidden relative">
|
||||
<video
|
||||
v-if="item.status === 'success'"
|
||||
:src="replaceImg(item.video_url)"
|
||||
class="w-full h-full object-cover"
|
||||
muted
|
||||
loop
|
||||
@click="playVideo(item)"
|
||||
/>
|
||||
<div
|
||||
v-else-if="item.status === 'downloading'"
|
||||
class="flex items-center justify-center h-full"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600 mx-auto"></div>
|
||||
<span class="text-xs text-purple-600 mt-2 block">下载中...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="item.status === 'in_progress'"
|
||||
class="flex items-center justify-center h-full"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<span class="text-xs text-blue-600 mt-2 block">{{ item.progress }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="item.status === 'failed'"
|
||||
class="flex items-center justify-center h-full bg-red-50"
|
||||
>
|
||||
<i class="text-red-500 text-2xl">✕</i>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-center h-full"
|
||||
>
|
||||
<i class="text-gray-400 text-2xl">⏱</i>
|
||||
</div>
|
||||
|
||||
<!-- 播放按钮 -->
|
||||
<button
|
||||
v-if="item.status === 'success'"
|
||||
@click="playVideo(item)"
|
||||
class="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 opacity-0 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<svg class="w-12 h-12 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 任务信息 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span :class="getStatusClass(item.status)" class="px-2 py-1 text-xs rounded-full font-medium">
|
||||
{{ getStatusText(item.status) }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-600">用户ID: {{ item.user_id }}</span>
|
||||
<span class="text-xs text-gray-500 px-2 py-1 bg-gray-100 rounded">{{ item.type }}</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500 whitespace-nowrap">{{ dateFormat(item.created_at) }}</span>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-700 mb-2 line-clamp-2">{{ item.prompt }}</p>
|
||||
|
||||
<div class="flex items-center gap-4 text-xs text-gray-600">
|
||||
<span>渠道: {{ item.channel }}</span>
|
||||
<span>算力: {{ item.power }}</span>
|
||||
<span v-if="item.status === 'in_progress'">进度: {{ item.progress }}%</span>
|
||||
<span v-if="item.err_msg" class="text-red-600">错误: {{ substr(item.err_msg, 30) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<span>
|
||||
<el-button size="small" @click="showDetailDialog(item)">详情</el-button>
|
||||
</span>
|
||||
<el-popconfirm title="确定要删除当前记录吗?" @confirm="remove(item)">
|
||||
<template #reference>
|
||||
<el-button size="small" type="danger">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无数据" />
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="total > 0" class="mt-6 flex justify-center">
|
||||
<el-pagination
|
||||
background
|
||||
layout="total, prev, pager, next"
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
@current-change="fetchData"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 视频预览对话框 -->
|
||||
<el-dialog v-model="showVideoDialog" title="视频预览" width="800px" @close="handleVideoDialogClose">
|
||||
<video
|
||||
ref="videoPlayerRef"
|
||||
style="width: 100%; max-height: 70vh"
|
||||
:src="currentVideoUrl"
|
||||
controls
|
||||
autoplay
|
||||
loop
|
||||
>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 详情对话框 -->
|
||||
<el-dialog v-model="detailDialogVisible" title="任务详情" width="800px" @close="handleDetailDialogClose">
|
||||
<div v-if="currentDetail" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="p-3 bg-gray-50 rounded">
|
||||
<div class="text-xs text-gray-500 mb-1">任务ID</div>
|
||||
<div class="text-sm font-medium">{{ currentDetail.task_id }}</div>
|
||||
</div>
|
||||
<div class="p-3 bg-gray-50 rounded">
|
||||
<div class="text-xs text-gray-500 mb-1">状态</div>
|
||||
<span :class="getStatusClass(currentDetail.status)" class="px-2 py-1 text-xs rounded-full">
|
||||
{{ getStatusText(currentDetail.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-3 bg-gray-50 rounded">
|
||||
<div class="text-xs text-gray-500 mb-1">渠道</div>
|
||||
<div class="text-sm font-medium">{{ currentDetail.channel }}</div>
|
||||
</div>
|
||||
<div class="p-3 bg-gray-50 rounded">
|
||||
<div class="text-xs text-gray-500 mb-1">类型</div>
|
||||
<div class="text-sm font-medium">{{ currentDetail.type }}</div>
|
||||
</div>
|
||||
<div class="p-3 bg-gray-50 rounded">
|
||||
<div class="text-xs text-gray-500 mb-1">算力消耗</div>
|
||||
<div class="text-sm font-medium">{{ currentDetail.power }}</div>
|
||||
</div>
|
||||
<div class="p-3 bg-gray-50 rounded">
|
||||
<div class="text-xs text-gray-500 mb-1">创建时间</div>
|
||||
<div class="text-sm font-medium">{{ dateFormat(currentDetail.created_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-3 bg-gray-50 rounded">
|
||||
<div class="text-xs text-gray-500 mb-2">提示词</div>
|
||||
<div class="text-sm">{{ currentDetail.prompt }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="currentDetail.params" class="p-3 bg-gray-50 rounded">
|
||||
<div class="text-xs text-gray-500 mb-2">任务参数</div>
|
||||
<pre class="text-xs overflow-x-auto">{{ JSON.stringify(currentDetail.params, null, 2) }}</pre>
|
||||
</div>
|
||||
|
||||
<div v-if="currentDetail.output" class="p-3 bg-gray-50 rounded">
|
||||
<div class="text-xs text-gray-500 mb-2">输出信息</div>
|
||||
<pre class="text-xs overflow-x-auto">{{ JSON.stringify(currentDetail.output, null, 2) }}</pre>
|
||||
</div>
|
||||
|
||||
<div v-if="currentDetail.err_msg" class="p-3 bg-red-50 rounded">
|
||||
<div class="text-xs text-red-500 mb-2">错误信息</div>
|
||||
<div class="text-sm text-red-700">{{ currentDetail.err_msg }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 视频预览区域 - 仅成功任务显示 -->
|
||||
<div v-if="currentDetail.status === 'success' && currentDetail.video_url" class="rounded-lg overflow-hidden">
|
||||
<div class="text-base text-blue-500 mb-2">视频预览</div>
|
||||
<video
|
||||
ref="detailVideoPlayerRef"
|
||||
:src="replaceImg(currentDetail.video_url)"
|
||||
class="w-full"
|
||||
style="max-height: 400px"
|
||||
controls
|
||||
loop
|
||||
>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { httpGet, httpPost } from '@/utils/http'
|
||||
import { dateFormat, replaceImg, substr } from '@/utils/libs'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { getVideoProviders } from '@/store/data/video_params'
|
||||
import { computed } from 'vue'
|
||||
import { getProviderName } from '@/store/data/video_params'
|
||||
|
||||
const items = ref([])
|
||||
const query = ref({
|
||||
prompt: '',
|
||||
type: '',
|
||||
status: '',
|
||||
created_at: [],
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
})
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const loading = ref(false)
|
||||
const showVideoDialog = ref(false)
|
||||
const currentVideoUrl = ref('')
|
||||
const videoPlayerRef = ref(null)
|
||||
const detailDialogVisible = ref(false)
|
||||
const detailVideoPlayerRef = ref(null)
|
||||
const currentDetail = ref(null)
|
||||
const providers = getVideoProviders()
|
||||
const orderedProviders = computed(() => {
|
||||
return ['sora', 'veo', 'luma', 'keling', 'minimax', 'wan'].filter((p) => providers.includes(p))
|
||||
})
|
||||
|
||||
|
||||
// 状态配置
|
||||
const statusConfig = {
|
||||
pending: { label: '等待中', class: 'bg-gray-100 text-gray-700' },
|
||||
in_progress: { label: '进行中', class: 'bg-blue-100 text-blue-700' },
|
||||
downloading: { label: '下载中', class: 'bg-purple-100 text-purple-700' },
|
||||
success: { label: '成功', class: 'bg-green-100 text-green-700' },
|
||||
failed: { label: '失败', class: 'bg-red-100 text-red-700' },
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
return statusConfig[status]?.label || status
|
||||
}
|
||||
|
||||
const getStatusClass = (status) => {
|
||||
return statusConfig[status]?.class || 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
|
||||
const search = (evt) => {
|
||||
if (evt.keyCode === 13) {
|
||||
fetchData()
|
||||
}
|
||||
}
|
||||
|
||||
const fetchData = () => {
|
||||
loading.value = true
|
||||
query.value.page = page.value
|
||||
query.value.page_size = pageSize.value
|
||||
httpPost('/api/admin/video/list', query.value)
|
||||
.then((res) => {
|
||||
if (res.data) {
|
||||
items.value = res.data.items || []
|
||||
total.value = res.data.total
|
||||
page.value = res.data.page
|
||||
pageSize.value = res.data.page_size
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('获取数据失败:' + e.message)
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const remove = (item) => {
|
||||
httpGet(`/api/admin/video/remove?id=${item.id}&tab=${item.type}`)
|
||||
.then(() => {
|
||||
ElMessage.success('删除成功!')
|
||||
fetchData()
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('删除失败:' + e.message)
|
||||
})
|
||||
}
|
||||
|
||||
const playVideo = (item) => {
|
||||
currentVideoUrl.value = replaceImg(item.video_url)
|
||||
showVideoDialog.value = true
|
||||
}
|
||||
|
||||
// 处理视频对话框关闭事件
|
||||
const handleVideoDialogClose = () => {
|
||||
if (videoPlayerRef.value) {
|
||||
videoPlayerRef.value.pause()
|
||||
videoPlayerRef.value.currentTime = 0
|
||||
}
|
||||
showVideoDialog.value = false
|
||||
}
|
||||
|
||||
// 处理详情对话框关闭事件
|
||||
const handleDetailDialogClose = () => {
|
||||
if (detailVideoPlayerRef.value) {
|
||||
detailVideoPlayerRef.value.pause()
|
||||
detailVideoPlayerRef.value.currentTime = 0
|
||||
}
|
||||
detailDialogVisible.value = false
|
||||
}
|
||||
|
||||
const showDetailDialog = (item) => {
|
||||
currentDetail.value = item
|
||||
detailDialogVisible.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -201,16 +201,6 @@
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<div class="label-title">
|
||||
SD反向提示词
|
||||
<span class="text-xs text-gray-500">(Stable-Diffusion 绘画默认反向提示词)</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-input type="textarea" :rows="2" v-model="system['sd_neg_prompt']" placeholder="" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="订单支付超时时间" prop="order_pay_timeout">
|
||||
<template #label>
|
||||
<div class="label-title">
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<div class="ppt-config form" v-loading="loading">
|
||||
<div class="container">
|
||||
<el-form :model="config" label-position="top" ref="formRef" class="px-3 py-5" :rules="rules">
|
||||
<!-- 分镜 LLM 配置 -->
|
||||
<div class="mb-4">
|
||||
<h3 class="heading-3 mb-2">分镜 LLM 配置</h3>
|
||||
<el-form-item label="分镜 LLM API 地址" prop="outline_llm_api_url">
|
||||
<el-input
|
||||
v-model="config.outline_llm_api_url"
|
||||
placeholder="例如:https://api.xxx.com/v1/chat/completions"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="分镜 LLM 模型名称" prop="outline_llm_model">
|
||||
<el-input
|
||||
v-model="config.outline_llm_model"
|
||||
placeholder="例如:gemini-3.1-pro-preview"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="分镜 LLM API Key" prop="outline_llm_api_key">
|
||||
<el-input
|
||||
v-model="config.outline_llm_api_key"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="用于生成 PPT 分镜的 LLM API Key"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<!-- 通用生成与算力配置 -->
|
||||
<div class="mb-4">
|
||||
<h3 class="heading-3 mb-2">生成策略与算力配置</h3>
|
||||
<el-form-item label="单个任务最大 PPT 页数" prop="max_slides_per_task">
|
||||
<el-input-number
|
||||
v-model="config.max_slides_per_task"
|
||||
:min="1"
|
||||
:max="50"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="每张 PPT 图片消耗算力" prop="power_cost_per_slide">
|
||||
<el-input-number v-model="config.power_cost_per_slide" :min="0" class="w-full" />
|
||||
</el-form-item>
|
||||
<el-form-item label="图片生成最大并发数" prop="max_concurrent_requests">
|
||||
<el-input-number
|
||||
v-model="config.max_concurrent_requests"
|
||||
:min="1"
|
||||
:max="10"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="外部图片 API QPS 限制" prop="qps_limit">
|
||||
<el-input-number v-model="config.qps_limit" :min="1" :max="10" class="w-full" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<!-- 图片模型配置 -->
|
||||
<div class="mb-4">
|
||||
<h3 class="heading-3 mb-2">图片生成模型配置</h3>
|
||||
<el-form-item label="当前使用的图片模型" prop="active_image_provider">
|
||||
<el-select v-model="config.active_image_provider" placeholder="请选择图片生成模型">
|
||||
<el-option label="Nano Banana" value="nano_banana" />
|
||||
<el-option label="Doubao Seedream" value="seedream" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<!-- Nano Banana 配置 -->
|
||||
<el-card
|
||||
v-if="config.active_image_provider === 'nano_banana'"
|
||||
class="mb-3"
|
||||
shadow="never"
|
||||
>
|
||||
<template #header>
|
||||
<span class="font-bold">Nano Banana 配置</span>
|
||||
</template>
|
||||
<el-form-item label="Nano Banana API 地址">
|
||||
<el-input
|
||||
v-model="config.nano_banana_api_url"
|
||||
placeholder="例如:https://xxx/v1/images/generations"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="Nano Banana 模型">
|
||||
<el-input
|
||||
v-model="config.nano_banana_model"
|
||||
placeholder="例如:gemini-3.1-flash-image-preview"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="响应格式">
|
||||
<el-select
|
||||
v-model="config.nano_banana_response_format"
|
||||
placeholder="默认 url"
|
||||
class="w-full"
|
||||
clearable
|
||||
>
|
||||
<el-option label="url" value="url" />
|
||||
<el-option label="b64_json" value="b64_json" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="宽高比">
|
||||
<el-select
|
||||
v-model="config.nano_banana_aspect_ratio"
|
||||
placeholder="请选择宽高比"
|
||||
class="w-full"
|
||||
>
|
||||
<el-option label="16:9" value="16:9" />
|
||||
<el-option label="21:9" value="21:9" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="Nano Banana API Key">
|
||||
<el-input
|
||||
v-model="config.nano_banana_api_key"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="Nano Banana API Key"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-card>
|
||||
|
||||
<!-- Seedream 配置 -->
|
||||
<el-card v-if="config.active_image_provider === 'seedream'" class="mb-3" shadow="never">
|
||||
<template #header>
|
||||
<span class="font-bold">Doubao Seedream 配置</span>
|
||||
</template>
|
||||
<el-form-item label="Seedream Base URL">
|
||||
<el-input
|
||||
v-model="config.seedream_base_url"
|
||||
placeholder="例如:https://ark.cn-beijing.volces.com/api/v3(API 根地址,SDK 会拼路径)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="Seedream API Key (ARK_API_KEY)">
|
||||
<el-input
|
||||
v-model="config.seedream_api_key"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="从火山控制台获取的 ARK_API_KEY"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="模型 ID">
|
||||
<el-input
|
||||
v-model="config.seedream_model"
|
||||
placeholder="例如:doubao-seedream-5-0-260128"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="图片尺寸">
|
||||
<el-input v-model="config.seedream_size" placeholder="例如:2K" />
|
||||
</el-form-item>
|
||||
<el-form-item label="输出格式">
|
||||
<el-input v-model="config.seedream_output_format" placeholder="例如:png" />
|
||||
</el-form-item>
|
||||
<el-form-item label="响应格式">
|
||||
<el-input v-model="config.seedream_response_format" placeholder="例如:url" />
|
||||
</el-form-item>
|
||||
<el-form-item label="是否开启水印">
|
||||
<el-switch v-model="config.seedream_watermark" />
|
||||
</el-form-item>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<div style="padding: 10px">
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="save" :loading="saving">保存配置</el-button>
|
||||
<el-button @click="loadConfig">重置</el-button>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { httpGet, httpPost } from '@/utils/http'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
|
||||
const config = ref({
|
||||
outline_llm_api_url: '',
|
||||
outline_llm_api_key: '',
|
||||
outline_llm_model: 'gemini-3.1-pro-preview',
|
||||
active_image_provider: '',
|
||||
max_slides_per_task: 10,
|
||||
power_cost_per_slide: 0,
|
||||
max_concurrent_requests: 3,
|
||||
qps_limit: 1,
|
||||
nano_banana_api_url: '',
|
||||
nano_banana_api_key: '',
|
||||
nano_banana_model: 'gemini-3.1-flash-image-preview',
|
||||
nano_banana_response_format: 'url',
|
||||
nano_banana_aspect_ratio: '16:9',
|
||||
seedream_base_url: '',
|
||||
seedream_api_key: '',
|
||||
seedream_model: '',
|
||||
seedream_size: '2K',
|
||||
seedream_output_format: 'png',
|
||||
seedream_response_format: 'url',
|
||||
seedream_watermark: false,
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const formRef = ref(null)
|
||||
|
||||
const rules = reactive({
|
||||
max_slides_per_task: [{ required: true, message: '请输入最大 PPT 页数', trigger: 'blur' }],
|
||||
power_cost_per_slide: [{ type: 'number', message: '请输入合法的算力值', trigger: 'blur' }],
|
||||
})
|
||||
|
||||
const applyDefaultConfig = () => {
|
||||
if (!config.value.outline_llm_model) {
|
||||
config.value.outline_llm_model = 'gemini-3.1-pro-preview'
|
||||
}
|
||||
if (!config.value.nano_banana_model) {
|
||||
config.value.nano_banana_model = 'gemini-3.1-flash-image-preview'
|
||||
}
|
||||
if (!config.value.nano_banana_response_format) {
|
||||
config.value.nano_banana_response_format = 'url'
|
||||
}
|
||||
if (!config.value.nano_banana_aspect_ratio) {
|
||||
config.value.nano_banana_aspect_ratio = '16:9'
|
||||
}
|
||||
}
|
||||
|
||||
const loadConfig = () => {
|
||||
loading.value = true
|
||||
httpGet('/api/admin/ppt/config')
|
||||
.then((res) => {
|
||||
if (res.data) {
|
||||
config.value = Object.assign(config.value, res.data)
|
||||
}
|
||||
applyDefaultConfig()
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('加载 PPT 配置失败: ' + e.message)
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const save = () => {
|
||||
formRef.value.validate((valid) => {
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
httpPost('/api/admin/ppt/config/update', config.value)
|
||||
.then(() => {
|
||||
ElMessage.success('保存成功!')
|
||||
loadConfig()
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('保存失败:' + e.message)
|
||||
})
|
||||
.finally(() => {
|
||||
saving.value = false
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
applyDefaultConfig()
|
||||
loadConfig()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use '@/assets/css/admin/form.scss' as *;
|
||||
@use '@/assets/css/main.scss' as *;
|
||||
|
||||
.ppt-config {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -49,49 +49,68 @@
|
||||
</template>
|
||||
<el-input v-model.number="system['mj_power']" placeholder="" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Stable-Diffusion算力" prop="sd_power">
|
||||
<el-input
|
||||
v-model.number="system['sd_power']"
|
||||
placeholder="使用Stable-Diffusion画一张图消耗算力"
|
||||
/>
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<div class="label-title">
|
||||
MJ 放大(Upscale)算力
|
||||
<el-tooltip effect="dark" content="MJ 放大、变换(V1-V4)操作消耗算力" raw-content placement="right">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
<el-input v-model.number="system['mj_upscale_power']" placeholder="未配置时使用 MJ 操作算力" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<div class="label-title">
|
||||
MJ 混合(Blend)算力
|
||||
<el-tooltip effect="dark" content="MJ 融图操作消耗算力" raw-content placement="right">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
<el-input v-model.number="system['mj_blend_power']" placeholder="未配置时使用 MJ 操作算力" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<div class="label-title">
|
||||
MJ 换脸算力
|
||||
<el-tooltip effect="dark" content="MJ 换脸操作消耗算力" raw-content placement="right">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
<el-input v-model.number="system['mj_swap_face_power']" placeholder="未配置时使用 MJ 操作算力" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<div class="label-title">
|
||||
MJ 局部重绘算力
|
||||
<el-tooltip effect="dark" content="MJ 局部重绘(Inpaint)操作消耗算力" raw-content placement="right">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
<el-input v-model.number="system['mj_modal_power']" placeholder="未配置时使用 MJ 操作算力" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<div class="label-title">
|
||||
MJ 操作算力(回退默认值)
|
||||
<el-tooltip effect="dark" content="上述 MJ 分项未配置时使用的默认值" raw-content placement="right">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
<el-input v-model.number="system['mj_action_power']" placeholder="" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Suno 算力" prop="suno_power">
|
||||
<el-input
|
||||
v-model.number="system['suno_power']"
|
||||
placeholder="使用 Suno 生成一首音乐消耗算力"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="Luma 算力" prop="luma_power">
|
||||
<el-input
|
||||
v-model.number="system['luma_power']"
|
||||
placeholder="使用 Luma 生成一段视频消耗算力"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<div class="label-title">
|
||||
可灵算力
|
||||
<el-tooltip
|
||||
effect="dark"
|
||||
content="可灵每个模型价格不一样,具体请参考:https://api.geekai.pro/models"
|
||||
raw-content
|
||||
placement="right"
|
||||
>
|
||||
<el-icon>
|
||||
<InfoFilled />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
<el-row :gutter="20" v-if="system['keling_powers']">
|
||||
<el-col :span="6" v-for="[key] in Object.entries(system['keling_powers'])" :key="key">
|
||||
<el-form-item :label="key" label-position="left">
|
||||
<el-input v-model.number="system['keling_powers'][key]" size="small" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div style="padding: 10px">
|
||||
@@ -118,20 +137,6 @@ onMounted(() => {
|
||||
httpGet('/api/admin/config/get?key=system')
|
||||
.then((res) => {
|
||||
system.value = res.data
|
||||
system.value.keling_powers = system.value.keling_powers || {
|
||||
'kling-v1-6_std_5': 240,
|
||||
'kling-v1-6_std_10': 480,
|
||||
'kling-v1-6_pro_5': 420,
|
||||
'kling-v1-6_pro_10': 840,
|
||||
'kling-v1-5_std_5': 240,
|
||||
'kling-v1-5_std_10': 480,
|
||||
'kling-v1-5_pro_5': 420,
|
||||
'kling-v1-5_pro_10': 840,
|
||||
'kling-v1_std_5': 120,
|
||||
'kling-v1_std_10': 240,
|
||||
'kling-v1_pro_5': 420,
|
||||
'kling-v1_pro_10': 840,
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('加载系统配置失败: ' + e.message)
|
||||
@@ -148,10 +153,12 @@ const save = function () {
|
||||
invite_power: system.value.invite_power,
|
||||
daily_power: system.value.daily_power,
|
||||
mj_power: system.value.mj_power,
|
||||
sd_power: system.value.sd_power,
|
||||
mj_action_power: system.value.mj_action_power,
|
||||
mj_upscale_power: system.value.mj_upscale_power,
|
||||
mj_blend_power: system.value.mj_blend_power,
|
||||
mj_swap_face_power: system.value.mj_swap_face_power,
|
||||
mj_modal_power: system.value.mj_modal_power,
|
||||
suno_power: system.value.suno_power,
|
||||
luma_power: system.value.luma_power,
|
||||
keling_powers: system.value.keling_powers,
|
||||
})
|
||||
.then(() => {
|
||||
ElMessage.success('操作成功!')
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
<el-input v-model="configs.bao.username" placeholder="请输入用户名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input v-model="configs.bao.password" placeholder="请输入密码" />
|
||||
<el-input v-model="configs.bao.password" type="password" show-password placeholder="请输入密码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="短信签名">
|
||||
<el-input v-model="configs.bao.sign" placeholder="请输入短信签名" />
|
||||
@@ -66,6 +66,57 @@
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="腾讯云短信" name="tencent">
|
||||
<template #label>
|
||||
<div class="flex items-center">
|
||||
<i class="iconfont icon-tencent"></i>
|
||||
<span class="ml-2">腾讯云短信</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Alert type="info">
|
||||
如果你不知道怎么获取这些配置信息,请参考文档:
|
||||
<a
|
||||
href="https://docs.geekai.me/plus/config/sms.html#%E8%85%BE%E8%AE%AF%E4%BA%91"
|
||||
target="_blank"
|
||||
>腾讯云短信配置</a
|
||||
>。
|
||||
</Alert>
|
||||
|
||||
<el-form :model="configs.tencent" label-position="top">
|
||||
<el-form-item label="SecretId">
|
||||
<el-input v-model="configs.tencent.secret_id" placeholder="请输入SecretId" />
|
||||
</el-form-item>
|
||||
<el-form-item label="SecretKey">
|
||||
<el-input
|
||||
v-model="configs.tencent.secret_key"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="请输入SecretKey"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="应用ID (SmsSdkAppId)">
|
||||
<el-input v-model="configs.tencent.sms_sdk_app_id" placeholder="请输入短信应用ID" />
|
||||
</el-form-item>
|
||||
<el-form-item label="短信签名">
|
||||
<el-input v-model="configs.tencent.sign" placeholder="请输入短信签名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="验证码模板ID">
|
||||
<el-input v-model="configs.tencent.code_temp_id" placeholder="请输入验证码模板ID" />
|
||||
<div class="text-sm text-gray-500 mt-1">在腾讯云短信控制台创建的模板ID</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="地区 (Region)">
|
||||
<el-input
|
||||
v-model="configs.tencent.region"
|
||||
placeholder="请输入地区,默认 ap-guangzhou"
|
||||
/>
|
||||
<div class="text-sm text-gray-500 mt-1">
|
||||
常见地区:ap-guangzhou(广州)、ap-beijing(北京)、ap-shanghai(上海)
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<div class="mt-3">
|
||||
@@ -73,6 +124,7 @@
|
||||
<el-radio-group v-model="configs.active" size="large">
|
||||
<el-radio value="aliyun" border>阿里云</el-radio>
|
||||
<el-radio value="bao" border>短信宝</el-radio>
|
||||
<el-radio value="tencent" border>腾讯云</el-radio>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
@@ -104,6 +156,15 @@ const configs = ref({
|
||||
sign: '',
|
||||
code_template: '',
|
||||
},
|
||||
tencent: {
|
||||
secret_id: '',
|
||||
secret_key: '',
|
||||
sms_sdk_app_id: '',
|
||||
sign: '',
|
||||
code_temp_id: '',
|
||||
code_template: '',
|
||||
region: 'ap-guangzhou',
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
@@ -34,6 +34,21 @@
|
||||
placeholder="请输入文件存储URL,如:/static/upload"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<label class="form-label"
|
||||
>缩略图模板
|
||||
<el-tooltip placement="top">
|
||||
<template #content>
|
||||
使用{width}和{height}作为变量占位符<br />默认:?imageView2/4/w/{width}/h/{height}/q/75<br />留空则使用默认模板
|
||||
</template>
|
||||
<i class="iconfont icon-info"></i>
|
||||
</el-tooltip>
|
||||
</label>
|
||||
<el-input
|
||||
v-model="local.thumb_template"
|
||||
placeholder="?imageView2/4/w/{width}/h/{height}/q/75"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
@@ -55,6 +70,23 @@
|
||||
<el-form-item label="Bucket"><el-input v-model="minio.bucket" /></el-form-item>
|
||||
<el-form-item label="UseSSL"><el-switch v-model="minio.use_ssl" /></el-form-item>
|
||||
<el-form-item label="Domain"><el-input v-model="minio.domain" /></el-form-item>
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<label
|
||||
>缩略图模板
|
||||
<el-tooltip
|
||||
placement="top"
|
||||
content="使用{width}和{height}作为变量占位符。MinIO不支持缩略图,留空则返回原图"
|
||||
>
|
||||
<i class="iconfont icon-info"></i>
|
||||
</el-tooltip>
|
||||
</label>
|
||||
</template>
|
||||
<el-input
|
||||
v-model="minio.thumb_template"
|
||||
placeholder="留空则返回原图(MinIO不支持缩略图)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
@@ -92,6 +124,23 @@
|
||||
<el-form-item label="Domain"
|
||||
><el-input v-model="qiniu.domain" placeholder="请输入七牛云Bucket绑定的域名"
|
||||
/></el-form-item>
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<label
|
||||
>缩略图模板
|
||||
<el-tooltip
|
||||
placement="top"
|
||||
content="使用{width}和{height}作为变量占位符。默认:?imageView2/4/w/{width}/h/{height}/q/75"
|
||||
>
|
||||
<i class="iconfont icon-info"></i>
|
||||
</el-tooltip>
|
||||
</label>
|
||||
</template>
|
||||
<el-input
|
||||
v-model="qiniu.thumb_template"
|
||||
placeholder="?imageView2/4/w/{width}/h/{height}/q/75"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
@@ -112,6 +161,76 @@
|
||||
/></el-form-item>
|
||||
<el-form-item label="Bucket"><el-input v-model="aliyun.bucket" /></el-form-item>
|
||||
<el-form-item label="Domain"><el-input v-model="aliyun.domain" /></el-form-item>
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<label
|
||||
>缩略图模板
|
||||
<el-tooltip
|
||||
placement="top"
|
||||
content="使用{width}和{height}作为变量占位符。默认:?x-oss-process=image/resize,m_lfit,w_{width},h_{height}"
|
||||
>
|
||||
<i class="iconfont icon-info"></i>
|
||||
</el-tooltip>
|
||||
</label>
|
||||
</template>
|
||||
<el-input
|
||||
v-model="aliyun.thumb_template"
|
||||
placeholder="?x-oss-process=image/resize,m_lfit,w_{width},h_{height}"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="腾讯云COS" name="tencent">
|
||||
<Alert type="info">
|
||||
如果你不知道怎么获取这些配置信息,请参考文档:
|
||||
<a href="https://cloud.tencent.com/document/product/436" target="_blank">腾讯云COS配置</a
|
||||
>。
|
||||
</Alert>
|
||||
<el-form :model="tencent" class="mt-4" label-position="top">
|
||||
<el-form-item label="Region">
|
||||
<el-select
|
||||
v-model="tencent.region"
|
||||
filterable
|
||||
allow-create
|
||||
placeholder="请选择或输入区域"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="region in tencentRegions"
|
||||
:key="region.value"
|
||||
:label="region.label"
|
||||
:value="region.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="SecretId"><el-input v-model="tencent.secret_id" /></el-form-item>
|
||||
<el-form-item label="SecretKey"
|
||||
><el-input v-model="tencent.secret_key" type="password" show-password
|
||||
/></el-form-item>
|
||||
<el-form-item label="Bucket"><el-input v-model="tencent.bucket" /></el-form-item>
|
||||
<el-form-item label="Domain"
|
||||
><el-input
|
||||
v-model="tencent.domain"
|
||||
placeholder="请输入自定义域名,留空则使用COS默认域名"
|
||||
/></el-form-item>
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<label
|
||||
>缩略图模板
|
||||
<el-tooltip
|
||||
placement="top"
|
||||
content="使用{width}和{height}作为变量占位符。默认:?imageView2/1/w/{width}/h/{height}/format/jpg"
|
||||
>
|
||||
<i class="iconfont icon-info"></i>
|
||||
</el-tooltip>
|
||||
</label>
|
||||
</template>
|
||||
<el-input
|
||||
v-model="tencent.thumb_template"
|
||||
placeholder="?imageView2/1/w/{width}/h/{height}/format/jpg"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
@@ -123,6 +242,7 @@
|
||||
<el-radio value="aliyun" border>阿里云</el-radio>
|
||||
<el-radio value="qiniu" border>七牛云</el-radio>
|
||||
<el-radio value="minio" border>Minio</el-radio>
|
||||
<el-radio value="tencent" border>腾讯云</el-radio>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
@@ -141,7 +261,17 @@ import Alert from '@/components/ui/Alert.vue'
|
||||
const loading = ref(true)
|
||||
const activeTab = ref('local')
|
||||
const active = ref('local')
|
||||
const local = ref({ base_path: './static/upload', base_url: '/static/upload' })
|
||||
// 默认缩略图模板
|
||||
const DEFAULT_LOCAL_THUMB = '?imageView2/4/w/{width}/h/{height}/q/75'
|
||||
const DEFAULT_QINIU_THUMB = '?imageView2/4/w/{width}/h/{height}/q/75'
|
||||
const DEFAULT_ALIYUN_THUMB = '?x-oss-process=image/resize,m_lfit,w_{width},h_{height}'
|
||||
const DEFAULT_TENCENT_THUMB = '?imageView2/1/w/{width}/h/{height}/format/jpg'
|
||||
|
||||
const local = ref({
|
||||
base_path: './static/upload',
|
||||
base_url: '/static/upload',
|
||||
thumb_template: DEFAULT_LOCAL_THUMB,
|
||||
})
|
||||
const minio = ref({
|
||||
endpoint: '',
|
||||
access_key: '',
|
||||
@@ -149,6 +279,7 @@ const minio = ref({
|
||||
bucket: '',
|
||||
use_ssl: false,
|
||||
domain: '',
|
||||
thumb_template: '', // MinIO不支持缩略图,留空
|
||||
})
|
||||
const qiniu = ref({
|
||||
zone: 'z2',
|
||||
@@ -156,6 +287,7 @@ const qiniu = ref({
|
||||
access_secret: '',
|
||||
bucket: '',
|
||||
domain: '',
|
||||
thumb_template: DEFAULT_QINIU_THUMB,
|
||||
})
|
||||
const aliyun = ref({
|
||||
endpoint: '',
|
||||
@@ -163,6 +295,31 @@ const aliyun = ref({
|
||||
access_secret: '',
|
||||
bucket: '',
|
||||
domain: '',
|
||||
thumb_template: DEFAULT_ALIYUN_THUMB,
|
||||
})
|
||||
// 腾讯云 COS 常见地区选项
|
||||
const tencentRegions = [
|
||||
{ label: '北京 (ap-beijing)', value: 'ap-beijing' },
|
||||
{ label: '南京 (ap-nanjing)', value: 'ap-nanjing' },
|
||||
{ label: '上海 (ap-shanghai)', value: 'ap-shanghai' },
|
||||
{ label: '广州 (ap-guangzhou)', value: 'ap-guangzhou' },
|
||||
{ label: '成都 (ap-chengdu)', value: 'ap-chengdu' },
|
||||
{ label: '重庆 (ap-chongqing)', value: 'ap-chongqing' },
|
||||
{ label: '中国香港 (ap-hongkong)', value: 'ap-hongkong' },
|
||||
{ label: '新加坡 (ap-singapore)', value: 'ap-singapore' },
|
||||
{ label: '雅加达 (ap-jakarta)', value: 'ap-jakarta' },
|
||||
{ label: '首尔 (ap-seoul)', value: 'ap-seoul' },
|
||||
{ label: '曼谷 (ap-bangkok)', value: 'ap-bangkok' },
|
||||
{ label: '东京 (ap-tokyo)', value: 'ap-tokyo' },
|
||||
]
|
||||
|
||||
const tencent = ref({
|
||||
region: 'ap-beijing',
|
||||
secret_id: '',
|
||||
secret_key: '',
|
||||
bucket: '',
|
||||
domain: '',
|
||||
thumb_template: DEFAULT_TENCENT_THUMB,
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
@@ -170,14 +327,45 @@ onMounted(() => {
|
||||
.then((res) => {
|
||||
const data = res.data || {}
|
||||
active.value = data.active.toLowerCase() || active.value
|
||||
local.value = data.local || local.value
|
||||
minio.value = data.minio || minio.value
|
||||
qiniu.value = data.qiniu || qiniu.value
|
||||
aliyun.value = data.aliyun || aliyun.value
|
||||
|
||||
// 合并配置,如果服务器返回的配置中没有thumb_template或为空,则使用默认值
|
||||
if (data.local) {
|
||||
local.value = { ...local.value, ...data.local }
|
||||
if (!data.local.thumb_template) {
|
||||
local.value.thumb_template = DEFAULT_LOCAL_THUMB
|
||||
}
|
||||
}
|
||||
|
||||
if (data.minio) {
|
||||
minio.value = { ...minio.value, ...data.minio }
|
||||
// MinIO不支持缩略图,保持为空
|
||||
}
|
||||
|
||||
if (data.qiniu) {
|
||||
qiniu.value = { ...qiniu.value, ...data.qiniu }
|
||||
if (!data.qiniu.thumb_template) {
|
||||
qiniu.value.thumb_template = DEFAULT_QINIU_THUMB
|
||||
}
|
||||
}
|
||||
|
||||
if (data.aliyun) {
|
||||
aliyun.value = { ...aliyun.value, ...data.aliyun }
|
||||
if (!data.aliyun.thumb_template) {
|
||||
aliyun.value.thumb_template = DEFAULT_ALIYUN_THUMB
|
||||
}
|
||||
}
|
||||
|
||||
if (data.tencent) {
|
||||
tencent.value = { ...tencent.value, ...data.tencent }
|
||||
if (!data.tencent.thumb_template) {
|
||||
tencent.value.thumb_template = DEFAULT_TENCENT_THUMB
|
||||
}
|
||||
}
|
||||
|
||||
minio.value.bucket = minio.value.bucket || 'geekai'
|
||||
qiniu.value.bucket = qiniu.value.bucket || 'geekai'
|
||||
aliyun.value.bucket = aliyun.value.bucket || 'geekai'
|
||||
tencent.value.bucket = tencent.value.bucket || 'geekai'
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => (loading.value = false))
|
||||
@@ -190,8 +378,11 @@ const save = () => {
|
||||
minio: minio.value,
|
||||
qiniu: qiniu.value,
|
||||
aliyun: aliyun.value,
|
||||
tencent: tencent.value,
|
||||
})
|
||||
.then(() => ElMessage.success('保存成功'))
|
||||
.then(() => {
|
||||
ElMessage.success('保存成功')
|
||||
})
|
||||
.catch((e) => ElMessage.error(e.message))
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<div class="system-config form">
|
||||
<div class="container">
|
||||
<el-tabs type="border-card" v-model="activeName">
|
||||
<el-tab-pane label="微信公众号配置" name="gzh">
|
||||
<el-form
|
||||
label-width="150px"
|
||||
label-position="top"
|
||||
:model="gzhConfig"
|
||||
ref="wechatFormRef"
|
||||
:rules="rules"
|
||||
>
|
||||
<el-form-item label="微信公众号 AppID" prop="app_id">
|
||||
<el-input v-model="gzhConfig['app_id']" />
|
||||
</el-form-item>
|
||||
<el-form-item label="微信公众号 AppSecret" prop="secret">
|
||||
<el-input v-model="gzhConfig['secret']" />
|
||||
</el-form-item>
|
||||
<el-form-item label="微信公众号 Token" prop="token">
|
||||
<el-input v-model="gzhConfig['token']" />
|
||||
</el-form-item>
|
||||
<el-form-item label="微信公众号 EncodingAESKey" prop="encoding_aes_key">
|
||||
<el-input v-model="gzhConfig['encoding_aes_key']" />
|
||||
</el-form-item>
|
||||
<el-form-item label="启用微信公众号登录" prop="enabled">
|
||||
<el-switch v-model="gzhConfig['enabled']" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<div style="padding: 10px">
|
||||
<el-button type="primary" @click="save">保存</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { httpGet, httpPost } from '@/utils/http'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const activeName = ref('gzh')
|
||||
const gzhConfig = ref({ app_id: '', secret: '' })
|
||||
const wechatFormRef = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
// 加载系统配置
|
||||
httpGet('/api/admin/config/get?key=wx_gzh')
|
||||
.then((res) => {
|
||||
gzhConfig.value = res.data || {}
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('加载系统配置失败: ' + e.message)
|
||||
})
|
||||
})
|
||||
|
||||
const rules = reactive({
|
||||
app_id: [{ required: true, message: '请输入公众号 AppID', trigger: 'blur' }],
|
||||
secret: [{ required: true, message: '请输入公众号 AppSecret', trigger: 'blur' }],
|
||||
})
|
||||
const save = function () {
|
||||
wechatFormRef.value.validate((valid) => {
|
||||
if (valid) {
|
||||
httpPost('/api/admin/config/update/wx_gzh', {
|
||||
app_id: gzhConfig.value.app_id,
|
||||
secret: gzhConfig.value.secret,
|
||||
token: gzhConfig.value.token,
|
||||
encoding_aes_key: gzhConfig.value.encoding_aes_key,
|
||||
enabled: gzhConfig.value.enabled,
|
||||
})
|
||||
.then(() => {
|
||||
ElMessage.success('操作成功!')
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('操作失败:' + e.message)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="css" scoped>
|
||||
@import '@/assets/css/admin/form.css';
|
||||
@import '@/assets/css/main.css';
|
||||
|
||||
.system-config {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
.sys-tabs {
|
||||
width: 100%;
|
||||
background-color: var(--el-bg-color);
|
||||
padding: 10px 20px 40px 20px;
|
||||
/* border: 1px solid var(--el-border-color); */
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,387 @@
|
||||
<template>
|
||||
<div class="system-config form" v-loading="loading">
|
||||
<div class="container">
|
||||
<el-form
|
||||
:model="videoConfig"
|
||||
label-width="150px"
|
||||
label-position="top"
|
||||
ref="configFormRef"
|
||||
:rules="rules"
|
||||
class="py-3 px-5"
|
||||
>
|
||||
<!-- API 配置分组 -->
|
||||
<div class="mb-3">
|
||||
<h3 class="heading-3 mb-2">API 配置</h3>
|
||||
<div class="py-3">
|
||||
<Alert type="info">
|
||||
<p class="mb-1">
|
||||
配置视频生成服务的 API 地址和 API Key。这些配置将用于所有视频生成模型的 API 调用。
|
||||
</p>
|
||||
</Alert>
|
||||
</div>
|
||||
<el-form-item label="API 地址" prop="api_url">
|
||||
<el-input
|
||||
v-model="videoConfig.api_url"
|
||||
placeholder="请输入视频生成服务的 API 地址,如:https://api.geekai.pro"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="API Key" prop="api_key">
|
||||
<el-input
|
||||
v-model="videoConfig.api_key"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="请输入视频生成服务的 API Key"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-divider />
|
||||
<!-- 算力配置分组 -->
|
||||
<div class="mb-3">
|
||||
<h3 class="heading-3 mb-3">模型算力配置</h3>
|
||||
<Alert type="info" class="mb-3">
|
||||
<div class="text-gray-500">
|
||||
根据模型的 priceParams
|
||||
配置自动生成价格组合。固定价格模型只需配置一个价格,多参数模型会生成所有参数组合的价格配置。
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
<div v-for="provider in providers" :key="provider" class="mb-4">
|
||||
<h4 class="mb-2 text-base font-bold flex items-center gap-2">
|
||||
<span>{{ getProviderName(provider) }}</span>
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div
|
||||
v-for="model in getModelsByProvider(provider)"
|
||||
:key="model.key"
|
||||
class="p-3 rounded-md border border-gray-100"
|
||||
>
|
||||
<div class="text-sm mb-2">
|
||||
<div class="font-bold">{{ model.name }}</div>
|
||||
<div class="text-gray-500 line-clamp-2" :title="model.label">
|
||||
{{ model.label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模型基础信息 -->
|
||||
<el-form-item label="模型标识" class="mb-2">
|
||||
<el-input
|
||||
v-model="getModelConfig(model.key).model"
|
||||
placeholder="模型标识,如:veo-2.0"
|
||||
size="small"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="服务提供商" class="mb-2">
|
||||
<el-input
|
||||
v-model="getModelConfig(model.key).provider"
|
||||
placeholder="服务提供商,如:veo"
|
||||
size="small"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 价格配置 -->
|
||||
<el-divider />
|
||||
<div class="text-xs text-gray-500 mb-2">
|
||||
价格配置(基于 priceParams: {{ JSON.stringify(model.priceParams || []) }})
|
||||
</div>
|
||||
<template v-for="priceKey in generatePriceKeys(model)" :key="priceKey">
|
||||
<el-form-item :label="getPriceKeyLabel(priceKey, model)" class="mb-2">
|
||||
<el-input-number
|
||||
v-model="getModelConfig(model.key).power_config[priceKey]"
|
||||
:min="1"
|
||||
:placeholder="`请输入${getPriceKeyLabel(priceKey, model)}的算力消耗`"
|
||||
size="small"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 10px">
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="saveConfig" :loading="saving">保存配置</el-button>
|
||||
<el-button @click="resetConfig">重置</el-button>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Alert from '@/components/ui/Alert.vue'
|
||||
import { VideoParams, getVideoProviders } from '@/store/data/video_params'
|
||||
import { httpGet, httpPost } from '@/utils/http'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
const videoConfig = ref({
|
||||
api_url: '',
|
||||
api_key: '',
|
||||
video_powers: {},
|
||||
})
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const configFormRef = ref()
|
||||
const providers = getVideoProviders()
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
api_url: [{ required: true, message: '请输入 API 地址', trigger: 'blur' }],
|
||||
api_key: [{ required: true, message: '请输入 API Key', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
// 获取服务提供商名称
|
||||
const getProviderName = (provider) => {
|
||||
const names = {
|
||||
veo: 'Veo',
|
||||
sora: 'Sora',
|
||||
luma: 'Luma',
|
||||
keling: '可灵',
|
||||
minimax: 'MiniMax',
|
||||
wan: '通义万相',
|
||||
}
|
||||
return names[provider] || provider
|
||||
}
|
||||
|
||||
// 获取指定 provider 的所有模型
|
||||
const getModelsByProvider = (provider) => {
|
||||
return VideoParams[provider] || []
|
||||
}
|
||||
|
||||
// 获取模型配置
|
||||
const getModelConfig = (modelKey) => {
|
||||
if (!videoConfig.value.video_powers[modelKey]) {
|
||||
// 从 VideoParams 中获取默认配置
|
||||
const model = findModelByKey(modelKey)
|
||||
const priceKeys = generatePriceKeys(model)
|
||||
const defaultPowerConfig = {}
|
||||
priceKeys.forEach((key) => {
|
||||
defaultPowerConfig[key] = 10
|
||||
})
|
||||
|
||||
videoConfig.value.video_powers[modelKey] = {
|
||||
provider: model?.provider || '',
|
||||
model: modelKey,
|
||||
power_config: defaultPowerConfig,
|
||||
api_key_type: '',
|
||||
}
|
||||
}
|
||||
// 确保 power_config 包含所有需要的 priceKeys
|
||||
const model = findModelByKey(modelKey)
|
||||
const priceKeys = generatePriceKeys(model)
|
||||
const currentConfig = videoConfig.value.video_powers[modelKey]
|
||||
if (!currentConfig.power_config) {
|
||||
currentConfig.power_config = {}
|
||||
}
|
||||
priceKeys.forEach((key) => {
|
||||
if (currentConfig.power_config[key] === undefined) {
|
||||
currentConfig.power_config[key] = 10
|
||||
}
|
||||
})
|
||||
return currentConfig
|
||||
}
|
||||
|
||||
// 根据 key 查找模型
|
||||
const findModelByKey = (key) => {
|
||||
for (const provider in VideoParams) {
|
||||
const models = VideoParams[provider]
|
||||
const model = models.find((m) => m.key === key)
|
||||
if (model) {
|
||||
return model
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 获取参数的所有可能值
|
||||
const getParamValues = (model, paramName) => {
|
||||
const param = model.params?.find((p) => p.name === paramName)
|
||||
if (!param) return []
|
||||
|
||||
if (param.type === 'select' && param.options) {
|
||||
return param.options.map((opt) => opt.value)
|
||||
}
|
||||
if (param.type === 'switch') {
|
||||
return [true, false]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// 笛卡尔乘积生成函数
|
||||
const cartesianProduct = (arrays) => {
|
||||
return arrays.reduce(
|
||||
(acc, arr) => {
|
||||
return acc.flatMap((x) => arr.map((y) => [...x, y]))
|
||||
},
|
||||
[[]]
|
||||
)
|
||||
}
|
||||
|
||||
// 格式化参数值为字符串(用于生成 priceKey)
|
||||
const formatParamValue = (paramName, value) => {
|
||||
if (paramName === 'sound') {
|
||||
// 处理 sound 参数:支持 'on'/'off' 字符串,也支持 true/false 布尔值
|
||||
return value === true || value === 'true' || value === 1 || value === 'on' ? 'sound' : 'silent'
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'true' : 'false'
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
// 根据模型的 priceParams 生成所有价格 key
|
||||
const generatePriceKeys = (model) => {
|
||||
if (!model.priceParams || !Array.isArray(model.priceParams)) {
|
||||
return ['fixed']
|
||||
}
|
||||
|
||||
// 如果是固定价格
|
||||
if (model.priceParams.length === 1 && model.priceParams[0] === 'fixed') {
|
||||
return ['fixed']
|
||||
}
|
||||
|
||||
// 获取每个参数的所有可能值
|
||||
const paramValueArrays = model.priceParams.map((paramName) => {
|
||||
const values = getParamValues(model, paramName)
|
||||
if (values.length === 0) {
|
||||
// 如果参数没有预定义选项,返回参数名本身(这种情况应该很少)
|
||||
return [paramName]
|
||||
}
|
||||
return values
|
||||
})
|
||||
|
||||
// 生成笛卡尔乘积
|
||||
const combinations = cartesianProduct(paramValueArrays)
|
||||
|
||||
// 将每个组合转换为 priceKey(用下划线连接)
|
||||
return combinations.map((combination) => {
|
||||
return combination
|
||||
.map((value, index) => formatParamValue(model.priceParams[index], value))
|
||||
.join('_')
|
||||
})
|
||||
}
|
||||
|
||||
// 生成价格 key 的显示标签
|
||||
const getPriceKeyLabel = (priceKey, model) => {
|
||||
if (priceKey === 'fixed') {
|
||||
return '固定价格(算力)'
|
||||
}
|
||||
|
||||
if (!model.priceParams || model.priceParams.length === 0) {
|
||||
return priceKey
|
||||
}
|
||||
|
||||
// 解析 priceKey
|
||||
const parts = priceKey.split('_')
|
||||
const labels = []
|
||||
|
||||
model.priceParams.forEach((paramName, index) => {
|
||||
const value = parts[index]
|
||||
const param = model.params?.find((p) => p.name === paramName)
|
||||
|
||||
if (param) {
|
||||
let label = param.label || paramName
|
||||
|
||||
// 查找对应的选项标签
|
||||
if (param.type === 'select' && param.options) {
|
||||
const option = param.options.find((opt) => {
|
||||
const optValue = formatParamValue(paramName, opt.value)
|
||||
return optValue === value
|
||||
})
|
||||
if (option) {
|
||||
label = `${label}: ${option.label}`
|
||||
} else {
|
||||
label = `${label}: ${value}`
|
||||
}
|
||||
} else if (param.type === 'switch') {
|
||||
label = `${label}: ${value === 'sound' ? '有声音' : value === 'silent' ? '无声音' : value}`
|
||||
} else {
|
||||
label = `${label}: ${value}`
|
||||
}
|
||||
|
||||
labels.push(label)
|
||||
} else {
|
||||
labels.push(`${paramName}: ${value}`)
|
||||
}
|
||||
})
|
||||
|
||||
return labels.join(' | ')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadConfig()
|
||||
})
|
||||
|
||||
// 加载配置
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const res = await httpGet('/api/admin/video/config')
|
||||
const cfg = res.data || {}
|
||||
cfg.video_powers = cfg.video_powers || {}
|
||||
videoConfig.value = cfg
|
||||
} catch (e) {
|
||||
ElMessage.error('加载配置失败: ' + e.message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
const saveConfig = async () => {
|
||||
try {
|
||||
await configFormRef.value.validate()
|
||||
saving.value = true
|
||||
await httpPost('/api/admin/video/config/update', videoConfig.value)
|
||||
ElMessage.success('配置保存成功!')
|
||||
} catch (e) {
|
||||
if (e.message) {
|
||||
ElMessage.error(e.message)
|
||||
} else {
|
||||
// 处理验证参数报错
|
||||
for (const key in e) {
|
||||
ElMessage.error(e[key][0]?.message)
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置配置
|
||||
const resetConfig = () => {
|
||||
videoConfig.value = {
|
||||
api_url: '',
|
||||
api_key: '',
|
||||
video_powers: {},
|
||||
}
|
||||
ElMessage.info('配置已重置')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use '@/assets/css/admin/form.scss' as *;
|
||||
@use '@/assets/css/main.scss' as *;
|
||||
|
||||
.system-config {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.heading-3 {
|
||||
color: var(--theme-text-color-primary);
|
||||
}
|
||||
|
||||
.el-input-number {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -10,7 +10,7 @@
|
||||
v-for="item in currentApps"
|
||||
:key="item.id"
|
||||
:app="item"
|
||||
:has-role="hasRole(item.key)"
|
||||
:has-role="hasRole(item.id)"
|
||||
@use-role="useRole"
|
||||
@update-role="updateRole"
|
||||
/>
|
||||
@@ -45,7 +45,7 @@
|
||||
v-for="item in getAppsByType(type.id)"
|
||||
:key="item.id"
|
||||
:app="item"
|
||||
:has-role="hasRole(item.key)"
|
||||
:has-role="hasRole(item.id)"
|
||||
@use-role="useRole"
|
||||
@update-role="updateRole"
|
||||
/>
|
||||
@@ -118,7 +118,7 @@ onMounted(async () => {
|
||||
try {
|
||||
const user = await checkSession()
|
||||
isLogin.value = true
|
||||
roles.value = user.chat_roles
|
||||
roles.value = Array.isArray(user.chat_roles) ? user.chat_roles : []
|
||||
} catch (error) {
|
||||
// 用户未登录,继续执行
|
||||
}
|
||||
@@ -181,30 +181,30 @@ const updateRole = async (app, opt) => {
|
||||
let actionTitle = ''
|
||||
if (opt === 'add') {
|
||||
actionTitle = '添加应用'
|
||||
const exists = arrayContains(roles.value, app.key)
|
||||
const exists = arrayContains(roles.value, app.id)
|
||||
if (exists) {
|
||||
return
|
||||
}
|
||||
roles.value.push(app.key)
|
||||
roles.value.push(app.id)
|
||||
} else {
|
||||
actionTitle = '移除应用'
|
||||
const exists = arrayContains(roles.value, app.key)
|
||||
const exists = arrayContains(roles.value, app.id)
|
||||
if (!exists) {
|
||||
return
|
||||
}
|
||||
roles.value = removeArrayItem(roles.value, app.key)
|
||||
roles.value = removeArrayItem(roles.value, app.id)
|
||||
}
|
||||
|
||||
try {
|
||||
await httpPost('/api/app/update', { keys: roles.value })
|
||||
await httpPost('/api/app/workspace', { ids: roles.value })
|
||||
showNotify({ type: 'success', message: actionTitle + '成功!' })
|
||||
} catch (e) {
|
||||
showNotify({ type: 'danger', message: actionTitle + '失败:' + e.message })
|
||||
}
|
||||
}
|
||||
|
||||
const hasRole = (roleKey) => {
|
||||
return arrayContains(roles.value, roleKey, (v1, v2) => v1 === v2)
|
||||
const hasRole = (roleId) => {
|
||||
return arrayContains(roles.value, roleId, (v1, v2) => v1 === v2)
|
||||
}
|
||||
|
||||
const useRole = (roleId) => {
|
||||
|
||||
@@ -15,22 +15,13 @@
|
||||
<image-mj />
|
||||
</div>
|
||||
</CustomTabPane>
|
||||
<CustomTabPane name="sd">
|
||||
<template #label>
|
||||
<i class="iconfont icon-sd mr-1"></i>
|
||||
<span>StableDiffusion</span>
|
||||
</template>
|
||||
<div class="tab-content">
|
||||
<image-sd />
|
||||
</div>
|
||||
</CustomTabPane>
|
||||
<CustomTabPane name="dalle">
|
||||
<CustomTabPane name="image">
|
||||
<template #label>
|
||||
<i class="iconfont icon-dalle mr-1"></i>
|
||||
<span>Dalle</span>
|
||||
<span>AI图像生成</span>
|
||||
</template>
|
||||
<div class="tab-content">
|
||||
<image-dall />
|
||||
<image-page />
|
||||
</div>
|
||||
</CustomTabPane>
|
||||
</CustomTabs>
|
||||
@@ -41,9 +32,8 @@
|
||||
<script setup>
|
||||
import CustomTabPane from '@/components/ui/CustomTabPane.vue'
|
||||
import CustomTabs from '@/components/ui/CustomTabs.vue'
|
||||
import ImageDall from '@/views/mobile/pages/ImageDall.vue'
|
||||
import ImagePage from '@/views/mobile/pages/Image.vue'
|
||||
import ImageMj from '@/views/mobile/pages/ImageMj.vue'
|
||||
import ImageSd from '@/views/mobile/pages/ImageSd.vue'
|
||||
import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
|
||||
@@ -65,28 +65,16 @@ const aiTools = ref([
|
||||
path: '/mj',
|
||||
},
|
||||
{
|
||||
key: 'sd',
|
||||
name: 'SD绘画',
|
||||
desc: 'Stable Diffusion本地化',
|
||||
icon: 'icon-sd',
|
||||
gradient: 'linear-gradient(135deg, #06B6D4, #0891B2)',
|
||||
tag: 'AI绘画',
|
||||
status: 'active',
|
||||
statusText: '可用',
|
||||
url: '/mobile/create?tab=sd',
|
||||
path: '/sd',
|
||||
},
|
||||
{
|
||||
key: 'dalle',
|
||||
name: 'DALL·E',
|
||||
key: 'image',
|
||||
name: 'AI图像生成',
|
||||
desc: 'OpenAI图像生成',
|
||||
icon: 'icon-dalle',
|
||||
gradient: 'linear-gradient(135deg, #F59E0B, #D97706)',
|
||||
tag: 'AI绘画',
|
||||
status: 'active',
|
||||
statusText: '可用',
|
||||
url: '/mobile/create?tab=dalle',
|
||||
path: '/dalle',
|
||||
url: '/mobile/create?tab=image',
|
||||
path: '/image',
|
||||
},
|
||||
{
|
||||
key: 'suno',
|
||||
|
||||
@@ -4,11 +4,8 @@
|
||||
<CustomTabPane name="mj" label="MJ" v-if="activeMenu.mj">
|
||||
<image-mj />
|
||||
</CustomTabPane>
|
||||
<CustomTabPane name="sd" label="SD" v-if="activeMenu.sd">
|
||||
<image-sd />
|
||||
</CustomTabPane>
|
||||
<CustomTabPane name="dall" label="DALL" v-if="activeMenu.dall">
|
||||
<image-dall />
|
||||
<CustomTabPane name="image" label="AI图像生成" v-if="activeMenu.image">
|
||||
<image-page />
|
||||
</CustomTabPane>
|
||||
</CustomTabs>
|
||||
</div>
|
||||
@@ -18,17 +15,15 @@
|
||||
import CustomTabPane from '@/components/ui/CustomTabPane.vue'
|
||||
import CustomTabs from '@/components/ui/CustomTabs.vue'
|
||||
import { httpGet } from '@/utils/http'
|
||||
import ImageDall from '@/views/mobile/pages/ImageDall.vue'
|
||||
import ImagePage from '@/views/mobile/pages/Image.vue'
|
||||
import ImageMj from '@/views/mobile/pages/ImageMj.vue'
|
||||
import ImageSd from '@/views/mobile/pages/ImageSd.vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
const activeName = ref('')
|
||||
const menus = ref([])
|
||||
const activeMenu = ref({
|
||||
mj: false,
|
||||
sd: false,
|
||||
dall: false,
|
||||
image: false,
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
@@ -36,8 +31,7 @@ onMounted(() => {
|
||||
menus.value = res.data
|
||||
activeMenu.value = {
|
||||
mj: menus.value.some((item) => item.url === '/mj'),
|
||||
sd: menus.value.some((item) => item.url === '/sd'),
|
||||
dall: menus.value.some((item) => item.url === '/dalle'),
|
||||
image: menus.value.some((item) => item.url === '/image'),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -138,14 +138,13 @@ const userAvatar = ref('/images/avatar/default.jpg')
|
||||
// 功能配置
|
||||
const features = ref([
|
||||
{ key: 'mj', name: 'MJ绘画', icon: 'icon-mj', color: '#8B5CF6', url: '/mobile/create?tab=mj' },
|
||||
{ key: 'sd', name: 'SD绘画', icon: 'icon-sd', color: '#06B6D4', url: '/mobile/create?tab=sd' },
|
||||
{
|
||||
key: 'dalle',
|
||||
name: 'DALL·E',
|
||||
key: 'image',
|
||||
name: 'AI图像生成',
|
||||
icon: 'icon-dalle',
|
||||
color: '#F59E0B',
|
||||
url: '/mobile/create?tab=dalle',
|
||||
path: '/dalle',
|
||||
url: '/mobile/create?tab=image',
|
||||
path: '/image',
|
||||
},
|
||||
{
|
||||
key: 'suno',
|
||||
@@ -226,7 +225,7 @@ onMounted(() => {
|
||||
checkSession()
|
||||
.then((user) => {
|
||||
isLogin.value = true
|
||||
roles.value = user.chat_roles
|
||||
roles.value = Array.isArray(user.chat_roles) ? user.chat_roles : []
|
||||
userAvatar.value = user.avatar || '/images/avatar/default.jpg'
|
||||
})
|
||||
.catch(() => {})
|
||||
@@ -265,20 +264,20 @@ const updateRole = (row, opt) => {
|
||||
let actionTitle = ''
|
||||
if (opt === 'add') {
|
||||
actionTitle = '添加应用'
|
||||
const exists = arrayContains(roles.value, row.key)
|
||||
const exists = arrayContains(roles.value, row.id)
|
||||
if (exists) {
|
||||
return
|
||||
}
|
||||
roles.value.push(row.key)
|
||||
roles.value.push(row.id)
|
||||
} else {
|
||||
actionTitle = '移除应用'
|
||||
const exists = arrayContains(roles.value, row.key)
|
||||
const exists = arrayContains(roles.value, row.id)
|
||||
if (!exists) {
|
||||
return
|
||||
}
|
||||
roles.value = removeArrayItem(roles.value, row.key)
|
||||
roles.value = removeArrayItem(roles.value, row.id)
|
||||
}
|
||||
httpPost('/api/app/update', { keys: roles.value })
|
||||
httpPost('/api/app/workspace', { ids: roles.value })
|
||||
.then(() => {
|
||||
showNotify({ type: 'success', message: actionTitle + '成功!', duration: 1000 })
|
||||
})
|
||||
@@ -287,8 +286,8 @@ const updateRole = (row, opt) => {
|
||||
})
|
||||
}
|
||||
|
||||
const hasRole = (roleKey) => {
|
||||
return arrayContains(roles.value, roleKey, (v1, v2) => v1 === v2)
|
||||
const hasRole = (roleId) => {
|
||||
return arrayContains(roles.value, roleId, (v1, v2) => v1 === v2)
|
||||
}
|
||||
|
||||
const useRole = (roleId) => {
|
||||
|
||||
@@ -116,6 +116,7 @@ import { useSharedStore } from '@/store/sharedata'
|
||||
import { httpGet, httpPost } from '@/utils/http'
|
||||
import FunDisabled from '@/components/ui/FunDisabled.vue'
|
||||
import QRCode from 'qrcode'
|
||||
import { isWechat } from '@/utils/libs'
|
||||
import { showFailToast, showLoadingToast, showSuccessToast } from 'vant'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
@@ -143,6 +144,7 @@ const handler = ref(null)
|
||||
const title = ref('')
|
||||
|
||||
const store = useSharedStore()
|
||||
const wxGzhConfig = ref({})
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
@@ -177,6 +179,15 @@ onMounted(() => {
|
||||
console.error('获取系统配置失败:', e.message)
|
||||
})
|
||||
|
||||
// 获取微信公众号配置
|
||||
httpGet('/api/config/get?key=wx_gzh')
|
||||
.then((res) => {
|
||||
wxGzhConfig.value = res.data
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('获取微信公众号配置失败:', e.message)
|
||||
})
|
||||
|
||||
getMenus()
|
||||
.then((data) => {
|
||||
menus.value = data
|
||||
@@ -202,9 +213,13 @@ const wxPay = (product) => {
|
||||
message: '正在生成支付订单...',
|
||||
forbidClick: true,
|
||||
})
|
||||
|
||||
var payWay = 'wxpay'
|
||||
var device = 'pc'
|
||||
if (isWechat()) {
|
||||
device = 'mobile'
|
||||
}
|
||||
// 生成支付订单
|
||||
GenerateOrder('wxpay')
|
||||
GenerateOrder(payWay, device)
|
||||
}
|
||||
|
||||
const alipay = (product) => {
|
||||
@@ -227,30 +242,49 @@ const alipay = (product) => {
|
||||
GenerateOrder('alipay')
|
||||
}
|
||||
|
||||
function GenerateOrder(payWay) {
|
||||
function GenerateOrder(payWay, device = 'pc') {
|
||||
// 生成支付订单
|
||||
httpPost('/api/payment/create', {
|
||||
pid: selectedPid.value,
|
||||
pay_way: payWay,
|
||||
domain: `${window.location.protocol}//${window.location.host}`,
|
||||
device: 'pc',
|
||||
device: device,
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.data.pay_url) {
|
||||
// 生成二维码
|
||||
QRCode.toDataURL(res.data.pay_url, { width: 200, height: 200, margin: 2 }, (error, url) => {
|
||||
if (error) {
|
||||
showFailToast('生成二维码失败')
|
||||
} else {
|
||||
qrImg.value = url
|
||||
showPayDialog.value = true
|
||||
// 开始查询订单状态
|
||||
if (handler.value) {
|
||||
clearTimeout(handler.value)
|
||||
// 如果是微信浏览器,并且微信公众号配置启用,则使用微信JSAPI支付
|
||||
if (isWechat() && wxGzhConfig.value.enabled) {
|
||||
let payParams = JSON.parse(res.data.pay_url)
|
||||
if (typeof WeixinJSBridge == 'undefined') {
|
||||
if (document.addEventListener) {
|
||||
document.addEventListener('WeixinJSBridgeReady', onBridgeReady(payParams), false)
|
||||
} else if (document.attachEvent) {
|
||||
document.attachEvent('WeixinJSBridgeReady', onBridgeReady(payParams))
|
||||
document.attachEvent('onWeixinJSBridgeReady', onBridgeReady(payParams))
|
||||
}
|
||||
handler.value = setTimeout(() => queryOrder(res.data.order_no), 3000)
|
||||
} else {
|
||||
onBridgeReady(payParams)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 生成二维码
|
||||
QRCode.toDataURL(
|
||||
res.data.pay_url,
|
||||
{ width: 200, height: 200, margin: 2 },
|
||||
(error, url) => {
|
||||
if (error) {
|
||||
showFailToast('生成二维码失败')
|
||||
} else {
|
||||
qrImg.value = url
|
||||
showPayDialog.value = true
|
||||
// 开始查询订单状态
|
||||
if (handler.value) {
|
||||
clearTimeout(handler.value)
|
||||
}
|
||||
handler.value = setTimeout(() => queryOrder(res.data.order_no), 3000)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
showFailToast('支付链接生成失败')
|
||||
}
|
||||
@@ -301,6 +335,31 @@ onUnmounted(() => {
|
||||
handler.value = null
|
||||
}
|
||||
})
|
||||
const onBridgeReady = function onBridgeReady(payParams) {
|
||||
WeixinJSBridge.invoke(
|
||||
'getBrandWCPayRequest',
|
||||
{
|
||||
appId: payParams.appId, //公众号ID,由商户传入
|
||||
timeStamp: payParams.timeStamp, //时间戳,自1970年以来的秒数
|
||||
nonceStr: payParams.nonceStr, //随机串
|
||||
package: payParams.package, //预支付交易会话标识
|
||||
signType: payParams.signType, //微信签名方式:
|
||||
paySign: payParams.paySign, //微信签名
|
||||
},
|
||||
function (res) {
|
||||
if (res.err_msg == 'get_brand_wcpay_request:ok') {
|
||||
// 使用以上方式判断前端返回,微信团队郑重提示:
|
||||
//res.err_msg将在用户支付成功后返回ok,但并不保证它绝对可靠。
|
||||
showSuccessToast('支付成功!')
|
||||
setTimeout(() => {
|
||||
location.reload()
|
||||
}, 1500)
|
||||
} else {
|
||||
showFailToast('支付失败:' + res.err_msg)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -313,10 +313,10 @@
|
||||
<!-- 标签 -->
|
||||
<div class="flex items-center space-x-2 mt-2">
|
||||
<span
|
||||
v-if="item.major_model_version"
|
||||
v-if="item.params.model"
|
||||
class="px-2 py-1 text-xs bg-blue-100 text-blue-600 rounded-full"
|
||||
>
|
||||
{{ item.major_model_version }}
|
||||
{{ item.params.model }}
|
||||
</span>
|
||||
<span
|
||||
v-if="item.type === 4"
|
||||
|
||||
@@ -14,425 +14,60 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 视频类型切换 -->
|
||||
<!-- 参数设置区域 -->
|
||||
<div class="p-4 space-y-6">
|
||||
<!-- 视频类型选择 -->
|
||||
<!-- 提供商切换 -->
|
||||
<div class="bg-white rounded-xl p-3 shadow-sm">
|
||||
<div class="flex space-x-2">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
@click="video.switchVideoType('luma')"
|
||||
v-for="p in providerOrder"
|
||||
:key="p"
|
||||
@click="store.switchProvider(p)"
|
||||
:class="[
|
||||
'flex-1 py-2.5 px-4 rounded-lg font-medium transition-colors',
|
||||
video.activeVideoType === 'luma'
|
||||
'flex items-center justify-center py-2.5 px-4 rounded-lg font-medium transition-colors',
|
||||
store.activeProvider === p
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200',
|
||||
]"
|
||||
type="button"
|
||||
>
|
||||
Luma视频
|
||||
</button>
|
||||
<button
|
||||
@click="video.switchVideoType('keling')"
|
||||
:class="[
|
||||
'flex-1 py-2.5 px-4 rounded-lg font-medium transition-colors',
|
||||
video.activeVideoType === 'keling'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200',
|
||||
]"
|
||||
>
|
||||
可灵视频
|
||||
<i class="iconfont mr-2 !text-xl" :class="getProviderIcon(p)"></i>
|
||||
{{ getProviderName(p) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Luma 视频参数 -->
|
||||
<div v-if="video.activeVideoType === 'luma'" class="space-y-6">
|
||||
<!-- 提示词输入 -->
|
||||
<div class="bg-white rounded-xl p-4 shadow-sm">
|
||||
<label class="block text-gray-700 font-medium mb-3">提示词</label>
|
||||
<textarea
|
||||
v-model="video.lumaParams.prompt"
|
||||
placeholder="请在此输入视频提示词,用逗号分割"
|
||||
class="w-full px-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||||
rows="4"
|
||||
maxlength="2000"
|
||||
/>
|
||||
<div class="flex justify-between">
|
||||
<van-button
|
||||
@click="video.generatePrompt"
|
||||
:disabled="video.isGenerating"
|
||||
type="primary"
|
||||
size="small"
|
||||
>
|
||||
<i v-if="video.isGenerating" class="iconfont icon-loading animate-spin"></i>
|
||||
<span class="ml-1">{{ video.isGenerating ? '' : '生成提示词' }}</span>
|
||||
</van-button>
|
||||
<span class="text-sm text-gray-500">{{ video.lumaParams.prompt.length }}/2000</span>
|
||||
<!-- 参数构建器 -->
|
||||
<div v-if="store.providerModels.length > 0" class="space-y-4">
|
||||
<ParamBuilderMobile
|
||||
v-model="store.formData"
|
||||
v-model:required-keys="store.requiredKeys"
|
||||
:items="store.providerModels"
|
||||
@price-params-change="handlePriceParamsChange"
|
||||
/>
|
||||
|
||||
<!-- 算力信息 -->
|
||||
<div
|
||||
class="flex items-center justify-between p-3 rounded-lg bg-gradient-to-r from-blue-50 to-purple-50 border border-blue-200 shadow-sm"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<i class="iconfont icon-lightning text-yellow-500 !text-xl"></i>
|
||||
<span class="font-medium text-gray-700">当前可用算力:</span>
|
||||
<span class="font-bold text-lg text-yellow-500">{{ store.availablePower }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图片辅助生成开关 -->
|
||||
<div class="bg-white rounded-xl p-4 shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="text-gray-900 font-medium">使用图片辅助生成</span>
|
||||
<p class="text-sm text-gray-500 mt-1">上传起始帧和结束帧图片</p>
|
||||
</div>
|
||||
<el-switch
|
||||
v-model="video.lumaUseImageMode"
|
||||
@change="video.toggleLumaImageMode"
|
||||
size="default"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图片上传区域 -->
|
||||
<div v-if="video.lumaUseImageMode" class="bg-white rounded-xl p-4 shadow-sm">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="relative">
|
||||
<div
|
||||
class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-blue-400 hover:bg-blue-50 transition-colors cursor-pointer h-32"
|
||||
>
|
||||
<input
|
||||
ref="lumaStartInput"
|
||||
type="file"
|
||||
accept=".jpg,.png,.jpeg"
|
||||
@change="video.handleLumaStartImageUpload"
|
||||
class="hidden"
|
||||
/>
|
||||
<div
|
||||
@click="$refs.lumaStartInput.click()"
|
||||
class="flex flex-col items-center space-y-2 h-full justify-center"
|
||||
>
|
||||
<i
|
||||
v-if="!video.lumaStartImage.length"
|
||||
class="iconfont icon-upload text-blue-500 text-xl"
|
||||
></i>
|
||||
<span v-if="!video.lumaStartImage.length" class="text-gray-700 text-sm"
|
||||
>起始帧</span
|
||||
>
|
||||
<div v-else class="w-full h-full relative">
|
||||
<el-image
|
||||
:src="video.lumaStartImage[0]?.url || video.lumaStartImage[0]?.content"
|
||||
fit="cover"
|
||||
class="w-full h-full rounded"
|
||||
/>
|
||||
<button
|
||||
@click.stop="video.lumaStartImage = []"
|
||||
class="absolute top-1 right-1 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center text-xs hover:bg-red-600 transition-colors"
|
||||
>
|
||||
<i class="iconfont icon-close"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-blue-400 hover:bg-blue-50 transition-colors cursor-pointer h-32"
|
||||
>
|
||||
<input
|
||||
ref="lumaEndInput"
|
||||
type="file"
|
||||
accept=".jpg,.png,.jpeg"
|
||||
@change="video.handleLumaEndImageUpload"
|
||||
class="hidden"
|
||||
/>
|
||||
<div
|
||||
@click="$refs.lumaEndInput.click()"
|
||||
class="flex flex-col items-center space-y-2 h-full justify-center"
|
||||
>
|
||||
<i
|
||||
v-if="!video.lumaEndImage.length"
|
||||
class="iconfont icon-upload text-blue-500 text-xl"
|
||||
></i>
|
||||
<span v-if="!video.lumaEndImage.length" class="text-gray-700 text-sm"
|
||||
>结束帧</span
|
||||
>
|
||||
<div v-else class="w-full h-full relative">
|
||||
<el-image
|
||||
:src="video.lumaEndImage[0]?.url || video.lumaEndImage[0]?.content"
|
||||
fit="cover"
|
||||
class="w-full h-full rounded"
|
||||
/>
|
||||
<button
|
||||
@click.stop="video.lumaEndImage = []"
|
||||
class="absolute top-1 right-1 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center text-xs hover:bg-red-600 transition-colors"
|
||||
>
|
||||
<i class="iconfont icon-close"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Luma 特有参数 -->
|
||||
<div class="bg-white rounded-xl p-4 shadow-sm">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-900 font-medium">循环参考图</span>
|
||||
<el-switch v-model="video.lumaParams.loop" size="default" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-900 font-medium">提示词优化</span>
|
||||
<el-switch v-model="video.lumaParams.expand_prompt" size="default" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 算力显示和生成按钮 -->
|
||||
<!-- 生成按钮 -->
|
||||
<div class="bg-white rounded-xl p-4 shadow-sm">
|
||||
<button
|
||||
@click="video.createLumaVideo"
|
||||
:disabled="video.generating"
|
||||
@click="store.createVideoTask"
|
||||
:disabled="store.submitting"
|
||||
type="button"
|
||||
class="w-full py-3 bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-xl disabled:from-gray-400 disabled:to-gray-400 disabled:cursor-not-allowed hover:from-blue-600 hover:to-purple-700 transition-all duration-200 flex items-center justify-center space-x-2"
|
||||
>
|
||||
<i v-if="video.generating" class="iconfont icon-loading animate-spin"></i>
|
||||
<i v-if="store.submitting" class="iconfont icon-loading animate-spin"></i>
|
||||
<i v-else class="iconfont icon-chuangzuo"></i>
|
||||
<span>{{
|
||||
video.generating ? '创作中...' : `立即生成 (${video.lumaPowerCost}算力)`
|
||||
}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KeLing 视频参数 -->
|
||||
<div v-if="video.activeVideoType === 'keling'" class="space-y-6">
|
||||
<!-- 画面比例 -->
|
||||
<div class="bg-white rounded-xl p-4 shadow-sm">
|
||||
<label class="block text-gray-700 font-medium mb-3">画面比例</label>
|
||||
<CustomSelect
|
||||
v-model="video.kelingParams.aspect_ratio"
|
||||
:options="video.aspectRatioOptions.map((ratio) => ({ label: ratio, value: ratio }))"
|
||||
title="选择比例"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 模型选择 -->
|
||||
<div class="bg-white rounded-xl p-4 shadow-sm">
|
||||
<label class="block text-gray-700 font-medium mb-3">模型选择</label>
|
||||
<CustomSelect
|
||||
v-model="video.kelingParams.model"
|
||||
:options="video.modelOptions"
|
||||
placeholder="请选择模型"
|
||||
title="选择模型"
|
||||
>
|
||||
<template #option="{ option, selected }">
|
||||
<div class="flex items-center w-full">
|
||||
<span
|
||||
class="font-bold text-gray-900 mr-2"
|
||||
:class="{ '!text-purple-600': selected }"
|
||||
>{{ option.label }}</span
|
||||
>
|
||||
<span class="text-xs text-gray-400">({{ option.value }})</span>
|
||||
</div>
|
||||
</template>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
|
||||
<!-- 视频时长 -->
|
||||
<div class="bg-white rounded-xl p-4 shadow-sm">
|
||||
<label class="block text-gray-700 font-medium mb-3">视频时长</label>
|
||||
<CustomSelect
|
||||
v-model="video.kelingParams.duration"
|
||||
:options="
|
||||
video.durationOptions.map((duration) => ({ label: `${duration}秒`, value: duration }))
|
||||
"
|
||||
title="选择时长"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 生成模式 -->
|
||||
<div class="bg-white rounded-xl p-4 shadow-sm">
|
||||
<label class="block text-gray-700 font-medium mb-3">生成模式</label>
|
||||
<CustomSelect
|
||||
v-model="video.kelingParams.mode"
|
||||
:options="
|
||||
video.modeOptions.map((mode) => ({
|
||||
label: mode === 'std' ? '标准模式' : '专业模式',
|
||||
value: mode,
|
||||
}))
|
||||
"
|
||||
title="选择模式"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 创意程度 -->
|
||||
<div class="bg-white rounded-xl p-4 shadow-sm">
|
||||
<div class="space-y-4">
|
||||
<label class="block text-gray-700 font-medium">创意程度</label>
|
||||
<el-slider v-model="video.kelingParams.cfg_scale" :min="0" :max="1" :step="0.1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 运镜控制 -->
|
||||
<div class="bg-white rounded-xl p-4 shadow-sm">
|
||||
<label class="block text-gray-700 font-medium mb-3">运镜控制</label>
|
||||
<CustomSelect
|
||||
v-model="video.kelingParams.camera_control.type"
|
||||
:options="
|
||||
video.cameraControlOptions.map((option) => ({
|
||||
label: video.getCameraControlLabel(option),
|
||||
value: option,
|
||||
}))
|
||||
"
|
||||
title="选择运镜类型"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 图片辅助生成开关 -->
|
||||
<div class="bg-white rounded-xl p-4 shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="text-gray-900 font-medium">使用图片辅助生成</span>
|
||||
<p class="text-sm text-gray-500 mt-1">上传起始帧和结束帧图片</p>
|
||||
</div>
|
||||
<el-switch
|
||||
v-model="video.kelingUseImageMode"
|
||||
@change="video.toggleKelingImageMode"
|
||||
size="default"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图片上传区域 -->
|
||||
<div v-if="video.kelingUseImageMode" class="bg-white rounded-xl p-4 shadow-sm">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="relative">
|
||||
<div
|
||||
class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-blue-400 hover:bg-blue-50 transition-colors cursor-pointer h-32"
|
||||
>
|
||||
<input
|
||||
ref="kelingStartInput"
|
||||
type="file"
|
||||
accept=".jpg,.png,.jpeg"
|
||||
@change="video.handleKelingStartImageUpload"
|
||||
class="hidden"
|
||||
/>
|
||||
<div
|
||||
@click="$refs.kelingStartInput.click()"
|
||||
class="flex flex-col items-center space-y-2 h-full justify-center"
|
||||
>
|
||||
<i
|
||||
v-if="!video.kelingStartImage.length"
|
||||
class="iconfont icon-upload text-blue-500 text-xl"
|
||||
></i>
|
||||
<span v-if="!video.kelingStartImage.length" class="text-gray-700 text-sm"
|
||||
>起始帧</span
|
||||
>
|
||||
<div v-else class="w-full h-full relative">
|
||||
<el-image
|
||||
:src="video.kelingStartImage[0]?.url || video.kelingStartImage[0]?.content"
|
||||
fit="cover"
|
||||
class="w-full h-full rounded"
|
||||
/>
|
||||
<button
|
||||
@click.stop="video.kelingStartImage = []"
|
||||
class="absolute top-1 right-1 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center text-xs hover:bg-red-600 transition-colors"
|
||||
>
|
||||
<i class="iconfont icon-close"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-blue-400 hover:bg-blue-50 transition-colors cursor-pointer h-32"
|
||||
>
|
||||
<input
|
||||
ref="kelingEndInput"
|
||||
type="file"
|
||||
accept=".jpg,.png,.jpeg"
|
||||
@change="video.handleKelingEndImageUpload"
|
||||
class="hidden"
|
||||
/>
|
||||
<div
|
||||
@click="$refs.kelingEndInput.click()"
|
||||
class="flex flex-col items-center space-y-2 h-full justify-center"
|
||||
>
|
||||
<i
|
||||
v-if="!video.kelingEndImage.length"
|
||||
class="iconfont icon-upload text-blue-500 text-xl"
|
||||
></i>
|
||||
<span v-if="!video.kelingEndImage.length" class="text-gray-700 text-sm"
|
||||
>结束帧</span
|
||||
>
|
||||
<div v-else class="w-full h-full relative">
|
||||
<el-image
|
||||
:src="video.kelingEndImage[0]?.url || video.kelingEndImage[0]?.content"
|
||||
fit="cover"
|
||||
class="w-full h-full rounded"
|
||||
/>
|
||||
<button
|
||||
@click.stop="video.kelingEndImage = []"
|
||||
class="absolute top-1 right-1 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center text-xs hover:bg-red-600 transition-colors"
|
||||
>
|
||||
<i class="iconfont icon-close"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提示词输入 -->
|
||||
<div class="bg-white rounded-xl p-4 shadow-sm">
|
||||
<label class="block text-gray-700 font-medium mb-3">提示词</label>
|
||||
<textarea
|
||||
v-model="video.kelingParams.prompt"
|
||||
:placeholder="video.kelingUseImageMode ? '描述视频画面细节' : '请在此输入视频提示词'"
|
||||
class="w-full px-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||||
rows="4"
|
||||
maxlength="500"
|
||||
/>
|
||||
<div class="flex justify-between">
|
||||
<van-button
|
||||
@click="video.generatePrompt"
|
||||
:disabled="video.isGenerating"
|
||||
type="primary"
|
||||
size="small"
|
||||
>
|
||||
<i v-if="video.isGenerating" class="iconfont icon-loading animate-spin"></i>
|
||||
<span class="ml-1">{{ video.isGenerating ? '' : '生成提示词' }}</span>
|
||||
</van-button>
|
||||
<span class="text-sm text-gray-500">{{ video.kelingParams.prompt.length }}/500</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 排除内容 -->
|
||||
<div class="bg-white rounded-xl p-4 shadow-sm">
|
||||
<label class="block text-gray-700 font-medium mb-3">不希望出现的内容</label>
|
||||
<textarea
|
||||
v-model="video.kelingParams.negative_prompt"
|
||||
placeholder="请在此输入你不希望出现在视频上的内容"
|
||||
class="w-full px-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||||
rows="3"
|
||||
maxlength="500"
|
||||
/>
|
||||
<div class="text-right mt-2">
|
||||
<span class="text-sm text-gray-500"
|
||||
>{{ video.kelingParams.negative_prompt.length }}/500</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 算力显示和生成按钮 -->
|
||||
<div class="bg-white rounded-xl p-4 shadow-sm">
|
||||
<button
|
||||
@click="video.createKelingVideo"
|
||||
:disabled="video.generating"
|
||||
type="button"
|
||||
class="w-full py-3 bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-xl disabled:from-gray-400 disabled:to-gray-400 disabled:cursor-not-allowed hover:from-blue-600 hover:to-purple-700 transition-all duration-200 flex items-center justify-center space-x-2"
|
||||
>
|
||||
<i v-if="video.generating" class="iconfont icon-loading animate-spin"></i>
|
||||
<i v-else class="iconfont icon-chuangzuo"></i>
|
||||
<span>{{
|
||||
video.generating ? '创作中...' : `立即生成 (${video.kelingPowerCost}算力)`
|
||||
}}</span>
|
||||
<span>立即生成 ({{ store.currentPowerCost }}算力)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -440,10 +75,26 @@
|
||||
|
||||
<!-- 作品列表 -->
|
||||
<div class="p-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">我的作品</h2>
|
||||
<div class="space-y-4" v-if="video.currentList.length > 0">
|
||||
<div class="flex flex-col !items-start justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-3">我的作品</h2>
|
||||
<!-- 过滤按钮 -->
|
||||
<CustomTabs
|
||||
v-model="store.taskFilter"
|
||||
@update:model-value="handleTaskFilterChange"
|
||||
class="w-full"
|
||||
>
|
||||
<CustomTabPane name="all" label="全部"></CustomTabPane>
|
||||
<CustomTabPane
|
||||
v-for="p in providerOrder"
|
||||
:key="`filter-${p}`"
|
||||
:name="p"
|
||||
:label="getProviderName(p)"
|
||||
></CustomTabPane>
|
||||
</CustomTabs>
|
||||
</div>
|
||||
<div class="space-y-4" v-if="store.currentList.length > 0">
|
||||
<div
|
||||
v-for="item in video.currentList"
|
||||
v-for="item in store.currentList"
|
||||
:key="item.id"
|
||||
class="bg-white rounded-xl p-4 shadow-sm"
|
||||
>
|
||||
@@ -451,35 +102,63 @@
|
||||
<div class="flex-shrink-0">
|
||||
<div class="relative w-16 h-16 rounded-lg overflow-hidden bg-gray-100">
|
||||
<el-image
|
||||
:src="item.cover_url"
|
||||
v-if="item.status === 'failed'"
|
||||
src="/images/failed.jpg"
|
||||
fit="cover"
|
||||
class="w-full h-full"
|
||||
:preview-disabled="true"
|
||||
/>
|
||||
<div
|
||||
v-else-if="item.status === 'success'"
|
||||
class="w-full h-full flex items-center justify-center bg-gray-100"
|
||||
>
|
||||
<template #error>
|
||||
<div class="w-full h-full flex items-center justify-center bg-gray-100">
|
||||
<i class="iconfont icon-video text-gray-400 text-xl"></i>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
<video
|
||||
class="w-full h-full object-cover"
|
||||
:src="store.replaceImg(item.video_url)"
|
||||
preload="auto"
|
||||
muted
|
||||
></video>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="item.status === 'in_progress'"
|
||||
class="w-full h-full flex items-center justify-center bg-gray-100"
|
||||
>
|
||||
<Generating message="正在生成视频" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="w-full h-full flex items-center justify-center bg-gray-100"
|
||||
>
|
||||
<i class="iconfont icon-video text-gray-400 text-xl"></i>
|
||||
</div>
|
||||
<!-- 视频播放按钮 -->
|
||||
<button
|
||||
v-if="item.progress === 100"
|
||||
@click="video.playVideo(item)"
|
||||
v-if="item.status === 'success'"
|
||||
@click="store.playVideo(item)"
|
||||
class="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 opacity-0 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<i class="iconfont icon-play text-white text-xl"></i>
|
||||
</button>
|
||||
<!-- 下载中状态 -->
|
||||
<div
|
||||
v-if="item.status === 'downloading'"
|
||||
class="absolute inset-0 flex items-center justify-center bg-purple-500 bg-opacity-20"
|
||||
>
|
||||
<div class="text-center">
|
||||
<i class="iconfont icon-loading animate-spin text-purple-500 text-xl"></i>
|
||||
<div class="text-xs text-purple-600 mt-1">下载中</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 进度动画 -->
|
||||
<div
|
||||
v-if="item.progress < 100 && item.progress !== 101"
|
||||
v-if="item.status === 'in_progress' || item.status === 'pending'"
|
||||
class="absolute inset-0 flex items-center justify-center bg-blue-500 bg-opacity-20"
|
||||
>
|
||||
<i class="iconfont icon-loading animate-spin text-blue-500 text-xl"></i>
|
||||
</div>
|
||||
<!-- 失败状态 -->
|
||||
<div
|
||||
v-if="item.progress === 101"
|
||||
v-if="item.status === 'failed'"
|
||||
class="absolute inset-0 flex items-center justify-center bg-red-500 bg-opacity-20"
|
||||
>
|
||||
<i class="iconfont icon-warning text-red-500 text-xl"></i>
|
||||
@@ -490,18 +169,24 @@
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<p class="text-gray-500 text-sm mt-1 line-clamp-2">
|
||||
{{ item.prompt }}
|
||||
{{ store.substr(item.prompt, 1000) }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- 任务状态 -->
|
||||
<div v-if="item.progress < 100" class="flex items-center space-x-2 text-sm">
|
||||
<div v-if="item.status !== 'success'" class="flex items-center space-x-2 text-sm">
|
||||
<div
|
||||
v-if="item.progress === 101"
|
||||
v-if="item.status === 'failed'"
|
||||
class="text-red-600 flex items-center space-x-1"
|
||||
>
|
||||
<i class="iconfont icon-warning"></i>
|
||||
<span>失败</span>
|
||||
</div>
|
||||
<div v-else-if="item.status === 'downloading'" class="text-purple-600 flex items-center space-x-1">
|
||||
<div
|
||||
class="w-3 h-3 border border-purple-600 border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
<span>下载中</span>
|
||||
</div>
|
||||
<div v-else class="text-blue-600 flex items-center space-x-1">
|
||||
<div
|
||||
class="w-3 h-3 border border-blue-600 border-t-transparent rounded-full animate-spin"
|
||||
@@ -511,25 +196,46 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- 标签 -->
|
||||
<div class="flex items-center space-x-2 mt-2">
|
||||
<div class="flex items-center gap-2 mt-2 flex-wrap">
|
||||
<span
|
||||
v-if="item.raw_data?.task_type"
|
||||
v-if="item.type"
|
||||
class="px-2 py-1 text-xs bg-blue-100 text-blue-600 rounded-full"
|
||||
>
|
||||
{{ item.raw_data.task_type }}
|
||||
{{ item.type }}
|
||||
</span>
|
||||
<span
|
||||
v-if="item.raw_data?.model"
|
||||
v-if="item.params?.task_type"
|
||||
class="px-2 py-1 text-xs bg-blue-100 text-blue-600 rounded-full"
|
||||
>
|
||||
{{ item.params.task_type }}
|
||||
</span>
|
||||
<span
|
||||
v-if="item.params?.model"
|
||||
class="px-2 py-1 text-xs bg-green-100 text-green-600 rounded-full"
|
||||
>
|
||||
{{ item.raw_data.model }}
|
||||
{{ item.params.model }}
|
||||
</span>
|
||||
<span
|
||||
v-if="item.raw_data?.duration"
|
||||
v-if="item.params?.duration"
|
||||
class="px-2 py-1 text-xs bg-yellow-100 text-yellow-600 rounded-full"
|
||||
>
|
||||
{{ item.raw_data.duration }}秒
|
||||
{{ item.params.duration }}秒
|
||||
</span>
|
||||
<span
|
||||
v-if="item.params?.size"
|
||||
class="px-2 py-1 text-xs bg-purple-100 text-purple-600 rounded-full"
|
||||
>
|
||||
分辨率:{{ item.params.size }}
|
||||
</span>
|
||||
<span
|
||||
v-if="item.power"
|
||||
class="px-2 py-1 text-xs bg-orange-100 text-orange-600 rounded-full"
|
||||
>
|
||||
消耗算力:{{ item.power }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="item.status === 'failed'" class="mt-2 text-sm text-red-600">
|
||||
任务执行失败:{{ item.err_msg }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -538,16 +244,16 @@
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
v-if="item.progress === 100"
|
||||
@click="video.playVideo(item)"
|
||||
v-if="item.status === 'success'"
|
||||
@click="store.playVideo(item)"
|
||||
class="px-3 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors flex items-center space-x-1"
|
||||
>
|
||||
<i class="iconfont icon-play !text-xs"></i>
|
||||
<span>播放</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="item.progress === 100"
|
||||
@click="video.downloadVideo(item)"
|
||||
v-if="item.status === 'success'"
|
||||
@click="store.downloadVideo(item)"
|
||||
:disabled="item.downloading"
|
||||
class="px-3 py-1.5 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 transition-colors disabled:bg-gray-400 flex items-center space-x-1"
|
||||
>
|
||||
@@ -566,86 +272,111 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<div v-if="video.listLoading" class="flex justify-center py-4">
|
||||
<!-- 加载中 -->
|
||||
<div v-if="store.loading" class="flex justify-center py-4">
|
||||
<i class="iconfont icon-loading animate-spin text-blue-500 text-xl"></i>
|
||||
</div>
|
||||
|
||||
<!-- 没有更多了 -->
|
||||
<div v-if="video.listFinished && !video.listLoading" class="text-center py-4 text-gray-500">
|
||||
没有更多了
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-4" v-else>
|
||||
<van-empty description="暂无数据" image-size="120" />
|
||||
<div class="px-4" v-if="store.noData">
|
||||
<van-empty description="没有任何作品,赶紧去创作吧!" image-size="120" />
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="flex justify-center p-4" v-if="store.total > store.pageSize">
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:hide-on-single-page="true"
|
||||
:current-page="store.page"
|
||||
:page-size="store.pageSize"
|
||||
@current-change="store.fetchData"
|
||||
:total="store.total"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 视频预览弹窗 -->
|
||||
<div
|
||||
v-if="video.showVideoDialog"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
|
||||
@click="video.showVideoDialog = false"
|
||||
<el-dialog
|
||||
v-model="store.showDialog"
|
||||
title="预览视频"
|
||||
hide-footer
|
||||
@close="handleVideoDialogClose"
|
||||
width="auto"
|
||||
>
|
||||
<div @click.stop class="bg-white rounded-2xl w-full max-w-4xl max-h-[80vh] animate-scale-up">
|
||||
<div class="flex items-center justify-between p-4 border-b">
|
||||
<h3 class="text-lg font-semibold text-gray-900">视频预览</h3>
|
||||
<button @click="video.showVideoDialog = false" class="p-2 hover:bg-gray-100 rounded-full">
|
||||
<i class="iconfont icon-close text-gray-500"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<video
|
||||
v-if="video.currentVideoUrl"
|
||||
:src="video.currentVideoUrl"
|
||||
controls
|
||||
autoplay
|
||||
class="w-full max-h-[60vh] rounded-lg"
|
||||
>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<video
|
||||
ref="videoPlayerRef"
|
||||
style="max-width: 90vw; max-height: 90vh"
|
||||
:src="store.currentVideoUrl"
|
||||
preload="auto"
|
||||
:autoplay="true"
|
||||
controls="controls"
|
||||
>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import '@/assets/css/mobile/video.scss'
|
||||
import CustomSelect from '@/components/mobile/CustomSelect.vue'
|
||||
import { checkSession } from '@/store/cache'
|
||||
import { useVideoStore } from '@/store/mobile/video'
|
||||
import ParamBuilderMobile from '@/components/mobile/ParamBuilderMobile.vue'
|
||||
import Generating from '@/components/ui/Generating.vue'
|
||||
import CustomTabs from '@/components/ui/CustomTabs.vue'
|
||||
import CustomTabPane from '@/components/ui/CustomTabPane.vue'
|
||||
import { useVideoStore } from '@/store/video'
|
||||
import { getProviderName } from '@/store/data/video_params'
|
||||
import { showConfirmDialog } from 'vant'
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const video = useVideoStore()
|
||||
const store = useVideoStore()
|
||||
const videoPlayerRef = ref(null)
|
||||
|
||||
// 页面专属方法
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
// 定时轮询等副作用
|
||||
let tastPullHandler = null
|
||||
onMounted(() => {
|
||||
checkSession()
|
||||
.then(() => {
|
||||
video.fetchData(1)
|
||||
video.fetchUserPower()
|
||||
tastPullHandler = setInterval(() => {
|
||||
if (video.taskPulling) {
|
||||
video.fetchData(1)
|
||||
}
|
||||
}, 5000)
|
||||
})
|
||||
.catch(() => {})
|
||||
})
|
||||
onUnmounted(() => {
|
||||
if (tastPullHandler) clearInterval(tastPullHandler)
|
||||
})
|
||||
// 提供商图标映射
|
||||
const getProviderIcon = (provider) => {
|
||||
const icons = {
|
||||
veo: 'icon-gemini',
|
||||
sora: 'icon-sora',
|
||||
luma: 'icon-luma',
|
||||
keling: 'icon-keling',
|
||||
minimax: 'icon-minimax',
|
||||
wan: 'icon-wan',
|
||||
}
|
||||
return icons[provider] || 'icon-video'
|
||||
}
|
||||
|
||||
// 提供商顺序
|
||||
const providerOrder = computed(() =>
|
||||
['sora', 'veo', 'luma', 'keling', 'minimax', 'wan'].filter((p) => store.providers.includes(p))
|
||||
)
|
||||
|
||||
// 处理价格参数变化事件
|
||||
const handlePriceParamsChange = () => {
|
||||
// 价格参数变化时,store 中的 watch 会自动触发 setCurrentPowerCost
|
||||
// setCurrentPowerCost 是异步的,会调用 API 获取最新算力值
|
||||
// 无需额外处理,watch 会自动更新 currentPowerCost
|
||||
}
|
||||
|
||||
// 处理任务过滤变化
|
||||
const handleTaskFilterChange = (filter) => {
|
||||
store.switchTaskFilter(filter)
|
||||
}
|
||||
|
||||
// 处理视频对话框关闭事件
|
||||
const handleVideoDialogClose = () => {
|
||||
if (videoPlayerRef.value) {
|
||||
videoPlayerRef.value.pause()
|
||||
videoPlayerRef.value.currentTime = 0
|
||||
}
|
||||
store.showDialog = false
|
||||
}
|
||||
|
||||
// 删除弹窗(页面层处理)
|
||||
const removeJob = (item) => {
|
||||
@@ -656,10 +387,18 @@ const removeJob = (item) => {
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
.then(() => {
|
||||
video.fetchData(1)
|
||||
store.removeJob(item)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.init()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
store.cleanup()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -23,36 +23,16 @@
|
||||
</van-cell>
|
||||
</van-list>
|
||||
</TabPane>
|
||||
<TabPane name="sd" label="Stable Diffusion">
|
||||
<TabPane name="image" label="AI图像生成">
|
||||
<van-list
|
||||
v-model:error="data['sd'].error"
|
||||
v-model:loading="data['sd'].loading"
|
||||
:finished="data['sd'].finished"
|
||||
v-model:error="data['image'].error"
|
||||
v-model:loading="data['image'].loading"
|
||||
:finished="data['image'].finished"
|
||||
error-text="请求失败,点击重新加载"
|
||||
finished-text="没有更多了"
|
||||
@load="onLoad"
|
||||
>
|
||||
<van-cell v-for="item in data['sd'].data" :key="item.id">
|
||||
<van-image :src="item['img_thumb']" @click="imageView(item)" fit="cover" />
|
||||
|
||||
<div class="opt-box">
|
||||
<el-button type="primary" @click="showPrompt(item)" circle>
|
||||
<i class="iconfont icon-prompt"></i>
|
||||
</el-button>
|
||||
</div>
|
||||
</van-cell>
|
||||
</van-list>
|
||||
</TabPane>
|
||||
<TabPane name="dall" label="DALL">
|
||||
<van-list
|
||||
v-model:error="data['dall'].error"
|
||||
v-model:loading="data['dall'].loading"
|
||||
:finished="data['dall'].finished"
|
||||
error-text="请求失败,点击重新加载"
|
||||
finished-text="没有更多了"
|
||||
@load="onLoad"
|
||||
>
|
||||
<van-cell v-for="item in data['dall'].data" :key="item.id">
|
||||
<van-cell v-for="item in data['image'].data" :key="item.id">
|
||||
<van-image :src="item['img_thumb']" @click="imageView(item)" fit="cover" />
|
||||
|
||||
<div class="opt-box">
|
||||
@@ -79,6 +59,7 @@
|
||||
|
||||
<script setup>
|
||||
import { httpGet } from '@/utils/http'
|
||||
import { getThumbURL } from '@/utils/libs'
|
||||
import Clipboard from 'clipboard'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { showConfirmDialog, showFailToast, showImagePreview, showNotify } from 'vant'
|
||||
@@ -97,22 +78,13 @@ const data = ref({
|
||||
url: '/api/mj/imgWall',
|
||||
data: [],
|
||||
},
|
||||
sd: {
|
||||
image: {
|
||||
loading: false,
|
||||
finished: false,
|
||||
error: false,
|
||||
page: 1,
|
||||
pageSize: 12,
|
||||
url: '/api/sd/imgWall',
|
||||
data: [],
|
||||
},
|
||||
dall: {
|
||||
loading: false,
|
||||
finished: false,
|
||||
error: false,
|
||||
page: 1,
|
||||
pageSize: 12,
|
||||
url: '/api/dall/imgWall',
|
||||
url: '/api/image/imgWall',
|
||||
data: [],
|
||||
},
|
||||
})
|
||||
@@ -157,7 +129,7 @@ const onLoad = () => {
|
||||
// 生成缩略图
|
||||
const imageList = res.data.items
|
||||
for (let i = 0; i < imageList.length; i++) {
|
||||
imageList[i]['img_thumb'] = imageList[i]['img_url'] + '?imageView2/4/w/300/h/0/q/75'
|
||||
imageList[i]['img_thumb'] = getThumbURL(imageList[i]['img_url'], 300, 0)
|
||||
}
|
||||
if (imageList.length < d.pageSize) {
|
||||
d.finished = true
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="mobile-sd">
|
||||
<div v-if="menus['/dalle']?.enabled">
|
||||
<div v-if="menus['/image']?.enabled">
|
||||
<van-form>
|
||||
<van-cell-group class="px-3 pt-3 pb-4">
|
||||
<div>
|
||||
@@ -64,7 +64,7 @@
|
||||
>
|
||||
<i v-if="loading" class="iconfont icon-loading animate-spin"></i>
|
||||
<i v-else class="iconfont icon-chuangzuo"></i>
|
||||
<span>{{ loading ? '创作中...' : '立即生成' }}({{ dallPower }}算力)</span>
|
||||
<span>{{ loading ? '创作中...' : '立即生成' }}({{ imagePower }}算力)</span>
|
||||
</button>
|
||||
</div>
|
||||
</van-cell-group>
|
||||
@@ -125,7 +125,7 @@
|
||||
<div class="failed" v-if="item.progress === 101">
|
||||
<div class="title">任务失败</div>
|
||||
<div class="opt">
|
||||
<van-button size="small" @click="showErrMsg(item)">详情</van-button>
|
||||
<van-button size="small" @click="showDetail(item)">详情</van-button>
|
||||
<van-button type="danger" @click="removeImage($event, item)" size="small"
|
||||
>删除</van-button
|
||||
>
|
||||
@@ -162,8 +162,8 @@
|
||||
<el-button type="success" v-else @click="publishImage($event, item, true)" circle>
|
||||
<i class="iconfont icon-share-bold"></i>
|
||||
</el-button>
|
||||
<el-button type="primary" @click="showPrompt(item)" circle>
|
||||
<i class="iconfont icon-prompt"></i>
|
||||
<el-button type="primary" @click="showDetail(item)" circle>
|
||||
<i class="iconfont icon-info"></i>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -171,18 +171,77 @@
|
||||
</van-grid>
|
||||
</van-list>
|
||||
</div>
|
||||
<button
|
||||
style="display: none"
|
||||
class="copy-prompt-dall"
|
||||
:data-clipboard-text="prompt"
|
||||
id="copy-btn-dall"
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<FunDisabled />
|
||||
</div>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<van-dialog
|
||||
v-model:show="detailDialogVisible"
|
||||
title="任务详情"
|
||||
:show-confirm-button="false"
|
||||
class="detail-dialog"
|
||||
>
|
||||
<div class="detail-content" v-if="currentDetail">
|
||||
<van-cell title="提示词" :label="currentDetail.prompt || '-'" />
|
||||
<van-cell title="生图模型" :value="currentDetail.params?.model_name || '-'" />
|
||||
<van-cell title="消耗算力" :value="currentDetail.power || 0" />
|
||||
<van-cell title="图片比例" :value="currentDetail.params?.aspect_ratio || '-'" />
|
||||
<van-cell title="图片尺寸" :value="currentDetail.params?.size || '-'" />
|
||||
<van-cell title="创建时间" :value="formatTime(currentDetail.created_at)" />
|
||||
|
||||
<van-cell
|
||||
v-if="currentDetail.progress === 100 && currentDetail.img_url"
|
||||
title="生成的图片"
|
||||
>
|
||||
<template #value>
|
||||
<van-image
|
||||
:src="currentDetail.img_url"
|
||||
fit="cover"
|
||||
width="150"
|
||||
height="150"
|
||||
@click="showImagePreview([currentDetail.img_url])"
|
||||
/>
|
||||
</template>
|
||||
</van-cell>
|
||||
|
||||
<van-cell
|
||||
v-if="currentDetail.params?.image && currentDetail.params.image.length > 0"
|
||||
title="引用的图片"
|
||||
>
|
||||
<template #value>
|
||||
<div class="reference-images">
|
||||
<van-image
|
||||
v-for="(img, idx) in currentDetail.params.image"
|
||||
:key="idx"
|
||||
:src="img"
|
||||
fit="cover"
|
||||
width="80"
|
||||
height="80"
|
||||
@click="showImagePreview(currentDetail.params.image, idx)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</van-cell>
|
||||
|
||||
<van-cell
|
||||
v-if="currentDetail.progress === 101 && currentDetail.err_msg"
|
||||
title="错误信息"
|
||||
>
|
||||
<template #value>
|
||||
<span style="color: #ee0a24">{{ currentDetail.err_msg }}</span>
|
||||
</template>
|
||||
</van-cell>
|
||||
|
||||
<div class="dialog-footer">
|
||||
<van-button type="primary" @click="copyPrompt(currentDetail.prompt)">
|
||||
复制提示词
|
||||
</van-button>
|
||||
<van-button @click="detailDialogVisible = false">关闭</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -191,9 +250,8 @@ import { checkSession, getSystemInfo, getMenus } from '@/store/cache'
|
||||
import { getSessionId } from '@/store/session'
|
||||
import { useSharedStore } from '@/store/sharedata'
|
||||
import { httpGet, httpPost } from '@/utils/http'
|
||||
import { showLoginDialog } from '@/utils/libs'
|
||||
import { getThumbURL, showLoginDialog } from '@/utils/libs'
|
||||
import { Delete } from '@element-plus/icons-vue'
|
||||
import Clipboard from 'clipboard'
|
||||
import {
|
||||
showConfirmDialog,
|
||||
showDialog,
|
||||
@@ -207,6 +265,7 @@ import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import FunDisabled from '@/components/ui/FunDisabled.vue'
|
||||
import ImageUpload from '@/components/ImageUpload.vue'
|
||||
import Clipboard from 'clipboard'
|
||||
|
||||
const listBoxHeight = ref(window.innerHeight - 40)
|
||||
const mjBoxHeight = ref(window.innerHeight - 150)
|
||||
@@ -272,35 +331,28 @@ const allowPulling = ref(true) // 是否允许轮询
|
||||
const tastPullHandler = ref(null)
|
||||
const router = useRouter()
|
||||
const power = ref(0)
|
||||
const dallPower = ref(0) // 画一张 DALL 图片消耗算力
|
||||
const imagePower = ref(0) // 画一张图片消耗算力
|
||||
|
||||
const userId = ref(0)
|
||||
const store = useSharedStore()
|
||||
const clipboard = ref(null)
|
||||
const prompt = ref('')
|
||||
const models = ref([])
|
||||
const selectedModel = ref(null)
|
||||
const detailDialogVisible = ref(false)
|
||||
const currentDetail = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
initData()
|
||||
clipboard.value = new Clipboard('.copy-prompt-dall')
|
||||
clipboard.value.on('success', () => {
|
||||
showNotify({ type: 'success', message: '复制成功', duration: 1000 })
|
||||
})
|
||||
clipboard.value.on('error', () => {
|
||||
showNotify({ type: 'danger', message: '复制失败', duration: 2000 })
|
||||
})
|
||||
|
||||
getSystemInfo()
|
||||
.then((res) => {
|
||||
dallPower.value = res.data.dall_power
|
||||
imagePower.value = res.data.image_power
|
||||
})
|
||||
.catch((e) => {
|
||||
showNotify({ type: 'danger', message: '获取系统配置失败:' + e.message })
|
||||
})
|
||||
|
||||
// 获取模型列表
|
||||
httpGet('/api/dall/models')
|
||||
httpGet('/api/image/models')
|
||||
.then((res) => {
|
||||
for (let i = 0; i < res.data.length; i++) {
|
||||
models.value.push({
|
||||
@@ -313,7 +365,7 @@ onMounted(() => {
|
||||
params.value.model_id = models.value[0]?.value
|
||||
})
|
||||
.catch((e) => {
|
||||
showMessageError('获取模型列表失败:' + e.message)
|
||||
showNotify({ type: 'danger', message: '获取模型列表失败:' + e.message })
|
||||
})
|
||||
|
||||
getMenus()
|
||||
@@ -326,7 +378,6 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clipboard.value.destroy()
|
||||
if (tastPullHandler.value) {
|
||||
clearInterval(tastPullHandler.value)
|
||||
}
|
||||
@@ -353,7 +404,7 @@ const initData = () => {
|
||||
|
||||
const fetchRunningJobs = () => {
|
||||
// 获取运行中的任务
|
||||
httpGet(`/api/dall/jobs?finish=0`)
|
||||
httpGet(`/api/image/jobs?finish=0`)
|
||||
.then((res) => {
|
||||
if (runningJobs.value.length !== res.data.items.length) {
|
||||
fetchFinishJobs(1)
|
||||
@@ -376,7 +427,7 @@ const pageSize = ref(10)
|
||||
// 获取已完成的任务
|
||||
const fetchFinishJobs = (page) => {
|
||||
loading.value = true
|
||||
httpGet(`/api/dall/jobs?finish=1&page=${page}&page_size=${pageSize.value}`)
|
||||
httpGet(`/api/image/jobs?finish=1&page=${page}&page_size=${pageSize.value}`)
|
||||
.then((res) => {
|
||||
const jobs = res.data.items
|
||||
if (jobs.length < pageSize.value) {
|
||||
@@ -385,7 +436,7 @@ const fetchFinishJobs = (page) => {
|
||||
const _jobs = []
|
||||
for (let i = 0; i < jobs.length; i++) {
|
||||
if (jobs[i].progress === -1) {
|
||||
jobs[i]['thumb_url'] = jobs[i]['img_url'] + '?imageView2/1/w/480/h/600/q/75'
|
||||
jobs[i]['thumb_url'] = getThumbURL(jobs[i]['img_url'], 480, 600)
|
||||
}
|
||||
_jobs.push(jobs[i])
|
||||
}
|
||||
@@ -419,10 +470,10 @@ const generate = () => {
|
||||
return showToast('请输入绘画提示词!')
|
||||
}
|
||||
params.value.session_id = getSessionId()
|
||||
httpPost('/api/dall/image', params.value)
|
||||
httpPost('/api/image/image', params.value)
|
||||
.then(() => {
|
||||
showSuccessToast('绘画任务推送成功,请耐心等待任务执行...')
|
||||
power.value -= dallPower.value
|
||||
power.value -= imagePower.value
|
||||
allowPulling.value = true
|
||||
runningJobs.value.push({
|
||||
progress: 0,
|
||||
@@ -433,26 +484,53 @@ const generate = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const showPrompt = (item) => {
|
||||
prompt.value = item.prompt
|
||||
showConfirmDialog({
|
||||
title: '绘画提示词',
|
||||
message: item.prompt,
|
||||
confirmButtonText: '复制',
|
||||
cancelButtonText: '关闭',
|
||||
})
|
||||
.then(() => {
|
||||
document.querySelector('#copy-btn-dall').click()
|
||||
})
|
||||
.catch(() => {})
|
||||
const showDetail = (item) => {
|
||||
// 解析 params 字段
|
||||
let params = {}
|
||||
try {
|
||||
if (item.params) {
|
||||
if (typeof item.params === 'string') {
|
||||
params = JSON.parse(item.params)
|
||||
} else {
|
||||
params = item.params
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析 params 失败:', e)
|
||||
}
|
||||
|
||||
currentDetail.value = {
|
||||
...item,
|
||||
params: params,
|
||||
}
|
||||
detailDialogVisible.value = true
|
||||
}
|
||||
|
||||
const showErrMsg = (item) => {
|
||||
showDialog({
|
||||
title: '错误详情',
|
||||
message: item['err_msg'],
|
||||
}).then(() => {
|
||||
// on close
|
||||
// 格式化时间
|
||||
const formatTime = (timestamp) => {
|
||||
if (!timestamp) return '-'
|
||||
const date = new Date(timestamp * 1000)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
// 复制提示词
|
||||
const copyPrompt = (text) => {
|
||||
const clipboard = new Clipboard('.copy-prompt-btn', {
|
||||
text: () => text,
|
||||
})
|
||||
clipboard.on('success', () => {
|
||||
showSuccessToast('复制成功!')
|
||||
clipboard.destroy()
|
||||
})
|
||||
clipboard.on('error', () => {
|
||||
showFailToast('复制失败!')
|
||||
clipboard.destroy()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -463,7 +541,7 @@ const removeImage = (event, item) => {
|
||||
message: '此操作将会删除任务和图片,继续操作码?',
|
||||
})
|
||||
.then(() => {
|
||||
httpGet('/api/dall/remove', { id: item.id, user_id: item.user_id })
|
||||
httpGet('/api/image/remove', { id: item.id, user_id: item.user_id })
|
||||
.then(() => {
|
||||
showSuccessToast('任务删除成功')
|
||||
fetchFinishJobs(1)
|
||||
@@ -484,7 +562,7 @@ const publishImage = (event, item, action) => {
|
||||
if (action === false) {
|
||||
text = '取消发布'
|
||||
}
|
||||
httpGet('/api/dall/publish', { id: item.id, action: action, user_id: item.user_id })
|
||||
httpGet('/api/image/publish', { id: item.id, action: action, user_id: item.user_id })
|
||||
.then(() => {
|
||||
showSuccessToast(text + '成功')
|
||||
item.publish = action
|
||||
@@ -517,6 +595,25 @@ const modelConfirm = (item) => {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@use '@/assets/css/mobile/image-sd.scss' as *;
|
||||
<style lang="scss" scoped>
|
||||
.detail-dialog {
|
||||
.detail-content {
|
||||
.reference-images {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
justify-content: flex-end;
|
||||
|
||||
.van-button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user