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:
RockYang
2026-08-11 14:51:20 +08:00
parent 37e024acea
commit 9ccff4efbc
219 changed files with 29212 additions and 10802 deletions
+1 -1
View File
@@ -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
View File
@@ -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
+7446
View File
File diff suppressed because it is too large Load Diff
+16 -14
View File
@@ -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
View File
@@ -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;
}
}
}
+24 -5
View File
@@ -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));
}
}
}
-372
View File
@@ -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;
}
-235
View File
@@ -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);
-2
View File
@@ -1,5 +1,3 @@
@use 'sd-task-dialog.scss' as *;
@keyframes expandUp {
0% {
transform: scaleY(0);
-133
View File
@@ -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;
}
-205
View File
@@ -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;
}
}
}
}
}
}
}
}
-63
View File
@@ -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%;
}
-94
View File
@@ -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
}
}
}
+17
View File
@@ -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);
}
+17
View File
@@ -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);
}
+13 -3
View File
@@ -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.
+123 -15
View File
@@ -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
+211 -22
View File
@@ -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.
+540
View File
@@ -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>
+1
View File
@@ -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 -16
View File
@@ -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 -16
View File
@@ -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%;
+497
View File
@@ -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>
+186
View File
@@ -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>
+177 -28
View File
@@ -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%;
+41
View File
@@ -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']
+153 -10
View File
@@ -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>
+2 -2
View File
@@ -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>
+3 -10
View File
@@ -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>
+271
View File
@@ -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>
+12 -12
View File
@@ -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 {
-177
View File
@@ -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>
-43
View File
@@ -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>
+2 -2
View File
@@ -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>
-173
View File
@@ -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>
+32 -8
View File
@@ -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>
+96 -69
View File
@@ -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>
+27 -18
View File
@@ -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>
+133 -10
View File
@@ -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>
+527
View File
@@ -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>
+124
View File
@@ -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>
+8 -15
View File
@@ -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>
+1
View File
@@ -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
View File
@@ -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',
+66 -75
View File
@@ -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
+1 -32
View File
@@ -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,
-3
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -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
}
}
+74
View File
@@ -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
}
+9 -9
View File
@@ -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
View File
@@ -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
View File
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
View File
@@ -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:21248x832', icon: 'icon-aspect_3_2' },
'3:4': { value: '3:4', size: '960x1280', label: '3:4864x1152', icon: 'icon-aspect_3_4' },
'4:5': { value: '4:5', size: '960x1280', label: '4:5896x1120', icon: 'icon-aspect_4_5' },
'5:4': {
value: '5:4',
size: '1280x960',
label: '5:41120x896',
icon: 'icon-aspect_5_4',
},
'21:9': { value: '21:9', size: '1344x576', label: '21:91536x658', 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
View File
@@ -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>
&nbsp;&nbsp; U/V 操作消耗<el-text type="warning"
>{{ mjActionPower }}算力;</el-text
> </el-text
>&nbsp;&nbsp;
<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>
-772
View File
@@ -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>
+1 -1
View File
@@ -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)
+1
View File
@@ -88,6 +88,7 @@ onMounted(() => {
setUserToken(token.value)
handleRegisterSuccess()
}
})
</script>
+7 -1
View File
@@ -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
View File
@@ -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>
+748
View File
@@ -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 一致:缩略图 URLApp.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>
+2 -2
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -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
View File
@@ -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>
+2 -2
View File
@@ -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
View File
@@ -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 -49
View File
@@ -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({
+371
View File
@@ -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>
+187 -167
View File
@@ -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>
-482
View File
@@ -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>
+450
View File
@@ -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>
+363
View File
@@ -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">
+278
View File
@@ -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/v3API 根地址,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>
+60 -53
View File
@@ -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('操作成功!')
+62 -1
View File
@@ -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 () => {
+197 -6
View File
@@ -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>
+387
View File
@@ -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 -10
View File
@@ -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) => {
+4 -14
View File
@@ -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'
+4 -16
View File
@@ -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',
+5 -11
View File
@@ -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'),
}
})
})
+12 -13
View File
@@ -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) => {
+75 -16
View File
@@ -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_msgok
showSuccessToast('支付成功!')
setTimeout(() => {
location.reload()
}, 1500)
} else {
showFailToast('支付失败:' + res.err_msg)
}
}
)
}
</script>
<style lang="scss" scoped>
+2 -2
View File
@@ -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"
+222 -483
View File
@@ -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