feat: 迁移 geekai-plus v4.3.1 到开源版

- 同步 Plus v4.3.1 功能源并移除商业 License 闭环
- 更新开源镜像命名、Docker 部署版本和 geekai 数据库配置
- 补充前端 ESLint 检查配置并修复存量解析与模板问题
- 保留 JWT、管理员权限、API Key 和 OAuth 等正常鉴权机制
This commit is contained in:
RockYang
2026-09-13 14:37:12 +08:00
parent 3e66c5ac5b
commit d4fd38ab7e
96 changed files with 7164 additions and 3047 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.3.0
VITE_VERSION=v4.3.1
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.3.0
VITE_VERSION=v4.3.1
VITE_TITLE="Geek-AI 创作系统"
VITE_DOCS_URL=https://docs.geekai.me
VITE_GITHUB_URL=https://github.com/yangjian102621/geekai
+29
View File
@@ -0,0 +1,29 @@
module.exports = {
root: true,
env: {
browser: true,
es2022: true,
node: true,
},
extends: [
'plugin:vue/vue3-essential',
],
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
globals: {
defineProps: 'readonly',
defineEmits: 'readonly',
defineExpose: 'readonly',
withDefaults: 'readonly',
},
rules: {
'vue/multi-word-component-names': 'off',
'vue/no-mutating-props': 'off',
'vue/require-valid-default-prop': 'off',
'vue/no-dupe-keys': 'off',
'vue/no-side-effects-in-computed-properties': 'off',
'vue/no-textarea-mustache': 'off',
},
}
+3 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"dev": "vite --host",
"build": "vite build",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --ignore-path .gitignore"
},
"dependencies": {
"@better-scroll/core": "^2.5.1",
@@ -51,6 +51,8 @@
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.4",
"autoprefixer": "^10.4.20",
"eslint": "8.57.1",
"eslint-plugin-vue": "9.33.0",
"postcss": "^8.4.49",
"sass-embedded": "^1.89.2",
"stylus": "^0.58.1",
+517
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

+480 -134
View File
@@ -10,23 +10,39 @@
.mj-box {
margin: 10px;
// background-color: #262626;
// border: 1px solid #454545;
// height: calc(100vh - 50px);
// overflow: scroll;
min-width: 300px;
max-width: 300px;
padding: 10px;
border-radius: 10px;
flex: 0 0 360px;
width: 360px;
min-width: 360px;
max-width: 360px;
padding: 12px 12px 14px;
border-radius: 12px;
color: var(--text-theme-color);
font-size: 14px;
overflow: auto;
background: var(--card-bg, rgba(255, 255, 255, 0.04));
border: 1px solid var(--chat-bg, #e8e8ef);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
h2 {
font-weight: bold;
font-size: 20px;
&__head {
text-align: center;
color: var(--theme-textcolor-normal);
padding-bottom: 12px;
margin-bottom: 4px;
border-bottom: 1px solid var(--chat-bg, #e5e7eb);
}
&__title {
margin: 0;
font-size: 18px;
font-weight: 700;
letter-spacing: 0.03em;
color: var(--theme-textcolor-normal, var(--text-theme-color));
}
&__sub {
margin: 4px 0 0;
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-secondary, #909399);
}
// 隐藏滚动条
@@ -37,135 +53,223 @@
}
.mj-params {
margin-top: 10px;
margin-top: 4px;
overflow: auto;
}
.param-line {
padding: 0 10px;
.mj-params-form {
.mj-form-item--compact {
margin-bottom: 6px;
.el-icon {
position: relative;
}
.grid-content {
// background-color: #383838;
background: var(--card-bg);
border-radius: 8px;
padding: 8px 14px;
display: flex;
cursor: pointer;
margin-bottom: 10px;
// border: 1px solid #383838;
border: 1px solid var(--chat-bg);
&:hover {
border: 1px solid var(--theme-border-hover);
}
.icon {
width: 20px;
height: 20px;
margin-bottom: 5px;
}
.text {
margin-left: 5px;
margin-top: 2px;
}
}
.grid-content.active {
// color: #47fff1;
// background-color: #585858;
border: 1px solid var(--theme-border-hover);
}
.model {
background: var(--card-bg);
// border: 1px solid #454545;
border-radius: 8px;
padding: 5px;
margin-bottom: 10px;
display: flex;
flex-flow: column;
align-items: center;
cursor: pointer;
border: 1px solid var(--chat-bg);
&:hover {
border: 1px solid var(--theme-border-hover);
}
.el-image {
height: 40px;
width: 100%;
}
.text {
margin-top: 4px;
font-size: 12px;
}
}
.model.active {
// color: #47fff1;
// background-color: #585858;
border: 1px solid var(--theme-border-hover);
}
.form-item-inner {
display: flex;
align-items: center;
.el-select {
--el-select-input-focus-border-color: var(--el-color-primary);
--el-input-focus-border-color: var(--el-color-primary);
}
.el-input__wrapper {
background: var(--chat-bg);
}
.el-input__inner {
color: var(--text-theme-color);
}
.el-icon {
margin-left: 10px;
}
}
.img-uploader {
.el-upload {
border: 1px dashed var(--el-border-color);
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
width: 100%;
transition: var(--el-transition-duration-fast);
&:hover {
border-color: var(--el-color-primary);
}
.el-icon.uploader-icon {
font-size: 28px;
color: #8c939d;
width: 100%;
height: 120px;
text-align: center;
}
}
:deep(.el-form-item__label) {
padding-bottom: 2px;
line-height: 1.35;
font-size: 12px;
font-weight: 500;
}
}
.param-line.pt {
display: flex;
align-items: center;
padding-top: 5px;
padding-bottom: 5px;
.el-input,
.el-slider {
width: 100%;
max-width: 100%;
}
.el-select.mj-select-full {
width: 100%;
}
}
.mj-sec {
padding: 10px 0 12px;
border-bottom: 1px solid var(--chat-bg, rgba(15, 23, 42, 0.08));
&--last {
border-bottom: 0;
padding-bottom: 4px;
}
}
.mj-sec__title {
margin: 0 0 6px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--el-text-color-secondary, #64748b);
}
.mj-sec__hint {
margin: 0 0 8px;
font-size: 11px;
line-height: 1.45;
color: var(--el-text-color-secondary, #94a3b8);
}
.mj-code {
font-size: 10px;
padding: 1px 5px;
border-radius: 4px;
background: var(--chat-bg, #f1f5f9);
color: var(--el-color-primary);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.mj-aspect-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.mj-chip {
margin: 0;
padding: 6px 10px;
font-size: 11px;
font-weight: 600;
font-family: inherit;
line-height: 1.2;
color: var(--text-theme-color);
background: var(--chat-bg, #f8fafc);
border: 1px solid #e5e7f5;
border-radius: 8px;
cursor: pointer;
transition:
border-color 0.2s ease,
background-color 0.2s ease,
color 0.2s ease;
&:hover {
border-color: #a5b4fc;
}
&:focus-visible {
outline: 2px solid #6366f1;
outline-offset: 2px;
}
&--active {
border-color: #6366f1;
background: #eef2ff;
color: #3730a3;
}
}
.mj-switch-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.mj-switch-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid #e5e7f5;
background: var(--chat-bg, #f8fafc);
cursor: pointer;
transition: border-color 0.2s ease;
&:hover {
border-color: #c7d2fe;
}
&__text {
font-size: 12px;
font-weight: 600;
color: var(--text-theme-color);
line-height: 1.3;
}
}
.mj-slider-field {
margin-bottom: 2px;
}
.mj-model-cards {
display: flex;
flex-direction: column;
gap: 8px;
}
.mj-model-card {
display: block;
width: 100%;
margin: 0;
padding: 10px 12px;
text-align: left;
font: inherit;
color: inherit;
background: var(--card-bg, #fff);
border: 1px solid #e5e7f5;
border-radius: 8px;
cursor: pointer;
transition:
border-color 0.2s ease,
background-color 0.2s ease;
&:hover {
border-color: #a5b4fc;
}
&:focus-visible {
outline: 2px solid #6366f1;
outline-offset: 2px;
}
&--active {
border-color: #6366f1;
background: #eef2ff;
}
}
.mj-model-card__row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.mj-model-card__name {
font-weight: 600;
font-size: 13px;
line-height: 1.35;
color: var(--theme-textcolor-normal, #252f76);
flex: 1;
min-width: 0;
}
.mj-model-card__flag {
flex-shrink: 0;
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.75);
color: #6366f1;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
border: 1px solid #e5e7f5;
}
.mj-model-card--active .mj-model-card__flag {
background: #fff;
border-color: #c7d2fe;
}
.mj-model-card__badge {
margin-top: 4px;
font-size: 11px;
font-weight: 600;
line-height: 1.35;
color: #7c3aed;
}
.mj-model-card__summary {
margin: 6px 0 0;
font-size: 11px;
line-height: 1.5;
color: var(--el-text-color-regular, #6b7280);
}
}
@@ -487,3 +591,245 @@
position: relative;
}
}
/* 创作记录:成功态 U/V、局部重绘固定在图下方;悬停仅图片区域时底栏显示分享/详情/删除(与 Image.vue 底栏样式一致) */
.page-mj .finish-job-list {
:deep(.waterfall-item:has(.image-task-preview--failed) .waterfall-card) {
height: 100%;
}
.image-task-item {
position: relative;
border-radius: 0;
overflow: hidden;
background: transparent;
&:has(.image-task-preview--failed) {
display: flex;
flex-direction: column;
height: 100%;
}
}
/* 仅图片区域为定位上下文,悬停条贴在图底部(下方 U/V 行不参与悬停显隐) */
.image-task-media {
position: relative;
width: 100%;
flex: 0 0 auto;
}
.image-task-item:has(.image-task-preview--failed) .image-task-media {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
}
.image-task-preview {
position: relative;
width: 100%;
min-height: 180px;
overflow: hidden;
border: 1px solid var(--el-border-color);
background: var(--card-bg);
flex: 0 0 auto;
&.image-task-preview--failed {
display: flex;
flex: 1 1 auto;
min-height: 180px;
justify-content: center;
align-items: center;
background: var(--card-bg);
}
}
.image-task-image {
width: 100%;
height: auto;
display: block;
cursor: pointer;
}
.image-task-image--failed {
position: static;
width: 60%;
max-width: 220px;
height: auto;
object-fit: contain;
object-position: center center;
cursor: pointer;
}
.image-task-overlay {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 3;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 10px;
background: rgba(0, 0, 0, 0.48);
backdrop-filter: blur(6px);
opacity: 0;
transform: translateY(4px);
pointer-events: none;
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.image-task-item:hover .image-task-overlay {
opacity: 0;
transform: translateY(4px);
pointer-events: none;
}
.image-task-media:hover .image-task-overlay {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.image-task-overlay-time {
min-width: 0;
font-size: 13px;
line-height: 1.2;
color: rgba(255, 255, 255, 0.92);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-variant-numeric: tabular-nums;
}
.image-task-tools {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
flex-shrink: 0;
}
.image-task-tool {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 7px;
background: rgba(8, 10, 16, 0.62);
color: #f5f7fa;
cursor: pointer;
transition:
color 0.15s ease,
background 0.15s ease,
border-color 0.15s ease;
}
.image-task-tool .iconfont {
font-size: 15px;
}
.image-task-tool:hover {
color: #fff;
border-color: rgba(129, 140, 248, 0.8);
background: rgba(79, 70, 229, 0.72);
}
.image-task-tool--danger {
color: var(--el-color-danger);
}
.image-task-tool--danger:hover {
color: #fff;
background: var(--el-color-danger);
border-color: var(--el-color-danger);
}
/* 成功态:底栏单行,与 Image.vue 一致(时间 + 分享/详情/删除) */
.mj-task-overlay--meta {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.mj-task-opt {
padding: 6px 4px 4px;
border: 1px solid var(--el-border-color);
border-top: 0;
background: var(--card-bg, rgba(255, 255, 255, 0.04));
}
.mj-task-opt__uv {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
margin-bottom: 4px;
}
.mj-task-opt__modal {
display: flex;
justify-content: center;
margin-top: 4px;
}
.mj-task-opt-btn {
margin: 0;
padding: 4px 0;
min-width: 40px;
text-align: center;
border-radius: 5px;
border: none;
font-size: 12px;
font-weight: 600;
font-family: inherit;
line-height: 1.2;
cursor: pointer;
color: #fff;
background-color: #4e5058;
transition: background-color 0.15s ease;
&:hover {
background-color: #6d6f78;
}
}
.mj-task-opt-btn--uv {
width: 100%;
min-width: 0;
}
.mj-task-opt-btn--modal {
width: 100%;
max-width: 100%;
min-height: 30px;
padding: 6px 10px;
}
.mj-task-overlay--failed {
flex-direction: column;
align-items: stretch;
justify-content: flex-end;
gap: 8px;
max-height: 88%;
overflow-y: auto;
min-height: auto;
padding-top: 10px;
padding-bottom: 10px;
}
.mj-task-overlay--failed .image-task-overlay-time {
width: 100%;
text-align: center;
}
.mj-task-overlay--failed .image-task-tools {
width: 100%;
justify-content: center;
}
}
+168 -1
View File
@@ -10,7 +10,10 @@
margin: 10px;
// background-color: #262626;
// border: 1px solid #454545;
min-width: 300px;
// 侧栏过窄时固定宽 btn 会提前换行;260px 仅能摆 4×56+gapChrome/Blink 常因 flex 分配更宽而表现为 5 列。锁定 360px 保证与 max-width 一致的可视空间。
flex: 0 0 360px;
width: 360px;
min-width: 360px;
max-width: 360px;
padding: 10px;
border-radius: 10px;
@@ -222,3 +225,167 @@
}
}
}
.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;
}
}
.image-task-item {
position: relative;
border-radius: 0;
overflow: hidden;
background: transparent;
&:has(.image-task-preview--failed) {
display: flex;
flex-direction: column;
height: 100%;
}
}
/* 失败项:瀑布流格子有高度,让卡片铺满以便占位图 cover */
.finish-job-list :deep(.waterfall-item:has(.image-task-preview--failed) .waterfall-card) {
height: 100%;
}
.image-task-preview {
position: relative;
width: 100%;
min-height: 180px;
overflow: hidden;
border: 1px solid var(--el-border-color);
background: var(--card-bg);
flex: 0 0 auto;
&.image-task-preview--failed {
display: flex;
flex: 1 1 auto;
min-height: 180px;
justify-content: center;
align-items: center;
background: var(--card-bg);
}
}
.image-task-image {
width: 100%;
height: auto;
display: block;
cursor: pointer;
}
.image-task-image--failed {
position: static;
width: 60%;
max-width: 220px;
height: auto;
object-fit: contain;
object-position: center center;
cursor: pointer;
}
.image-detail-failed-actions {
margin-top: 16px;
text-align: right;
}
.image-task-overlay {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 3;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 10px;
background: rgba(0, 0, 0, 0.48);
backdrop-filter: blur(6px);
opacity: 0;
transform: translateY(4px);
pointer-events: none;
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.image-task-item:hover .image-task-overlay {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.image-task-overlay-time {
min-width: 0;
font-size: 13px;
line-height: 1.2;
color: rgba(255, 255, 255, 0.92);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-variant-numeric: tabular-nums;
}
.image-task-tools {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
flex-shrink: 0;
}
.image-task-tool {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 7px;
background: rgba(8, 10, 16, 0.62);
color: #f5f7fa;
cursor: pointer;
transition:
color 0.15s ease,
background 0.15s ease,
border-color 0.15s ease;
}
.image-task-tool .iconfont {
font-size: 15px;
}
.image-task-tool:hover {
color: #fff;
border-color: rgba(129, 140, 248, 0.8);
background: rgba(79, 70, 229, 0.72);
}
.image-task-tool--danger {
color: var(--el-color-danger);
}
.image-task-tool--danger:hover {
color: #fff;
background: var(--el-color-danger);
border-color: var(--el-color-danger);
}
.image-task-image--failed .lazy__img {
width: 90% !important;
}
+212 -164
View File
@@ -289,7 +289,7 @@
}
}
// 算力信息
// 积分信息
.text-info {
width: 100%;
padding: 10px 0;
@@ -341,179 +341,221 @@
// }
}
// 任务列表
// 任务列表(竖排卡片:大预览 + 底部标签与操作,提示词见详情)
.video-list {
.list-box {
padding: 0;
display: flex;
flex-direction: column;
gap: 0;
.item {
.video-task-item {
display: flex;
flex-flow: row;
align-items: center;
min-height: 100px;
padding: 10px 15px;
border-radius: 10px;
cursor: pointer;
margin-bottom: 20px;
flex-direction: column;
align-items: stretch;
padding: 0;
overflow: hidden;
border-radius: 0;
background: transparent;
border: none;
box-shadow: none;
transition: none;
&:has(.video-task-preview--failed) {
height: 100%;
}
}
/* 失败卡片:瀑布流格子有固定高度,让内容区铺满以便占位图 cover */
:deep(.waterfall-item:has(.video-task-preview--failed) .waterfall-card) {
height: 100%;
}
.video-task-preview {
position: relative;
width: 100%;
min-height: 180px;
max-height: none;
border-radius: 0;
overflow: hidden;
background: var(--card-bg);
flex: 0 0 auto;
.left {
.container {
width: 160px;
position: relative;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
.video {
width: 160px;
height: 120px;
border-radius: 5px;
background-color: var(--el-fill-color-light);
}
.el-image {
width: 160px;
height: 90px;
border-radius: 5px;
}
.duration {
position: absolute;
bottom: 0;
right: 0;
background-color: rgba(14, 8, 8, 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: var(--text-theme-color);
opacity: 0;
transform: translate(-50%, 0px);
transition: opacity 0.3s ease 0s;
}
&:hover {
.play {
opacity: 1;
}
}
}
}
.center {
width: 100%;
display: flex;
justify-content: center;
align-items: flex-start;
flex-flow: column;
padding: 0 20px;
// 允许中间区域在 flex 布局中收缩,避免挤压右侧操作区
&.video-task-preview--failed {
flex: 1 1 auto;
min-width: 0;
.prompt,
.failed {
padding: 0;
font-size: 16px;
max-height: 80px;
line-height: 28px;
overflow: hidden;
text-overflow: ellipsis;
}
.prompt {
color: var(--text-fb);
cursor: text;
}
.failed {
color: #e4696b;
// 错误信息中经常包含很长的无空格字符串(如 JSON、错误栈),
// 强制单词内换行,防止撑破布局把右侧删除按钮挤出视口
word-break: break-all;
white-space: normal;
}
.pb-2 {
padding-bottom: 8px;
.el-tag {
margin-right: 4px;
margin-bottom: 4px;
}
}
min-height: 180px;
background: var(--card-bg);
}
.right {
display: flex;
justify-content: right;
min-width: 200px;
font-size: 14px;
padding: 0;
&.video-task-preview--busy {
min-height: 180px;
}
}
.tools {
display: flex;
justify-content: left;
align-items: center;
flex-flow: row;
height: 90px;
.video-task-preview-inner {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.btn-publish {
padding: 2px 10px;
.video-task-preview-inner--success {
position: relative;
inset: auto;
display: block;
}
.text {
margin-right: 10px;
}
}
.video-task-preview-inner--busy {
padding: 8px;
min-height: 180px;
}
.btn-icon {
background: none;
padding: 6px;
transition: background 0.6s ease 0s;
color: #919191;
// 失败态:占位图铺满格子(与 LazyImg 成功态 cover 一致)
.video-task-preview-inner--failed {
position: absolute;
inset: 0;
padding: 0;
margin: 0;
width: 100%;
height: 100%;
box-sizing: border-box;
background: transparent;
}
&:hover {
color: var(--el-color-primary);
}
.video-task-fail-img {
width: 60%;
.downloading {
width: 16px;
}
}
}
display: block;
object-fit: cover;
object-position: center;
}
.video-task-video {
width: 100%;
height: auto;
max-height: none;
object-fit: contain;
object-position: center center;
display: block;
background: #111;
cursor: pointer;
}
.video-task-preview .video-task-overlay {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 4;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 10px;
border-radius: 0;
background: rgba(0, 0, 0, 0.48);
backdrop-filter: blur(6px);
opacity: 0;
transform: translateY(4px);
pointer-events: none;
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.video-task-overlay-time {
min-width: 0;
font-size: 13px;
line-height: 1.2;
color: rgba(255, 255, 255, 0.92);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-variant-numeric: tabular-nums;
}
.video-task-preview .video-task-overlay .video-task-tools {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
flex-shrink: 0;
}
.video-task-item:hover .video-task-preview .video-task-overlay {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.video-task-tool {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 7px;
background: rgba(8, 10, 16, 0.62);
color: #f5f7fa;
cursor: pointer;
transition:
color 0.15s ease,
background 0.15s ease,
border-color 0.15s ease;
.iconfont {
font-size: 15px;
}
.right-error {
display: flex;
justify-content: center;
align-items: center;
// 保证错误状态下删除按钮始终占据固定宽度,不被中间内容挤压
min-width: 60px;
flex-shrink: 0;
&:hover:not(:disabled) {
color: #fff;
border-color: rgba(129, 140, 248, 0.8);
background: rgba(79, 70, 229, 0.72);
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
.video-task-tool--danger {
color: var(--el-color-danger);
&:hover:not(:disabled) {
color: #fff;
background: var(--el-color-danger);
}
}
.video-task-tool-loading {
width: 18px;
height: 18px;
display: block;
}
}
// 分页
.pagination {
margin-top: 20px;
display: flex;
justify-content: center;
.waterfall-load-more {
padding: 10px 0 14px;
text-align: center;
font-size: 13px;
color: var(--text-disabled-color, #909399);
.waterfall-loading-icon {
width: 40px;
height: 40px;
margin: 0 auto;
display: block;
}
.waterfall-no-more {
display: inline-flex;
align-items: center;
color: var(--text-disabled-color, #909399);
}
}
}
}
@@ -555,22 +597,28 @@
.main-content {
padding: 1rem;
.video-list .list-box .item {
.left .container {
width: 120px;
.video,
.el-image {
width: 120px;
}
.video-list .list-box {
.video-task-preview {
min-height: 0;
max-height: none;
}
.center {
padding: 0 10px;
.video-task-item {
border-radius: 0;
}
.right {
min-width: 120px;
.video-task-preview .video-task-overlay {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.video-task-overlay-time {
font-size: 12px;
}
.video-task-preview-inner--busy {
min-height: 160px;
}
}
}
+1 -1
View File
@@ -56,7 +56,7 @@ onMounted(() => {
// 获取模型列表
httpGet('/api/model/list?type=tts').then((res) => {
models.value = res.data
if (!data.ttsModel && models.value.length > 0) {
if (!data.value.ttsModel && models.value.length > 0) {
store.setTtsModel(models.value[0].id)
}
})
+2 -2
View File
@@ -15,14 +15,14 @@
unique-opened
router
>
<template v-for="item in items">
<template v-for="item in items" :key="item.path">
<template v-if="item.subs">
<el-sub-menu :index="item.index" :key="item.index">
<template #title>
<i :class="'iconfont icon-' + item.icon"></i>
<span>{{ item.title }}</span>
</template>
<template v-for="subItem in item.subs">
<template v-for="subItem in item.subs" :key="subItem.index">
<el-sub-menu v-if="subItem.subs" :index="subItem.index" :key="subItem.index">
<template #title>{{ subItem.title }}</template>
<el-menu-item
+1 -13
View File
@@ -19,14 +19,6 @@
@click="$emit('use-role', app.id)"
>开始对话</van-button
>
<van-button
size="small"
:type="hasRole ? 'danger' : 'success'"
class="action-btn"
@click="$emit('update-role', app, hasRole ? 'remove' : 'add')"
>
{{ hasRole ? '移出工作台' : '添加到工作台' }}
</van-button>
</div>
</div>
</van-cell>
@@ -38,13 +30,9 @@ defineProps({
type: Object,
required: true,
},
hasRole: {
type: Boolean,
default: false,
},
})
defineEmits(['use-role', 'update-role'])
defineEmits(['use-role'])
</script>
<style scoped lang="scss">
File diff suppressed because it is too large Load Diff
+4 -6
View File
@@ -65,13 +65,11 @@ export const VideoParams = {
type: 'select',
required: true,
options: [
{ label: '10秒(逆向)', value: '10' },
{ label: '15秒(逆向)', value: '15' },
{ label: '4秒(官转)', value: '4' },
{ label: '8秒(官转)', value: '8' },
{ label: '12秒(官转)', value: '12' },
{ label: '4秒', value: '4' },
{ label: '8秒', value: '8' },
{ label: '12秒', value: '12' },
],
value: '10',
value: '4',
},
],
priceParams: ['fixed'], // 固定价格
+171 -35
View File
@@ -7,13 +7,44 @@
import { checkSession } from '@/store/cache'
import { JimengFunctions, JimengParams } from '@/store/data/jimeng_params'
import { useSharedStore } from '@/store/sharedata'
import { showMessageError, showMessageOK } from '@/utils/dialog'
import { httpDownload, httpGet, httpPost } from '@/utils/http'
import { replaceImg, substr } from '@/utils/libs'
import failedIcon from '@/assets/img/failed.png'
import loadingGif from '@/assets/img/loading.gif'
import { getThumbURL, replaceImg, substr } from '@/utils/libs'
import { ElMessageBox } from 'element-plus'
import { defineStore } from 'pinia'
import { reactive, ref } from 'vue'
import { nextTick, reactive, ref } from 'vue'
/** 与瀑布流 imgSelector: img_thumb 对齐(参考 Image.vue */
function normalizeJimengWaterfallThumb(item) {
const img = item.img_url ? replaceImg(item.img_url) : ''
if (img) {
item.img_thumb = getThumbURL(img, 300, 0)
return
}
if (item.video_url) {
item.img_thumb = loadingGif
return
}
if (item.status === 'failed') {
item.img_thumb = failedIcon
return
}
item.img_thumb = loadingGif
}
function refreshJimengListThumbs(list) {
if (!list || !list.length) {
return
}
for (const row of list) {
normalizeJimengWaterfallThumb(row)
}
}
/** 轮询需跟进的非终态(含 submited:队列尚未改 in_queue 前) */
const POLLING_ACTIVE_STATUSES = new Set(['submited', 'in_queue', 'generating', 'done'])
export const useJimengStore = defineStore('jimeng', () => {
// 共同状态
@@ -26,15 +57,10 @@ export const useJimengStore = defineStore('jimeng', () => {
const currentList = ref([])
const isOver = ref(false)
// 用户信息
const isLogin = ref(false)
// 视频预览
const showDialog = ref(false)
const currentVideoUrl = ref('')
// 登录弹窗
const shareStore = useSharedStore()
// 积分消耗配置
const powerConfig = reactive({ powers: {} })
const currentPowerCost = ref('0积分')
@@ -74,6 +100,7 @@ export const useJimengStore = defineStore('jimeng', () => {
submited: '任务已提交',
in_queue: '任务排队中',
generating: '任务执行中',
done: '处理完成',
success: '任务成功',
failed: '任务失败',
canceled: '任务已取消',
@@ -131,6 +158,7 @@ export const useJimengStore = defineStore('jimeng', () => {
} else {
currentList.value = currentList.value.concat(data.items)
}
refreshJimengListThumbs(currentList.value)
} catch (error) {
showMessageError('获取任务列表失败:' + error.message)
} finally {
@@ -138,34 +166,63 @@ export const useJimengStore = defineStore('jimeng', () => {
}
}
/** 轮询合并:新行对象 + 新数组引用,避免瀑布流对同 id 行就地 mutate 不刷新 */
const mergeJobsIntoCurrentList = (rows) => {
if (!rows || !rows.length) {
return
}
const next = currentList.value.map((item) => {
const hit = rows.find((i) => i.id === item.id)
if (hit) {
const row = { ...item, ...hit }
normalizeJimengWaterfallThumb(row)
return row
}
return item
})
currentList.value = next
}
// 简单轮询逻辑
const startPolling = () => {
if (pollHandler) {
clearInterval(pollHandler)
}
pollHandler = setInterval(async () => {
const response = await httpPost('/api/jimeng/jobs', {
page: 1,
page_size: 20,
})
const data = response.data
if (!data.items || data.items.length === 0) {
stopPolling()
return
}
try {
const activeIds = currentList.value
.filter((item) => POLLING_ACTIVE_STATUSES.has(item.status))
.map((item) => item.id)
const todoList = data.items.filter(
(item) => item.status === 'in_queue' || item.status === 'generating'
)
// 更新当前列表
currentList.value.forEach((item) => {
const index = data.items.findIndex((i) => i.id === item.id)
if (index !== -1) {
Object.assign(item, data.items[index])
const body = {
page: 1,
page_size: 100,
filter: taskFilter.value,
}
})
if (todoList.length === 0) {
stopPolling()
if (activeIds.length > 0) {
body.ids = activeIds
}
const response = await httpPost('/api/jimeng/jobs', body)
const data = response.data || {}
if (!data.items || data.items.length === 0) {
if (activeIds.length === 0) {
stopPolling()
}
return
}
mergeJobsIntoCurrentList(data.items)
const stillActive = currentList.value.some((item) =>
POLLING_ACTIVE_STATUSES.has(item.status)
)
if (!stillActive) {
stopPolling()
}
} catch (e) {
console.error('jimeng poll error', e)
}
}, 3000)
}
@@ -179,10 +236,6 @@ export const useJimengStore = defineStore('jimeng', () => {
// 提交任务
const submitTask = async () => {
if (!isLogin.value) {
shareStore.setShowLoginDialog(true)
return
}
for (const key in requiredKeys.value) {
if (!formData.value[key]) {
showMessageError('缺少参数:' + requiredKeys.value[key].label)
@@ -203,6 +256,24 @@ export const useJimengStore = defineStore('jimeng', () => {
if (data.image_urls && !Array.isArray(data.image_urls)) {
data.image_urls = [data.image_urls]
}
if (data.video_url && !Array.isArray(data.video_url)) {
data.video_url = [data.video_url]
}
if (data.audio_url && !Array.isArray(data.audio_url)) {
data.audio_url = [data.audio_url]
}
if (typeof data.req_key === 'string' && data.req_key.startsWith('doubao-seedance-')) {
data.content = buildSeedanceContent(data)
if (data.seedance_mode === 'multimodal' && data.content.length === 0) {
throw new Error('多模态模式下,请至少上传图片、视频或音频中的一种素材')
}
// Seedance 统一使用 content[] 传多模态,避免旧字段类型与后端绑定冲突。
delete data.video_url
delete data.audio_url
delete data.image_role
delete data.seedance_mode
}
const response = await httpPost('/api/jimeng/task', data)
showMessageOK('任务提交成功')
@@ -294,6 +365,73 @@ export const useJimengStore = defineStore('jimeng', () => {
})
}
const normalizeMediaList = (value) => {
if (!value) {
return []
}
if (Array.isArray(value)) {
return value.filter(Boolean)
}
return [value]
}
const buildSeedanceContent = (data) => {
const content = []
if (data.prompt) {
content.push({
type: 'text',
text: data.prompt,
})
}
const imageList = normalizeMediaList(data.image_urls)
const videoList = normalizeMediaList(data.video_url)
const audioList = normalizeMediaList(data.audio_url)
const imageRole = data.image_role || 'reference_image'
if (data.seedance_mode === 'image_first') {
imageList.slice(0, 1).forEach((url) => {
content.push({
type: 'image_url',
image_url: { url },
role: 'first_frame',
})
})
} else if (data.seedance_mode === 'image_first_last') {
imageList.slice(0, 2).forEach((url, index) => {
content.push({
type: 'image_url',
image_url: { url },
role: index === 0 ? 'first_frame' : 'last_frame',
})
})
} else {
imageList.forEach((url) => {
content.push({
type: 'image_url',
image_url: { url },
role: imageRole,
})
})
}
videoList.forEach((url) => {
content.push({
type: 'video_url',
video_url: { url },
role: 'reference_video',
})
})
audioList.forEach((url) => {
content.push({
type: 'audio_url',
audio_url: { url },
role: 'reference_audio',
})
})
return content
}
watch(
() => formData.value,
() => {
@@ -310,8 +448,7 @@ export const useJimengStore = defineStore('jimeng', () => {
powerConfig.powers = powerRes.data.powers || {}
setFunctionPowers()
}
const user = await checkSession()
isLogin.value = true
await checkSession()
// 获取任务列表
await fetchData(1)
// 开始轮询
@@ -345,7 +482,6 @@ export const useJimengStore = defineStore('jimeng', () => {
taskFilter,
currentList,
isOver,
isLogin,
showDialog,
currentVideoUrl,
// 配置
+4 -4
View File
@@ -198,10 +198,10 @@ export const useSunoStore = defineStore('suno', () => {
let needPull = false
const items = []
for (let v of res.data.items) {
if (v.progress === 100) {
if (v.status === 'success') {
v.major_model_version = v['raw_data']['major_model_version']
}
if (v.progress === 0 || v.progress === 102) {
if (v.status === 'pending' || v.status === 'in_progress' || v.status === 'downloading') {
needPull = true
}
items.push(v)
@@ -236,10 +236,10 @@ export const useSunoStore = defineStore('suno', () => {
let needPull = false
const firstPageItems = []
for (let v of res.data.items) {
if (v.progress === 100) {
if (v.status === 'success') {
v.major_model_version = v['raw_data']['major_model_version']
}
if (v.progress === 0 || v.progress === 102) {
if (v.status === 'pending' || v.status === 'in_progress' || v.status === 'downloading') {
needPull = true
}
firstPageItems.push(v)
+1 -1
View File
@@ -251,7 +251,7 @@ export const useVideoStore = defineStore('video', () => {
let needPull = false
const items = []
for (let v of res.data.items) {
if (v.progress === 0 || v.progress === 102) {
if (v.status === 'pending' || v.status === 'in_progress' || v.status === 'downloading') {
needPull = true
}
items.push(v)
+3
View File
@@ -3,6 +3,8 @@ import loadingIcon from '@/assets/img/loading.gif'
import Storage from 'good-storage'
import { defineStore } from 'pinia'
const taskFailedImage = '/images/failed.png'
let waterfallOptions = {
// 唯一key值
rowKey: 'id',
@@ -87,6 +89,7 @@ export const useSharedStore = defineStore('shared', {
chatListExtend: Storage.get('chat_list_extend', true),
ttsModel: Storage.get('tts_model', ''),
waterfallOptions,
taskFailedImage,
}),
getters: {},
actions: {
+31 -46
View File
@@ -1,22 +1,42 @@
import { closeLoading, showLoading, showMessageError, showMessageOK } from '@/utils/dialog'
import { httpDownload, httpGet, httpPost } from '@/utils/http'
import { downloadFile, httpDownload, httpGet, httpPost } from '@/utils/http'
import { replaceImg } from '@/utils/libs'
import Compressor from 'compressorjs'
import { ElMessage, ElMessageBox } from 'element-plus'
import { compact } from 'lodash'
import { defineStore } from 'pinia'
import { computed, onMounted, ref } from 'vue'
import { checkSession, getSystemInfo } from './cache'
import { useSharedStore } from './sharedata'
import { getSystemInfo } from './cache'
export const useSunoStore = defineStore('suno', () => {
// 响应式数据
const custom = ref(false)
/**
* Suno mv 与各版本说明Geek 服务文档对齐
* badge下拉左侧角标内短文案
*/
const models = ref([
{ label: 'v3.0', value: 'chirp-v3-0' },
{ label: 'v3.5', value: 'chirp-v3-5' },
{ label: 'v4.0', value: 'chirp-v4' },
{ label: 'v4.5', value: 'chirp-auk' },
{
label: 'v5.5',
value: 'chirp-fenix',
badge: '5.5',
hint: '当前最强,人声细腻情绪足,最长8分钟',
},
{ label: 'v5', value: 'chirp-crow', badge: '5.0', hint: '接近真人、提示词理解准,通用首选' },
{
label: 'v4.5+',
value: 'chirp-bluejay',
badge: '4.5+',
hint: '配器音色丰富可编辑,最长8分钟',
},
{
label: 'v4.5-all',
value: 'chirp-auk-turbo',
badge: '4.5t',
hint: '极速出歌结构好,最长4分钟',
},
{ label: 'v4.5', value: 'chirp-auk', badge: '4.5', hint: '人声稳定配器均衡,最长4分钟' },
{ label: 'v4', value: 'chirp-v4', badge: '4.0', hint: '48k细节多中文友好,最长150秒' },
])
const tags = ref([
@@ -39,7 +59,7 @@ export const useSunoStore = defineStore('suno', () => {
])
const data = ref({
model: 'chirp-auk',
model: 'chirp-fenix',
tags: '',
lyrics: '',
prompt: '',
@@ -63,8 +83,6 @@ export const useSunoStore = defineStore('suno', () => {
const promptPlaceholder = ref('请在这里输入你自己写的歌词...')
const isGenerating = ref(false)
const sunoPower = ref(0)
const isLogin = ref(false)
const shareStore = useSharedStore()
// 分页相关
const page = ref(1)
@@ -81,9 +99,6 @@ export const useSunoStore = defineStore('suno', () => {
getSystemInfo().then((res) => {
sunoPower.value = res.data.suno_power
})
checkSession().then((res) => {
isLogin.value = true
})
})
// 方法
@@ -104,7 +119,7 @@ export const useSunoStore = defineStore('suno', () => {
const items = []
for (let v of res.data.items) {
if (v.progress === 0 || v.progress === 102) {
if (['pending', 'in_progress', 'downloading'].includes(v.status)) {
needPull = true
}
items.push(v)
@@ -126,10 +141,6 @@ export const useSunoStore = defineStore('suno', () => {
}
const create = async () => {
if (!isLogin.value) {
return shareStore.setShowLoginDialog(true)
}
data.value.type = custom.value ? 2 : 1
data.value.ref_task_id = refSong.value ? refSong.value.task_id : ''
data.value.ref_song_id = refSong.value ? refSong.value.song_id : ''
@@ -175,36 +186,10 @@ export const useSunoStore = defineStore('suno', () => {
}
const download = async (item) => {
const url = replaceImg(item.audio_url)
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
}
await downloadFile(item, 'audio_url')
}
const uploadAudio = async (file) => {
// 判断是否登录
if (!isLogin.value) {
return shareStore.setShowLoginDialog(true)
}
const formData = new FormData()
formData.append('file', file.file, file.name)
showLoading('正在上传文件...')
@@ -375,7 +360,7 @@ export const useSunoStore = defineStore('suno', () => {
const resetData = () => {
data.value = {
model: 'chirp-auk',
model: 'chirp-fenix',
tags: '',
lyrics: '',
prompt: '',
+160 -40
View File
@@ -6,28 +6,61 @@
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
import nodata from '@/assets/img/no-data.png'
import failedIcon from '@/assets/img/failed.png'
import loadingGif from '@/assets/img/loading.gif'
import { checkSession } from '@/store/cache'
import { getVideoModelByKey, getVideoModels, getVideoProviders } from '@/store/data/video_params'
import { useSharedStore } from '@/store/sharedata'
import { closeLoading, showLoading, showMessageError, showMessageOK } from '@/utils/dialog'
import { httpDownload, httpGet, httpPost } from '@/utils/http'
import { replaceImg, substr } from '@/utils/libs'
import { getThumbURL, replaceImg, substr } from '@/utils/libs'
import Clipboard from 'clipboard'
import { ElMessage, ElMessageBox } from 'element-plus'
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
export const useVideoStore = defineStore('video', () => {
const normalizeWorkItemThumb = (item) => {
if (!item) {
return item
}
if (item.status === 'success' && item.video_url) {
item.img_thumb = getThumbURL(replaceImg(item.video_url), 300, 0)
return item
}
if (item.status === 'failed') {
item.img_thumb = failedIcon
return item
}
if (item.status === 'downloading') {
item.img_thumb = loadingGif
return item
}
item.img_thumb = loadingGif
return item
}
const normalizeWorkListThumbs = (rows) => {
if (!rows || !rows.length) {
return rows
}
for (const row of rows) {
normalizeWorkItemThumb(row)
}
return rows
}
const providers = getVideoProviders()
const activeProvider = ref(providers.includes('sora') ? 'sora' : providers[0] || '')
const loading = ref(false)
const submitting = ref(false)
const taskList = ref([])
const list = ref([])
const noData = ref(true)
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const isOver = ref(false)
const taskPulling = ref(true)
const pullHandler = ref(null)
const clipboard = ref(null)
@@ -35,9 +68,7 @@ export const useVideoStore = defineStore('video', () => {
const showDialog = ref(false)
const currentVideoUrl = ref('')
const isLogin = ref(false)
const availablePower = ref(0)
const shareStore = useSharedStore()
const taskFilter = ref('all') // 'all' 或 provider
@@ -48,10 +79,11 @@ export const useVideoStore = defineStore('video', () => {
const currentPowerCost = ref(0)
const currentList = computed(() => {
return list.value.filter((item) => {
const filtered = list.value.filter((item) => {
if (taskFilter.value === 'all') return true
return item.type === taskFilter.value
})
return filtered
})
const providerModels = computed(() => {
@@ -72,11 +104,10 @@ export const useVideoStore = defineStore('video', () => {
if (pullHandler.value) {
clearInterval(pullHandler.value)
}
pollLatest()
pullHandler.value = setInterval(() => {
if (taskPulling.value) {
fetchData(page.value)
}
}, 5000)
pollLatest()
}, 2000)
}
const stopPolling = () => {
@@ -89,11 +120,11 @@ export const useVideoStore = defineStore('video', () => {
const init = async () => {
try {
const user = await checkSession()
isLogin.value = true
availablePower.value = user.power
initClipboard()
await loadPowerConfig()
await fetchTaskList()
await fetchData(1)
startPolling()
} catch (error) {
@@ -108,43 +139,129 @@ export const useVideoStore = defineStore('video', () => {
stopPolling()
}
const fetchData = async (_page) => {
if (_page) {
page.value = _page
}
const fetchData = async (pageNum = 1) => {
try {
loading.value = true
const res = await httpGet('/api/video/list', {
page: page.value,
page.value = pageNum
if (pageNum === 1) {
isOver.value = false
}
const res = await httpGet('/api/video/works', {
page: pageNum,
page_size: pageSize.value,
type: taskFilter.value === 'all' ? '' : taskFilter.value,
})
total.value = res.data.total
let needPull = false
const items = []
for (let v of res.data.items) {
// 检查是否需要继续轮询:progress 为 0 或 102,或者状态为 pending/in_progress/downloading
if (v.status === 'pending' || v.status === 'in_progress' || v.status === 'downloading') {
needPull = true
const items = (res.data.items || []).map((v) => ({
...v,
downloading: false,
}))
normalizeWorkListThumbs(items)
if (items.length === 0) {
isOver.value = true
if (pageNum === 1) {
list.value = []
noData.value = true
}
items.push({
...v,
downloading: false,
})
taskPulling.value = false
return
}
if (items.length < pageSize.value || pageNum * pageSize.value >= total.value) {
isOver.value = true
}
const needPull = items.some((item) => item.status === 'downloading')
taskPulling.value = needPull
list.value = items
if (pageNum === 1) {
list.value = items
} else {
const merged = [...list.value]
const exists = new Set(merged.map((it) => it.id))
for (const item of items) {
if (!exists.has(item.id)) {
merged.push(item)
}
}
list.value = merged
}
noData.value = list.value.length === 0
} catch (error) {
noData.value = true
if (pageNum === 1) {
noData.value = true
}
console.error('获取任务列表失败:', error)
} finally {
loading.value = false
}
}
const pollLatest = async () => {
try {
const [taskRes, workRes] = await Promise.all([
httpGet('/api/video/tasks', {
type: taskFilter.value === 'all' ? '' : taskFilter.value,
}),
httpGet('/api/video/works', {
page: 1,
page_size: pageSize.value,
type: taskFilter.value === 'all' ? '' : taskFilter.value,
}),
])
const nextTasks = (taskRes.data || []).map((v) => ({ ...v, downloading: false }))
taskList.value = nextTasks
const res = workRes
const items = (res.data.items || []).map((v) => ({ ...v, downloading: false }))
normalizeWorkListThumbs(items)
let needPull = false
const latestMap = new Map(items.map((it) => [it.id, it]))
const matchedIds = []
const missedIds = []
for (const fetchedItem of items) {
if (list.value.some((localItem) => localItem.id === fetchedItem.id)) {
matchedIds.push(fetchedItem.id)
} else {
missedIds.push(fetchedItem.id)
}
}
list.value = list.value.map((row) => {
const latest = latestMap.get(row.id)
if (!latest) {
return row
}
if (latest.status === 'downloading') {
needPull = true
}
return normalizeWorkItemThumb({ ...row, ...latest })
})
for (const row of items) {
if (!list.value.some((item) => item.id === row.id)) {
list.value.unshift(row)
}
}
normalizeWorkListThumbs(list.value)
taskPulling.value = needPull || nextTasks.length > 0
} catch (error) {
console.error('轮询视频任务失败:', error)
}
}
const fetchTaskList = async () => {
try {
const res = await httpGet('/api/video/tasks', {
type: taskFilter.value === 'all' ? '' : taskFilter.value,
})
const items = (res.data || []).map((v) => ({ ...v, downloading: false }))
taskList.value = items
} catch (error) {
console.error('获取任务列表失败:', error)
}
}
const switchProvider = (provider) => {
activeProvider.value = provider
}
@@ -152,6 +269,8 @@ export const useVideoStore = defineStore('video', () => {
const switchTaskFilter = (filter) => {
taskFilter.value = filter
page.value = 1
isOver.value = false
fetchTaskList()
fetchData(1)
}
@@ -160,7 +279,7 @@ export const useVideoStore = defineStore('video', () => {
const res = await httpGet('/api/video/power-config')
powerConfig.value = res.data || {}
} catch (error) {
console.error('加载算力配置失败:', error)
console.error('加载积分配置失败:', error)
powerConfig.value = {}
}
}
@@ -218,7 +337,7 @@ export const useVideoStore = defineStore('video', () => {
// 防抖定时器
let powerDebounceTimer = null
// 根据 priceKey 获取算力
// 根据 priceKey 获取积分
const getPowerByPriceKey = async (modelKey, priceKey) => {
try {
const res = await httpGet('/api/video/power-by-key', {
@@ -227,7 +346,7 @@ export const useVideoStore = defineStore('video', () => {
})
return res.data?.power || 0
} catch (error) {
console.error('获取算力失败:', error)
console.error('获取积分失败:', error)
return 0
}
}
@@ -258,7 +377,7 @@ export const useVideoStore = defineStore('video', () => {
return
}
// 调用 API 获取算力
// 调用 API 获取积分
const power = await getPowerByPriceKey(modelKey, priceKey)
currentPowerCost.value = power
}, 300) // 300ms 防抖
@@ -290,11 +409,6 @@ export const useVideoStore = defineStore('video', () => {
}
const createVideoTask = async () => {
if (!isLogin.value) {
shareStore.setShowLoginDialog(true)
return
}
const modelKey = formData.value?.req_key
if (!modelKey) {
return ElMessage.error('请选择模型')
@@ -349,6 +463,8 @@ export const useVideoStore = defineStore('video', () => {
await httpPost('/api/video/create', requestData)
showMessageOK('任务创建成功')
closeLoading()
isOver.value = false
await fetchTaskList()
await fetchData(1)
taskPulling.value = true
} catch (error) {
@@ -399,6 +515,8 @@ export const useVideoStore = defineStore('video', () => {
await httpGet('/api/video/remove', { id: item.id })
ElMessage.success('任务删除成功')
isOver.value = false
await fetchTaskList()
await fetchData(1)
} catch (error) {
if (error !== 'cancel') {
@@ -415,14 +533,15 @@ export const useVideoStore = defineStore('video', () => {
loading,
submitting,
list,
taskList,
currentList,
noData,
page,
pageSize,
total,
isOver,
showDialog,
currentVideoUrl,
isLogin,
availablePower,
nodata,
taskFilter,
@@ -435,6 +554,7 @@ export const useVideoStore = defineStore('video', () => {
init,
cleanup,
fetchData,
fetchTaskList,
switchProvider,
switchTaskFilter,
+2 -69
View File
@@ -35,19 +35,6 @@
<el-button size="small" class="sm-btn-theme" @click="useRole(scope.item)"
>使用</el-button
>
<el-tooltip content="从工作区移除" placement="top" v-if="hasRole(scope.item.id)">
<el-button size="small" type="danger" @click="updateRole(scope.item, 'remove')"
>移除</el-button
>
</el-tooltip>
<el-tooltip content="添加到工作区" placement="top" v-else>
<el-button
size="small"
style="--el-color-primary: #009999"
@click="updateRole(scope.item, 'add')"
>添加</el-button
>
</el-tooltip>
</div>
</div>
</div>
@@ -64,10 +51,8 @@
<script setup>
import nodata from '@/assets/img/no-data.png'
import ItemList from '@/components/ItemList.vue'
import { checkSession } from '@/store/cache'
import { useSharedStore } from '@/store/sharedata'
import { httpGet, httpPost } from '@/utils/http'
import { arrayContains, removeArrayItem, substr } from '@/utils/libs'
import { httpGet } from '@/utils/http'
import { substr } from '@/utils/libs'
import { ElMessage } from 'element-plus'
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
@@ -77,25 +62,12 @@ const listBoxHeight = window.innerHeight - 133
const typeId = ref('')
const appTypes = ref([])
const list = ref([])
const roles = ref([])
const store = useSharedStore()
onMounted(() => {
getAppType()
getAppList()
getRoles()
})
const getRoles = () => {
checkSession()
.then((user) => {
roles.value = Array.isArray(user.chat_roles) ? user.chat_roles : []
})
.catch((e) => {
console.log(e.message)
})
}
const getAppType = () => {
httpGet('/api/app/type/list')
.then((res) => {
@@ -122,45 +94,6 @@ const getAppList = (tid = '') => {
})
}
const updateRole = (row, opt) => {
checkSession()
.then(() => {
const title = ref('')
if (opt === 'add') {
title.value = '添加应用'
const exists = arrayContains(roles.value, row.id)
if (exists) {
return
}
roles.value.push(row.id)
} else {
title.value = '移除应用'
const exists = arrayContains(roles.value, row.id)
if (!exists) {
return
}
roles.value = removeArrayItem(roles.value, row.id)
}
httpPost('/api/app/workspace', { ids: roles.value })
.then(() => {
ElMessage.success({
message: title.value + '成功!',
duration: 1000,
})
})
.catch((e) => {
ElMessage.error(title.value + '失败:' + e.message)
})
})
.catch(() => {
store.setShowLoginDialog(true)
})
}
const hasRole = (roleId) => {
return arrayContains(roles.value, roleId, (v1, v2) => v1 === v2)
}
const router = useRouter()
const useRole = (role) => {
router.push(`/chat?role_id=${role.id}`)
+408 -112
View File
@@ -18,59 +18,33 @@
<Waterfall
v-if="imgType === 'mj'"
id="waterfall-mj"
v-bind="galleryWaterfallBind"
:list="data['mj']"
: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"
class="gallery-wall-card image-task-item bg-gray-900 shadow-md overflow-hidden transition-all duration-300 ease-linear hover:shadow-md hover:shadow-purple-800"
>
<div class="overflow-hidden rounded-lg">
<div class="image-task-preview overflow-hidden">
<LazyImg
:url="url"
class="cursor-pointer transition-all duration-300 ease-linear group-hover:scale-105"
class="image-task-image cursor-pointer transition-transform duration-300 ease-linear"
@click="previewImg(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="drawSameMj(item)">
<i class="iconfont icon-palette"></i>
</el-button>
</el-tooltip>
</div>
<div class="image-task-overlay">
<div class="image-task-overlay-time">
{{ dateFormat(item.created_at) }}
</div>
<div class="image-task-tools">
<el-tooltip content="任务详情" placement="top">
<button type="button" class="image-task-tool" @click.stop="openDetail(item)">
<i class="iconfont icon-info text-[#6366f1]"></i>
</button>
</el-tooltip>
</div>
</div>
</div>
@@ -80,53 +54,33 @@
<Waterfall
v-if="imgType === 'image'"
id="waterfall-image"
v-bind="galleryWaterfallBind"
:list="data['image']"
: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"
class="gallery-wall-card image-task-item bg-gray-900 shadow-md overflow-hidden transition-all duration-300 ease-linear hover:shadow-md hover:shadow-purple-800"
>
<div class="overflow-hidden rounded-lg">
<div class="image-task-preview overflow-hidden">
<LazyImg
:url="url"
class="cursor-pointer transition-all duration-300 ease-linear group-hover:scale-105"
class="image-task-image cursor-pointer transition-transform duration-300 ease-linear"
@click="previewImg(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>
</div>
<div class="image-task-overlay">
<div class="image-task-overlay-time">
{{ dateFormat(item.created_at) }}
</div>
<div class="image-task-tools">
<el-tooltip content="任务详情" placement="top">
<button type="button" class="image-task-tool" @click.stop="openDetail(item)">
<i class="iconfont icon-info text-[#6366f1]"></i>
</button>
</el-tooltip>
</div>
</div>
</div>
@@ -156,18 +110,196 @@
<back-top :right="30" :bottom="30" />
</div>
<!-- end of waterfall -->
</div>
<!-- 图片预览 -->
<el-image-viewer
@close="
() => {
previewURL = ''
}
"
v-if="previewURL !== ''"
:url-list="[previewURL]"
/>
<el-image-viewer @close="closePreview" v-if="previewURL !== ''" :url-list="[previewURL]" />
<el-dialog
v-model="detailDialogVisible"
title="任务详情"
:width="detailKind === 'mj' ? '680px' : '600px'"
:class="['gallery-detail-dialog', { 'mj-detail-dialog': detailKind === 'mj' }]"
:close-on-click-modal="false"
>
<div
v-if="detailKind === 'mj' && currentDetail"
class="gallery-mj-detail-body detail-content"
>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="任务 ID">
<div class="mj-detail-copy-row">
<span class="break-all font-mono text-[13px]">{{
currentDetail.task_id || '-'
}}</span>
<el-tooltip v-if="currentDetail.task_id" content="复制" placement="top">
<i
class="iconfont icon-copy mj-detail-copy-ico"
@click="copyText(currentDetail.task_id)"
/>
</el-tooltip>
</div>
</el-descriptions-item>
<el-descriptions-item label="任务类型">
{{ mjDetailTypeLabel(currentDetail.type) }}
</el-descriptions-item>
<el-descriptions-item label="状态">
{{ currentDetail.status || '-' }}
<span
v-if="currentDetail.progress != null && currentDetail.status !== 'success'"
class="text-gray-500 ml-1"
>
({{ currentDetail.progress }}%)
</span>
</el-descriptions-item>
<el-descriptions-item label="原始提示词" v-if="detailTaskPayload?.prompt">
<div class="mj-detail-copy-row">
<span class="break-all mj-detail-text">{{ detailTaskPayload.prompt }}</span>
<el-tooltip content="复制" placement="top">
<i
class="iconfont icon-copy mj-detail-copy-ico"
@click="copyText(detailTaskPayload.prompt)"
/>
</el-tooltip>
</div>
</el-descriptions-item>
<el-descriptions-item label="负面提示词" v-if="detailTaskPayload?.neg_prompt">
<span class="break-all mj-detail-text">{{ detailTaskPayload.neg_prompt }}</span>
</el-descriptions-item>
<el-descriptions-item label="完整提示词">
<div class="mj-detail-copy-row align-start">
<span class="break-all mj-detail-text">{{ currentDetail.prompt || '—' }}</span>
<el-tooltip v-if="currentDetail.prompt" content="复制" placement="top">
<i
class="iconfont icon-copy mj-detail-copy-ico"
@click="copyText(currentDetail.prompt)"
/>
</el-tooltip>
</div>
</el-descriptions-item>
<el-descriptions-item label="引用图片" v-if="detailTaskImages.length > 0">
<div class="mj-detail-refimgs">
<el-image
v-for="(u, idx) in detailTaskImages"
:key="'ref-' + idx"
:src="u"
:preview-src-list="detailTaskImages"
fit="cover"
class="mj-detail-refimg"
preview-teleported
/>
</div>
</el-descriptions-item>
<el-descriptions-item label="局部重绘" v-if="detailHasMask">
<el-text type="info">已提交蒙版内容略</el-text>
</el-descriptions-item>
<el-descriptions-item
label="生成结果"
v-if="currentDetail.status === 'success' && currentDetail.img_url"
>
<el-image
:src="getThumbURL(currentDetail.img_url, 240, 240)"
:preview-src-list="[currentDetail.img_url]"
fit="cover"
class="mj-detail-result"
preview-teleported
/>
</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.status === 'failed' && currentDetail.err_msg"
>
<el-text type="danger">{{ currentDetail.err_msg }}</el-text>
</el-descriptions-item>
<el-descriptions-item
label="原始载荷"
v-if="currentDetail.task_info && !detailTaskPayload"
>
<span class="text-gray-500 text-xs">无法解析 JSON以下为原始文本</span>
<pre class="mj-detail-raw">{{ currentDetail.task_info }}</pre>
</el-descriptions-item>
</el-descriptions>
</div>
<div v-else-if="detailKind === 'image' && currentDetail" class="detail-content">
<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"
@click="copyText(currentDetail.prompt)"
/>
</el-tooltip>
</div>
</el-descriptions-item>
<el-descriptions-item
label="生成的图片"
v-if="currentDetail.status === 'success' && 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"
preview-teleported
/>
</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"
preview-teleported
/>
</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.status === 'failed' && currentDetail.err_msg"
>
<el-text type="danger">{{ currentDetail.err_msg }}</el-text>
</el-descriptions-item>
</el-descriptions>
</div>
</el-dialog>
</div>
</template>
@@ -175,24 +307,29 @@
import BackTop from '@/components/BackTop.vue'
import { useSharedStore } from '@/store/sharedata'
import { httpGet } from '@/utils/http'
import { getThumbURL } from '@/utils/libs'
import Clipboard from 'clipboard'
import { dateFormat, getThumbURL } from '@/utils/libs'
import { ElMessage } from 'element-plus'
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { computed, nextTick, ref } from 'vue'
import { LazyImg, Waterfall } from 'vue-waterfall-plugin-next'
import 'vue-waterfall-plugin-next/dist/style.css'
const store = useSharedStore()
const waterfallOptions = store.waterfallOptions
/** 作品墙:间距约 2px、无外围 gutter,视觉接近小红书信息流 */
const galleryWaterfallBind = computed(() => ({
...store.waterfallOptions,
gutter: 3,
hasAroundGutter: false,
}))
const data = ref({
mj: [],
image: [],
})
const loading = ref(true)
const isOver = ref(false)
const imgType = ref('mj') //
const imgType = ref('mj')
const listBoxHeight = window.innerHeight - 124
const previewURL = ref('')
@@ -200,9 +337,13 @@ const previewImg = (item) => {
previewURL.value = item.img_url
}
const closePreview = () => {
previewURL.value = ''
}
const page = ref(0)
const pageSize = ref(15)
//
const getNext = () => {
if (isOver.value) {
return
@@ -227,7 +368,6 @@ const getNext = () => {
return
}
//
const imageList = res.data.items
for (let i = 0; i < imageList.length; i++) {
imageList[i]['img_thumb'] = getThumbURL(imageList[i]['img_url'], 300, 0)
@@ -250,24 +390,7 @@ const getNext = () => {
getNext()
const clipboard = ref(null)
onMounted(() => {
clipboard.value = new Clipboard('.copy-prompt-wall')
clipboard.value.on('success', () => {
ElMessage.success('复制成功!')
})
clipboard.value.on('error', () => {
ElMessage.error('复制失败!')
})
})
onUnmounted(() => {
clipboard.value.destroy()
})
const changeImgType = () => {
console.log(imgType.value)
document.getElementById('waterfall-box').scrollTo(0, 0)
page.value = 0
data.value = {
@@ -279,13 +402,186 @@ const changeImgType = () => {
nextTick(() => getNext())
}
const router = useRouter()
const drawSameMj = (row) => {
router.push({ name: 'image-mj', params: { prompt: row.prompt } })
/** ---------- 任务详情(与 Image / ImageMj 对齐) ---------- */
const detailDialogVisible = ref(false)
const detailKind = ref('mj')
const currentDetail = ref(null)
function parseMjTaskInfo(raw) {
if (raw == null || raw === '') return null
if (typeof raw === 'object') return raw
if (typeof raw !== 'string') return null
try {
return JSON.parse(raw)
} catch {
return null
}
}
const detailTaskPayload = computed(() => {
if (detailKind.value !== 'mj' || !currentDetail.value) return null
return parseMjTaskInfo(currentDetail.value.task_info)
})
const detailTaskImages = computed(() => {
const arr = detailTaskPayload.value?.img_arr
return Array.isArray(arr) ? arr.filter((u) => u && String(u).trim()) : []
})
const detailHasMask = computed(() => {
const m = detailTaskPayload.value?.mask_base64
return typeof m === 'string' && m.length > 0
})
function mjDetailTypeLabel(type) {
const map = {
image: '绘图',
upscale: '放大',
variation: '变换',
blend: '融图',
swapFace: '换脸',
modal: '局部重绘',
}
return map[type] || type || '-'
}
const openDetail = (item) => {
detailKind.value = imgType.value
if (imgType.value === 'image') {
let params = {}
try {
if (item.params) {
params = typeof item.params === 'string' ? JSON.parse(item.params) : item.params
}
} catch (e) {
console.error('解析 params 失败:', e)
}
currentDetail.value = { ...item, params }
} else {
currentDetail.value = { ...item }
}
detailDialogVisible.value = true
}
const copyText = (text) => {
if (!text) return
navigator.clipboard
.writeText(text)
.then(() => {
ElMessage.success('复制成功!')
})
.catch(() => {
ElMessage.error('复制失败!')
})
}
</script>
<style lang="scss">
@use '../assets/css/images-wall.scss' as *;
@use '../assets/css/custom-scroll.scss' as *;
@use '../assets/css/image.scss' as *;
</style>
<!-- Dialog teleport body ImageMj 一致使用非 scoped 样式 -->
<style lang="scss">
.gallery-detail-dialog.mj-detail-dialog {
:deep(.el-dialog__body) {
padding-top: 6px;
}
}
.gallery-mj-detail-body {
max-height: min(72vh, 640px);
overflow-y: auto;
}
.mj-detail-copy-row {
display: flex;
align-items: center;
gap: 8px;
&.align-start {
align-items: flex-start;
.mj-detail-copy-ico {
margin-top: 4px;
}
}
}
.mj-detail-text {
font-size: 13px;
line-height: 1.55;
}
.mj-detail-copy-ico {
flex-shrink: 0;
cursor: pointer;
color: var(--el-text-color-secondary);
opacity: 0.85;
transition:
opacity 0.15s ease,
color 0.15s ease;
&:hover {
opacity: 1;
color: var(--el-color-primary);
}
}
.mj-detail-refimgs {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.mj-detail-refimg {
width: 88px;
height: 88px;
border-radius: 0;
overflow: hidden;
border: 1px solid var(--el-border-color-lighter);
}
.mj-detail-result {
width: 240px;
max-width: 100%;
height: 240px;
border-radius: 0;
overflow: hidden;
border: 1px solid var(--el-border-color-lighter);
}
.mj-detail-raw {
display: block;
margin-top: 8px;
padding: 8px 10px;
font-size: 11px;
line-height: 1.45;
max-height: 180px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
border-radius: 0;
background: var(--el-fill-color-light);
}
.page-images-wall .gallery-wall-card,
.page-images-wall .gallery-wall-card .image-task-preview {
border-radius: 0;
}
.page-images-wall .gallery-wall-card :deep(img),
.page-images-wall .gallery-wall-card :deep(.lazy__img) {
border-radius: 0 !important;
}
.gallery-detail-dialog :deep(.el-image__inner),
.gallery-detail-dialog :deep(.el-image__wrapper) {
border-radius: 0 !important;
}
.gallery-wall-card:hover .image-task-image {
transform: scale(1.05);
}
</style>
+5 -26
View File
@@ -76,7 +76,7 @@
</div>
</li>
<li>
<div @click="showMemberDialog = true" class="flex">
<div @click="goMember" class="flex">
<i class="iconfont icon-config"></i>
<span class="title">用户设置</span>
</div>
@@ -148,21 +148,6 @@
</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"
@@ -195,7 +180,6 @@
import LoginDialog from '@/components/LoginDialog.vue'
import ThemeChange from '@/components/ThemeChange.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'
@@ -216,7 +200,6 @@ const store = useSharedStore()
const loginUser = ref({})
const routerViewKey = ref(0)
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)
@@ -319,6 +302,10 @@ const init = () => {
.catch(() => {})
}
const goMember = () => {
router.push('/member')
}
const logout = function () {
httpGet('/api/user/logout')
.then(() => {
@@ -345,7 +332,6 @@ const loginSuccess = () => {
<style lang="scss">
.powerlog-dialog-content,
.member-dialog-content,
.invitation-dialog-content {
max-height: calc(100vh - 150px);
overflow-y: auto;
@@ -360,13 +346,6 @@ const loginSuccess = () => {
}
}
.member-dialog-content {
.member-page {
min-height: auto;
padding: 0;
}
}
.invitation-dialog-content {
.page-invitation {
.inner {
+113 -148
View File
@@ -147,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>立即生成({{ imagePower }}算力)</span>
<span v-else>立即生成({{ imagePower }}积分)</span>
</button>
</div>
</div>
@@ -157,118 +157,93 @@
<h2 class="text-xl">任务列表</h2>
<task-list :list="runningJobs" />
<template v-if="finishedJobs.length > 0">
<h2 class="text-xl">创作记录</h2>
<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"
v-bind="imageWaterfallOptions"
:is-loading="loading"
:is-over="isOver"
@afterRender="loading = false"
:lazyload="true"
@afterRender="onWaterfallAfterRender"
>
<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">
<div class="image-task-item">
<div
class="image-task-preview"
:class="{ 'image-task-preview--failed': item.status === 'failed' }"
>
<LazyImg
:url="url"
v-if="item.progress === 100"
class="cursor-pointer transition-all duration-300 ease-linear group-hover:scale-105"
v-if="item.status === 'success'"
class="image-task-image"
@click="previewImg(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>
<img
v-else-if="item.status === 'failed'"
class="image-task-image image-task-image--failed"
:src="taskFailedImage"
title="点击查看详情"
@click="showDetail(item)"
/>
</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>
<div class="image-task-overlay">
<div class="image-task-overlay-time">
{{ dateFormat(item.created_at) }}
</div>
<div class="image-task-tools">
<el-tooltip
content="取消分享"
placement="top"
v-if="item.status === 'success' && item.publish"
>
<button
type="button"
class="image-task-tool"
@click="publishImage(item, false)"
>
<i class="iconfont icon-cancel-share"></i>
</button>
</el-tooltip>
<el-tooltip content="分享" placement="top" v-else-if="item.status === 'success'">
<button
type="button"
class="image-task-tool"
@click="publishImage(item, true)"
>
<i class="iconfont icon-share-bold"></i>
</button>
</el-tooltip>
<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">
<el-button
type="danger"
:icon="Delete"
@click="removeImage(item)"
circle
/>
</el-tooltip>
<el-tooltip content="详情" placement="top">
<button
type="button"
class="image-task-tool"
@click="showDetail(item)"
>
<i class="iconfont icon-info text-[#6366f1]"></i>
</button>
</el-tooltip>
<el-tooltip content="删除" placement="top">
<button
type="button"
class="image-task-tool image-task-tool--danger"
@click="removeImage(item)"
>
<i class="iconfont icon-remove"></i>
</button>
</el-tooltip>
<el-tooltip content="下载" placement="top">
<el-button
type="primary"
circle
:icon="Download"
@click="downloadImage(item)"
:loading="item.downloading"
/>
</el-tooltip>
</div>
<el-tooltip content="下载" placement="top" v-if="item.status === 'success'">
<button
type="button"
class="image-task-tool"
@click="downloadImage(item)"
>
<i class="iconfont icon-download"></i>
</button>
</el-tooltip>
</div>
</div>
</div>
@@ -277,19 +252,12 @@
<div class="flex justify-center py-10">
<img
:src="waterfallOptions.loadProps.loading"
:src="imageWaterfallOptions.loadProps.loading"
class="max-w-[50px] max-h-[50px]"
v-if="loading"
v-if="!waterfallRendered"
/>
<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>
<div class="no-more-data" v-if="isOver">
<span class="text-gray-500 mr-2">没有更多数据了</span>
<i class="iconfont icon-face"></i>
</div>
@@ -341,7 +309,7 @@
<el-descriptions-item
label="生成的图片"
v-if="currentDetail.progress === 100 && currentDetail.img_url"
v-if="currentDetail.status === 'success' && currentDetail.img_url"
>
<el-image
:src="getThumbURL(currentDetail.img_url, 200, 200)"
@@ -372,7 +340,7 @@
{{ currentDetail.params?.model_name || '-' }}
</el-descriptions-item>
<el-descriptions-item label="消耗算力">
<el-descriptions-item label="消耗积分">
{{ currentDetail.power || 0 }}
</el-descriptions-item>
@@ -390,11 +358,14 @@
<el-descriptions-item
label="错误信息"
v-if="currentDetail.progress === 101 && currentDetail.err_msg"
v-if="currentDetail.status === 'failed' && currentDetail.err_msg"
>
<el-text type="danger">{{ currentDetail.err_msg }}</el-text>
</el-descriptions-item>
</el-descriptions>
<div v-if="currentDetail.status === 'failed'" class="image-detail-failed-actions">
<el-button type="danger" @click="removeImageFromDetail">删除此任务</el-button>
</div>
</div>
</el-dialog>
</div>
@@ -413,23 +384,28 @@ 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 { 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 isLogin = ref(false)
const loading = ref(true)
const isOver = ref(false)
const previewURL = ref('')
const store = useSharedStore()
const models = ref([])
const waterfallOptions = store.waterfallOptions
const imageWaterfallOptions = computed(() => ({
...waterfallOptions,
gutter: 2,
hasAroundGutter: false,
}))
const waterfallRendered = ref(false)
const resizeElement = function () {
listBoxHeight.value = window.innerHeight - 58
}
const taskFailedImage = store.taskFailedImage
resizeElement()
window.onresize = () => {
@@ -537,7 +513,6 @@ const initData = () => {
.then((user) => {
userPower.value = user['power']
userId.value = user.id
isLogin.value = true
page.value = 0
fetchRunningJobs()
fetchFinishJobs()
@@ -561,9 +536,6 @@ const initData = () => {
}
const fetchRunningJobs = () => {
if (!isLogin.value) {
return
}
//
httpGet(`/api/image/jobs?finish=false`)
.then((res) => {
@@ -588,11 +560,8 @@ const page = ref(1)
const pageSize = ref(15)
//
const fetchFinishJobs = () => {
if (!isLogin.value) {
return
}
loading.value = true
waterfallRendered.value = false
page.value = page.value + 1
httpGet(`/api/image/jobs?finish=true&page=${page.value}&page_size=${pageSize.value}`)
@@ -606,7 +575,7 @@ const fetchFinishJobs = () => {
for (let i = 0; i < imageList.length; i++) {
if (imageList[i]['img_url']) {
imageList[i]['img_thumb'] = getThumbURL(imageList[i]['img_url'], 300, 0)
} else if (imageList[i].progress === 100) {
} else if (imageList[i].status === 'downloading') {
needPulling = true
imageList[i]['img_thumb'] = waterfallOptions.loadProps.loading
}
@@ -621,6 +590,7 @@ const fetchFinishJobs = () => {
} else {
finishedJobs.value = finishedJobs.value.concat(imageList)
}
loading.value = false
})
.catch((e) => {
ElMessage.error('获取任务失败:' + e.message)
@@ -628,6 +598,14 @@ const fetchFinishJobs = () => {
})
}
const onWaterfallAfterRender = () => {
waterfallRendered.value = true
loading.value = false
if (!isOver.value) {
fetchFinishJobs()
}
}
const isGenerating = ref(false)
const generate = () => {
if (isGenerating.value) {
@@ -637,11 +615,6 @@ const generate = () => {
return ElMessage.error('请输入绘画提示词!')
}
if (!isLogin.value) {
store.setShowLoginDialog(true)
return
}
if (!params.value.size) {
return ElMessage.error('请选择或填写图片尺寸!')
}
@@ -667,6 +640,7 @@ const generate = () => {
//
runningJobs.value.push({
prompt: params.value.prompt,
status: 'pending',
progress: 0,
})
allowPulling.value = true
@@ -680,7 +654,8 @@ const generate = () => {
})
}
const removeImage = (item) => {
const removeImage = (item, options = {}) => {
const { closeDetailAfterRemove = false } = options
ElMessageBox.confirm('此操作将会删除任务和图片,继续操作码?', '删除提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
@@ -690,6 +665,9 @@ const removeImage = (item) => {
httpGet('/api/image/remove', { id: item.id })
.then(() => {
ElMessage.success('任务删除成功')
if (closeDetailAfterRemove) {
detailDialogVisible.value = false
}
page.value = 0
isOver.value = false
fetchFinishJobs()
@@ -701,6 +679,13 @@ const removeImage = (item) => {
.catch(() => {})
}
const removeImageFromDetail = () => {
if (!currentDetail.value) {
return
}
removeImage(currentDetail.value, { closeDetailAfterRemove: true })
}
const previewImg = (item) => {
previewURL.value = item.img_url
}
@@ -772,29 +757,9 @@ const showDetail = (item) => {
}
detailDialogVisible.value = true
}
</script>
<style lang="scss" scoped>
@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>
+572 -424
View File
File diff suppressed because it is too large Load Diff
+376 -3
View File
@@ -233,6 +233,7 @@
<div class="task-preview">
<el-image
v-if="item.img_url"
:key="`${item.id}-${item.status}-${item.img_url}`"
:src="item.img_url"
:preview-src-list="[item.img_url]"
:preview-teleported="true"
@@ -247,6 +248,7 @@
</el-image>
<div v-else-if="item.video_url" class="w-full h-full preview-video-wrapper">
<video
:key="`${item.id}-${item.status}-${item.video_url}`"
:src="item.video_url"
preload="auto"
loop="loop"
@@ -274,7 +276,11 @@
</span>
</div>
<div
v-else-if="item.status === 'generating'"
v-else-if="
item.status === 'generating' ||
item.status === 'submited' ||
item.status === 'done'
"
class="flex flex-col items-center gap-1"
>
<span>
@@ -321,6 +327,15 @@
</el-tooltip>
</span>
<span class="ml-1">
<el-tooltip content="任务详情" placement="top">
<i
class="iconfont icon-info cursor-pointer text-[#6366f1]"
@click="showTaskDetail(item)"
></i>
</el-tooltip>
</span>
<template v-if="item.status === 'failed'">
<span class="ml-1" v-if="item.status === 'failed'">
<el-tooltip content="重试" placement="top">
@@ -400,6 +415,132 @@
</video>
</div>
</el-dialog>
<!-- 任务详情参考 Image.vue -->
<el-dialog
v-model="detailDialogVisible"
title="任务详情"
width="680px"
class="jimeng-detail-dialog"
:close-on-click-modal="false"
destroy-on-close
>
<div class="detail-content" v-if="currentDetail">
<el-descriptions :column="1" border>
<el-descriptions-item label="任务类型">
<el-tag size="small" :type="store.getTaskType(currentDetail.type)">
{{ store.getFunctionName(currentDetail.type) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="模型 / ReqKey">
{{ currentDetail.req_key || '-' }}
</el-descriptions-item>
<el-descriptions-item label="状态">
{{ store.getTaskStatusText(currentDetail.status) }}
</el-descriptions-item>
<el-descriptions-item label="任务 ID">
{{ currentDetail.task_id || '-' }}
</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="提示词">
<div class="prompt-with-copy">
<span class="break-all">{{ currentDetail.prompt || '(无)' }}</span>
<el-tooltip v-if="currentDetail.prompt" content="复制提示词" placement="top">
<i
class="iconfont icon-copy ml-2 cursor-pointer shrink-0"
@click="copyPrompt(currentDetail.prompt)"
/>
</el-tooltip>
</div>
</el-descriptions-item>
<el-descriptions-item
v-if="currentDetail.err_msg && String(currentDetail.err_msg).trim()"
label="错误信息"
>
<el-text type="danger" class="break-all">{{ currentDetail.err_msg }}</el-text>
</el-descriptions-item>
</el-descriptions>
<template v-if="detailUploadImages.length">
<h4 class="detail-section-title">上传的图片</h4>
<div class="detail-media-grid detail-media-grid--image">
<el-image
v-for="(url, idx) in detailUploadImages"
:key="'uimg-' + idx"
:src="getThumbURL(url, 160, 160)"
:preview-src-list="detailUploadImages"
:initial-index="idx"
fit="cover"
class="detail-thumb"
/>
</div>
</template>
<template v-if="detailUploadVideos.length">
<h4 class="detail-section-title">上传的视频</h4>
<div class="detail-media-stack">
<video
v-for="(url, idx) in detailUploadVideos"
:key="'uvid-' + idx"
:src="url"
controls
preload="metadata"
class="detail-video-preview"
>
您的浏览器不支持视频播放
</video>
</div>
</template>
<template v-if="detailUploadAudios.length">
<h4 class="detail-section-title">上传的音频</h4>
<div class="detail-media-stack">
<audio
v-for="(url, idx) in detailUploadAudios"
:key="'uaud-' + idx"
:src="url"
controls
class="w-full"
/>
</div>
</template>
<template v-if="detailResultImage || detailResultVideo">
<h4 class="detail-section-title">生成结果</h4>
<div v-if="detailResultImage" class="detail-media-grid detail-media-grid--image mb-3">
<el-image
:src="getThumbURL(detailResultImage, 220, 220)"
:preview-src-list="[detailResultImage]"
fit="cover"
class="detail-thumb detail-thumb--large"
/>
</div>
<div v-if="detailResultVideo" class="detail-media-stack">
<video
:src="detailResultVideo"
controls
preload="metadata"
class="detail-video-preview detail-video-preview--large"
>
您的浏览器不支持视频播放
</video>
</div>
</template>
<template v-if="detailOtherParamsText">
<el-collapse class="detail-params-collapse">
<el-collapse-item title="其他请求参数(JSON" name="params">
<pre class="detail-json">{{ detailOtherParamsText }}</pre>
</el-collapse-item>
</el-collapse>
</template>
</div>
</el-dialog>
</div>
</template>
@@ -410,9 +551,9 @@ import ParamBuilder from '@/components/ParamBuilder.vue'
import Generating from '@/components/ui/Generating.vue'
import { useJimengStore } from '@/store/jimeng'
import { useSharedStore } from '@/store/sharedata'
import { dateFormat } from '@/utils/libs'
import { dateFormat, getThumbURL, replaceImg } from '@/utils/libs'
import { ElMessage } from 'element-plus'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { Waterfall } from 'vue-waterfall-plugin-next'
import 'vue-waterfall-plugin-next/dist/style.css'
@@ -430,6 +571,167 @@ const templatePreview = ref('')
const guideActive = ref([])
const videoPreviewRef = ref(null)
function toUrlList(v) {
if (v == null) {
return []
}
if (Array.isArray(v)) {
return v.map((x) => (typeof x === 'string' ? x.trim() : x)).filter(Boolean)
}
if (typeof v === 'string' && v.trim()) {
return [v.trim()]
}
return []
}
// Seedance content { url: "..." }
function pickRefUrl(ref) {
if (!ref || typeof ref !== 'object') {
return ''
}
const url = ref.url
return typeof url === 'string' && url.trim() ? url.trim() : ''
}
function collectContentUrls(params, type) {
if (!params || !Array.isArray(params.content)) {
return []
}
const out = []
for (const row of params.content) {
if (!row || typeof row !== 'object' || row.type !== type) {
continue
}
if (type === 'image_url') {
const u = pickRefUrl(row.image_url)
if (u) {
out.push(u)
}
} else if (type === 'video_url') {
const u = pickRefUrl(row.video_url)
if (u) {
out.push(u)
}
} else if (type === 'audio_url') {
const u = pickRefUrl(row.audio_url)
if (u) {
out.push(u)
}
}
}
return out
}
function uniqUrls(urls) {
return [...new Set(urls.filter(Boolean))]
}
// params Image.vue
function parseJobParams(item) {
let params = {}
try {
if (item.params) {
if (typeof item.params === 'string') {
params = JSON.parse(item.params)
} else if (typeof item.params === 'object') {
params = { ...item.params }
}
}
} catch (e) {
console.error('解析即梦任务 params 失败:', e)
}
return params
}
const detailDialogVisible = ref(false)
const currentDetail = ref(null)
const detailParsedParams = computed(() => {
const row = currentDetail.value
if (!row) {
return {}
}
return parseJobParams(row)
})
const detailUploadImages = computed(() => {
const p = detailParsedParams.value
const fromFields = toUrlList(p.image_urls)
const fromContent = collectContentUrls(p, 'image_url')
return uniqUrls([...fromFields, ...fromContent].map((u) => replaceImg(u)))
})
const detailUploadVideos = computed(() => {
const p = detailParsedParams.value
const fromFields = toUrlList(p.video_url)
const fromContent = collectContentUrls(p, 'video_url')
return uniqUrls([...fromFields, ...fromContent].map((u) => replaceImg(u)))
})
const detailUploadAudios = computed(() => {
const p = detailParsedParams.value
const fromFields = toUrlList(p.audio_url)
const fromContent = collectContentUrls(p, 'audio_url')
return uniqUrls([...fromFields, ...fromContent].map((u) => replaceImg(u)))
})
const detailResultImage = computed(() => {
const row = currentDetail.value
if (!row || row.status !== 'success' || !row.img_url) {
return ''
}
return replaceImg(row.img_url)
})
const detailResultVideo = computed(() => {
const row = currentDetail.value
if (!row || row.status !== 'success' || !row.video_url) {
return ''
}
return replaceImg(row.video_url)
})
const PARAM_SKIP_KEYS = new Set([
'prompt',
'image_urls',
'video_url',
'audio_url',
'content',
'type',
'req_key',
'action',
'power',
])
const detailOtherParamsText = computed(() => {
const p = detailParsedParams.value
const rest = {}
for (const k of Object.keys(p)) {
if (PARAM_SKIP_KEYS.has(k)) {
continue
}
rest[k] = p[k]
}
if (Object.keys(rest).length === 0) {
return ''
}
try {
return JSON.stringify(rest, null, 2)
} catch {
return ''
}
})
function showTaskDetail(item) {
const params = parseJobParams(item)
currentDetail.value = {
...item,
params,
}
detailDialogVisible.value = true
}
//
const playVideo = (item) => {
store.currentVideoUrl = item.video_url
@@ -505,4 +807,75 @@ function copyErrorMsg(msg) {
<style lang="scss" scoped>
@use '@/assets/css/jimeng.scss' as *;
.detail-content {
:deep(.el-descriptions__label) {
min-width: 112px;
}
}
.detail-section-title {
margin: 16px 0 8px;
font-size: 15px;
font-weight: 600;
color: var(--text-theme-color, #252f76);
}
.prompt-with-copy {
display: flex;
align-items: flex-start;
gap: 4px;
}
.detail-media-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.detail-media-grid--image .detail-thumb {
width: 120px;
height: 120px;
border-radius: 8px;
overflow: hidden;
}
.detail-thumb--large {
width: 200px;
height: 200px;
}
.detail-media-stack {
display: flex;
flex-direction: column;
gap: 12px;
}
.detail-video-preview {
max-width: 100%;
max-height: 220px;
border-radius: 8px;
background: #000;
}
.detail-video-preview--large {
max-height: 360px;
}
.detail-params-collapse {
margin-top: 16px;
}
.detail-json {
margin: 0;
padding: 10px 12px;
font-size: 12px;
line-height: 1.45;
max-height: 240px;
overflow: auto;
border-radius: 8px;
background: var(--el-fill-color-light, #f5f7fa);
white-space: pre-wrap;
word-break: break-all;
}
</style>
+1 -1
View File
@@ -81,7 +81,7 @@ const handleChangeActive = (newValue) => {
onMounted(() => {
//
if (loginDialogRef.value) {
loginDialogRef.value.login = !isRegister
loginDialogRef.value.login = !isRegister.value
}
if (token.value) {
+50 -17
View File
@@ -167,23 +167,38 @@
<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 class="grid grid-cols-2 gap-2">
<span>
<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>
</span>
<span>
<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>
</span>
<span class="col-span-2">
<el-button
v-if="stripePayEnabled"
class="h-10 w-full text-[0.8125rem] font-medium"
color="#f59e0b"
@click="stripePay(item)"
>
<i class="iconfont icon-reward mr-1"></i>
<span>Stripe</span>
</el-button>
</span>
</div>
</div>
</div>
@@ -322,6 +337,7 @@ const usernameInput = ref('')
const user = ref(null)
const isLogin = ref(false)
const orderTimeout = ref(1800)
const stripePayEnabled = ref(false)
const loading = ref(true)
const loadingText = ref('加载中...')
@@ -387,6 +403,7 @@ onMounted(() => {
if (res.data['order_pay_timeout'] > 0) {
orderTimeout.value = res.data['order_pay_timeout']
}
stripePayEnabled.value = !!res.data['stripe_pay_enabled']
})
.catch((e) => {
ElMessage.error('获取系统配置失败:' + e.message)
@@ -485,6 +502,17 @@ const alipay = (product) => {
generateOrder('alipay')
}
const stripePay = (product) => {
if (!isLogin.value) {
store.setShowLoginDialog(true)
return
}
selectedPid.value = product.id
title.value = 'Stripe Checkout'
generateOrder('stripe')
}
const generateOrder = (payWay) => {
showLoading('正在生成支付订单...')
//
@@ -497,6 +525,11 @@ const generateOrder = (payWay) => {
.then((res) => {
closeLoading()
if (payWay === 'stripe') {
window.location.href = res.data.pay_url
return
}
if (isMobile()) {
window.location.href = res.data.pay_url
} else {
+162 -29
View File
@@ -30,13 +30,31 @@
</template>
</el-popover>
</div>
<el-select v-model="store.data.model" placeholder="请选择模型" class="w-full">
<el-select
v-model="store.data.model"
placeholder="请选择模型"
class="suno-model-select w-full"
fit-input-width
popper-class="suno-model-select__popper"
>
<el-option
v-for="model in store.models"
:key="model.value"
:label="model.label"
:value="model.value"
/>
>
<div class="suno-model-option">
<div aria-hidden="true">
<i class="iconfont icon-suno !text-2xl"></i>
</div>
<div class="suno-model-option__body">
<div class="suno-model-option__title">
Suno {{ model.label }} - {{ model.value }}
</div>
<div class="suno-model-option__hint" :title="model.hint">{{ model.hint }}</div>
</div>
</div>
</el-option>
</el-select>
</div>
@@ -210,7 +228,7 @@
<i v-if="store.loading" class="iconfont icon-loading animate-spin"></i>
<i v-else class="iconfont icon-chuangzuo"></i>
<span
>{{ store.loading ? '创作中...' : store.btnText }} ({{ store.sunoPower }}算力)</span
>{{ store.loading ? '创作中...' : store.btnText }} ({{ store.sunoPower }}积分)</span
>
</button>
</div>
@@ -289,15 +307,15 @@
</template>
</el-image>
<!-- 音乐播放按钮 -->
<button v-if="item.progress === 100" @click="play(item)" class="play-overlay">
<button v-if="item.status === 'success'" @click="play(item)" class="play-overlay">
<i class="iconfont icon-play text-white text-xl"></i>
</button>
<!-- 进度动画 -->
<div v-if="item.progress < 100 && item.progress !== 101" class="progress-overlay">
<div v-if="item.status === 'pending' || item.status === 'in_progress' || item.status === 'downloading'" class="progress-overlay">
<i class="iconfont icon-loading animate-spin text-blue-500 text-xl"></i>
</div>
<!-- 失败状态 -->
<div v-if="item.progress === 101" class="error-overlay">
<div v-if="item.status === 'failed'" class="error-overlay">
<i class="iconfont icon-warning text-red-500 text-xl"></i>
</div>
</div>
@@ -315,8 +333,8 @@
</p>
</div>
<!-- 任务状态 -->
<div v-if="item.progress < 100" class="task-status">
<div v-if="item.progress === 101" class="status-error">
<div v-if="item.status !== 'success'" class="task-status">
<div v-if="item.status === 'failed'" class="status-error">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
@@ -351,15 +369,14 @@
</div>
</div>
<!-- 操作按钮 -->
<div class="song-actions">
<!-- 操作按钮仅生成成功时展示失败时仅保留删除 -->
<div v-if="item.status === 'success'" class="song-actions">
<div class="action-buttons">
<button v-if="item.progress === 100" @click="play(item)" class="action-btn play-btn">
<button @click="play(item)" class="action-btn play-btn">
<i class="iconfont icon-play text-xs"></i>
<span>播放</span>
</button>
<button
v-if="item.progress === 100"
@click="store.download(item)"
:disabled="item.downloading"
class="action-btn download-btn"
@@ -387,29 +404,17 @@
<i v-else class="iconfont icon-download text-xs"></i>
<span>{{ item.downloading ? '下载中...' : '下载' }}</span>
</button>
<button
v-if="item.progress === 100 && item.ref_song"
@click="store.merge(item)"
class="action-btn merge-btn"
>
<button v-if="item.ref_song" @click="store.merge(item)" class="action-btn merge-btn">
<i class="iconfont icon-concat text-xs"></i>
<span>合并</span>
</button>
<button
v-if="item.progress !== 101"
@click="store.extend(item)"
class="action-btn extend-btn"
>
<button @click="store.extend(item)" class="action-btn extend-btn">
<i class="iconfont icon-edit text-xs"></i>
<span>续写</span>
</button>
</div>
<div class="action-buttons">
<button
v-if="item.progress !== 101"
@click="store.update(item)"
class="action-btn edit-btn"
>
<button @click="store.update(item)" class="action-btn edit-btn">
<i class="iconfont icon-edit text-xs"></i>
<span>编辑</span>
</button>
@@ -419,9 +424,17 @@
</button>
</div>
</div>
<div v-else-if="item.status === 'failed'" class="song-actions">
<div class="action-buttons ml-auto">
<button @click="store.removeJob(item)" class="action-btn delete-btn">
<i class="iconfont icon-remove text-xs"></i>
<span>删除</span>
</button>
</div>
</div>
<!-- 进度条 -->
<div v-if="item.progress < 100 && item.progress !== 101" class="progress-bar">
<div v-if="item.status === 'pending' || item.status === 'in_progress' || item.status === 'downloading'" class="progress-bar">
<div class="progress-info">
<span>生成进度</span>
<span>{{ item.progress }}%</span>
@@ -432,7 +445,7 @@
</div>
<!-- 错误信息 -->
<div v-if="item.progress === 101" class="error-message">
<div v-if="item.status === 'failed'" class="error-message">
<div class="flex items-start space-x-2">
<div>
<p class="error-text">{{ item.err_msg || '未知错误' }}</p>
@@ -570,3 +583,123 @@ onUnmounted(() => {
<style lang="scss" scoped>
@use '@/assets/css/suno.scss';
</style>
<!-- 下拉 teleport body Element 内部结构用纯 CSS 覆盖 -->
<style lang="scss">
.suno-model-select__popper {
margin-top: 6px !important;
border: 1px solid var(--el-border-color-lighter);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.1);
/* 插槽外层 span 默认单行高,强制放开避免行与行叠字 */
.el-select-dropdown__item > span {
display: block !important;
height: auto !important;
width: 100%;
min-height: 0 !important;
line-height: normal !important;
white-space: normal !important;
}
.el-select-dropdown__item {
display: flex;
align-items: flex-start;
height: auto !important;
min-height: 56px;
margin: 2px 6px;
padding: 8px 10px !important;
line-height: normal !important;
white-space: normal !important;
border-radius: 10px;
transition: background-color 0.15s ease;
&:hover {
background-color: rgba(37, 99, 235, 0.07) !important;
}
&.is-selected {
background-color: rgba(37, 99, 235, 0.1) !important;
.suno-model-option__title {
color: var(--el-color-primary);
}
}
}
.suno-model-option {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
min-width: 0;
}
.suno-model-option__badge {
position: relative;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
overflow: hidden;
border-radius: 10px;
background: linear-gradient(155deg, #8b5cf6 0%, #6366f1 42%, #4f46e5 100%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.38),
0 2px 8px rgba(79, 70, 229, 0.45);
}
.suno-model-option__badge::before {
content: '';
position: absolute;
inset: 0 0 auto 0;
height: 52%;
border-radius: 10px 10px 0 0;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.42), transparent);
pointer-events: none;
}
.suno-model-option__badge-text {
position: relative;
z-index: 1;
padding: 0 3px;
font-size: 13px;
font-weight: 800;
line-height: 1.05;
letter-spacing: -0.02em;
color: #fff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.28);
}
.suno-model-option__body {
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.suno-model-option__title {
font-size: 13px;
font-weight: 600;
line-height: 1.3;
color: var(--el-text-color-primary);
}
.suno-model-option__hint {
font-size: 12px;
line-height: 1.35;
color: var(--el-text-color-secondary);
}
.suno-model-option__title,
.suno-model-option__hint {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
</style>
+640 -117
View File
@@ -34,10 +34,10 @@
>
<div class="flex items-center space-x-2">
<el-icon color="#f59e42" size="20"><i class="iconfont icon-lightning"></i></el-icon>
<span class="font-medium text-gray-700">当前可用算力</span>
<span class="font-medium text-gray-700">当前可用积分</span>
<span class="font-bold text-lg text-yellow-500">{{ store.availablePower }}</span>
</div>
<el-tooltip content="算力用于生成视频,每次生成会消耗对应算力" placement="left">
<el-tooltip content="积分用于生成视频,每次生成会消耗对应积分" placement="left">
<el-icon color="#a78bfa" size="18"><InfoFilled /></el-icon>
</el-tooltip>
</div>
@@ -51,7 +51,7 @@
>
<i v-if="store.submitting" class="iconfont icon-loading animate-spin"></i>
<i v-else class="iconfont icon-chuangzuo"></i>
<span>立即生成 ({{ store.currentPowerCost }}算力)</span>
<span>立即生成 ({{ store.currentPowerCost }}积分)</span>
</button>
</div>
</div>
@@ -63,6 +63,11 @@
v-loading="store.loading"
element-loading-background="rgba(100,100,100,0.3)"
>
<div class="job-list-box px-2 pt-2 pb-2">
<h2 class="text-xl mb-1">任务列表</h2>
<task-list :list="videoRunningJobsForList" />
</div>
<div class="works-header">
<h2 class="h-title text-2xl">你的作品</h2>
<div class="filter-buttons">
@@ -89,139 +94,137 @@
<div class="video-list">
<div class="list-box" v-if="!store.noData">
<div v-for="item in store.currentList" :key="item.id">
<div class="item">
<div class="left">
<div class="container">
<div v-if="item.status === 'success'">
<Waterfall
:list="worksListForWaterfall"
v-bind="videoWaterfallOptions"
:is-loading="store.loading"
:is-over="store.isOver"
:lazyload="true"
@afterRender="onWaterfallAfterRender"
>
<template #default="{ item }">
<div class="video-task-item">
<div
class="video-task-preview"
:class="{
'video-task-preview--failed': item.status === 'failed',
'video-task-preview--busy':
item.status === 'downloading' ||
item.status === 'pending' ||
item.status === 'in_progress',
}"
@mouseenter="handleTaskPreviewEnter"
@mouseleave="handleTaskPreviewLeave"
>
<div
v-if="item.status === 'success'"
class="video-task-preview-inner video-task-preview-inner--success"
>
<video
class="video"
class="video-task-video"
:src="store.replaceImg(item.video_url)"
preload="auto"
loop="loop"
muted="muted"
preload="metadata"
loop
muted
playsinline
@loadedmetadata="handleTaskMediaReady"
@loadeddata="handleTaskMediaReady"
@error="handleTaskMediaReady"
@click.stop="store.playVideo(item)"
>
您的浏览器不支持视频播放
</video>
<button
class="play flex justify-center items-center"
@click="store.playVideo(item)"
>
<img src="/images/play.svg" alt="" />
</button>
</div>
<div
v-else-if="item.status === 'downloading'"
class="flex items-center justify-center"
style="height: 200px"
class="video-task-preview-inner video-task-preview-inner--busy"
>
<div class="text-center">
<div class="text-center px-2">
<div
class="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-600 mx-auto"
class="animate-spin rounded-full h-12 w-12 border-2 border-indigo-500 border-t-transparent mx-auto"
></div>
<span class="text-sm text-purple-600 mt-2 block">视频下载中...</span>
<span class="text-sm text-indigo-600 dark:text-indigo-300 mt-3 block">
视频下载中
</span>
</div>
</div>
<el-image
src="/images/failed.jpg"
class="border rounded-lg"
fit="cover"
<div
v-else-if="item.status === 'failed'"
/>
class="video-task-preview-inner video-task-preview-inner--failed"
>
<img class="video-task-fail-img" :src="taskFailedImage" alt="" />
</div>
<div
v-else-if="(item.progress || 0) > 0 && (item.progress || 0) < 100"
class="flex h-[120px] items-center justify-center"
class="video-task-preview-inner video-task-preview-inner--busy"
>
<el-progress
type="circle"
:percentage="item.progress || 0"
:width="80"
:width="96"
:stroke-width="6"
class="rounded-full bg-white/95 p-1 shadow-sm flex items-center justify-center"
>
<template #default="{ percentage }">
<span class="flex w-full justify-center text-base font-medium text-gray-700"
>{{ percentage }}%</span
>
<span class="text-base font-medium text-gray-700">{{ percentage }}%</span>
</template>
</el-progress>
</div>
<div class="flex !items-end justify-center h-[120px]" v-else>
<div v-else class="video-task-preview-inner video-task-preview-inner--busy">
<Generating message="正在生成视频" />
</div>
<div
class="absolute top-0 right-0"
v-if="item.status === 'pending' && !((item.progress || 0) > 0)"
>
<!-- in_progress 状态才显示 status 标签 -->
<el-tag type="info" class="mr-1"> 排队中 </el-tag>
<div class="video-task-overlay">
<div class="video-task-overlay-time">{{ dateFormat(item.created_at) }}</div>
<div class="video-task-tools">
<el-tooltip content="复制提示词" placement="top">
<button
type="button"
class="video-task-tool copy-prompt"
:data-clipboard-text="item.prompt"
>
<i class="iconfont icon-copy"></i>
</button>
</el-tooltip>
<el-tooltip content="任务详情(含完整提示词)" placement="top">
<button type="button" class="video-task-tool" @click="showTaskDetail(item)">
<i class="iconfont icon-info text-[#6366f1]"></i>
</button>
</el-tooltip>
<el-tooltip
v-if="item.status === 'success' && item.video_url"
content="下载视频"
placement="top"
>
<button
type="button"
class="video-task-tool"
:disabled="item.downloading"
@click="store.downloadVideo(item)"
>
<i v-if="!item.downloading" class="iconfont icon-download"></i>
<img
v-else
src="/images/loading.gif"
class="video-task-tool-loading"
alt=""
/>
</button>
</el-tooltip>
<el-tooltip content="删除任务" placement="top">
<button
type="button"
class="video-task-tool video-task-tool--danger"
@click="store.removeJob(item)"
>
<i class="iconfont icon-remove"></i>
</button>
</el-tooltip>
</div>
</div>
</div>
</div>
<div class="center">
<div class="pb-2">
<el-tag class="mr-1">{{ item.type }}</el-tag>
<template v-if="item.params">
<el-tag class="mr-1" v-if="item.params.task_type">{{
item.params.task_type
}}</el-tag>
<el-tag class="mr-1" v-if="item.params.model">{{ item.params.model }}</el-tag>
<el-tag class="mr-1" v-if="item.params.duration"
>{{ item.params.duration }}</el-tag
>
<el-tag class="mr-1" v-if="item.params.mode">{{ item.params.mode }}</el-tag>
<el-tag class="mr-1" v-if="item.params.size">
分辨率{{ item.params.size }}
</el-tag>
</template>
<el-tag class="mr-1" type="warning" v-if="item.power">
消耗算力{{ item.power }}
</el-tag>
</div>
<div class="failed" v-if="item.status === 'failed'">
任务执行失败{{ item.err_msg }}任务提示词{{ item.prompt }}
</div>
<div class="prompt" v-else>
{{ store.substr(item.prompt, 1000) }}
</div>
</div>
<div class="right" v-if="item.progress === 100">
<div class="tools">
<el-tooltip content="复制提示词" placement="top">
<button class="btn btn-icon copy-prompt" :data-clipboard-text="item.prompt">
<i class="iconfont icon-copy"></i>
</button>
</el-tooltip>
<el-tooltip content="下载视频" placement="top">
<button
class="btn btn-icon"
@click="store.downloadVideo(item)"
:disabled="item.downloading"
>
<i class="iconfont icon-download" v-if="!item.downloading"></i>
<el-image src="/images/loading.gif" class="downloading" fit="cover" v-else />
</button>
</el-tooltip>
<el-tooltip content="删除" placement="top">
<button class="btn btn-icon" @click="store.removeJob(item)">
<i class="iconfont icon-remove"></i>
</button>
</el-tooltip>
</div>
</div>
<div class="right-error" v-else>
<el-button type="danger" @click="store.removeJob(item)" circle>
<i class="iconfont icon-remove"></i>
</el-button>
</div>
</div>
</div>
</template>
</Waterfall>
</div>
<el-empty
@@ -231,18 +234,20 @@
v-else
/>
<div class="pagination">
<el-pagination
v-if="store.total > store.pageSize"
background
style="--el-pagination-button-bg-color: rgba(86, 86, 95, 0.2)"
layout="total,prev, pager, next"
:hide-on-single-page="true"
:current-page="store.page"
:page-size="store.pageSize"
@current-change="store.fetchData"
:total="store.total"
<div class="waterfall-load-more" v-if="!store.noData">
<img
:src="videoWaterfallOptions.loadProps.loading"
class="waterfall-loading-icon"
v-if="!waterfallRendered"
alt=""
/>
<div
v-if="waterfallRendered && store.isOver"
class="waterfall-no-more bg-[#f5f5f5] text-gray-500 rounded-md px-3 py-2 mt-3"
>
<span class="mr-2 text-base">没有更多数据了</span>
<i class="iconfont icon-face"></i>
</div>
</div>
</div>
</div>
@@ -266,19 +271,388 @@
您的浏览器不支持视频播放
</video>
</el-dialog>
<!-- 任务详情 -->
<el-dialog
v-model="detailDialogVisible"
title="任务详情"
width="680px"
:close-on-click-modal="false"
destroy-on-close
>
<div class="detail-content" v-if="currentDetail">
<el-descriptions :column="1" border>
<el-descriptions-item label="提供商">
{{ getProviderName(currentDetail.type) }}
</el-descriptions-item>
<el-descriptions-item v-if="currentDetail.channel" label="渠道">
{{ currentDetail.channel }}
</el-descriptions-item>
<el-descriptions-item label="状态">
{{ statusConfig[currentDetail.status]?.label || currentDetail.status }}
</el-descriptions-item>
<el-descriptions-item label="进度">
{{ currentDetail.progress ?? 0 }}%
</el-descriptions-item>
<el-descriptions-item label="任务 ID">
{{ currentDetail.task_id || '-' }}
</el-descriptions-item>
<el-descriptions-item v-if="detailFlatParams.model" label="模型">
{{ detailFlatParams.model }}
</el-descriptions-item>
<el-descriptions-item v-if="detailFlatParams.task_type" label="生成模式">
{{ detailFlatParams.task_type }}
</el-descriptions-item>
<el-descriptions-item
v-if="detailFlatParams.duration != null && detailFlatParams.duration !== ''"
label="时长"
>
{{ detailFlatParams.duration }}
</el-descriptions-item>
<el-descriptions-item
v-if="detailFlatParams.size || detailFlatParams.resolution"
label="分辨率"
>
{{ detailFlatParams.size || detailFlatParams.resolution }}
</el-descriptions-item>
<el-descriptions-item v-if="detailFlatParams.mode" label="模式">
{{ detailFlatParams.mode }}
</el-descriptions-item>
<el-descriptions-item
v-if="detailFlatParams.sound != null && detailFlatParams.sound !== ''"
label="声音"
>
{{ detailFlatParams.sound }}
</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="提示词">
<div class="prompt-with-copy">
<span class="break-all">{{ currentDetail.prompt || '(无)' }}</span>
<el-tooltip v-if="currentDetail.prompt" content="复制提示词" placement="top">
<button
type="button"
class="inline-btn"
@click="copyPromptText(currentDetail.prompt)"
>
<i class="iconfont icon-copy ml-2 cursor-pointer shrink-0" />
</button>
</el-tooltip>
</div>
</el-descriptions-item>
<el-descriptions-item
v-if="currentDetail.err_msg && String(currentDetail.err_msg).trim()"
label="错误信息"
>
<el-text type="danger" class="break-all">{{ currentDetail.err_msg }}</el-text>
</el-descriptions-item>
</el-descriptions>
<template v-if="detailUploadImages.length">
<h4 class="detail-section-title">参考 / 上传的图片</h4>
<div class="detail-media-grid detail-media-grid--image">
<el-image
v-for="(url, idx) in detailUploadImages"
:key="'uimg-' + idx"
:src="getThumbURL(url, 160, 160)"
:preview-src-list="detailUploadImages"
:initial-index="idx"
fit="cover"
class="detail-thumb"
/>
</div>
</template>
<template v-if="detailUploadVideos.length">
<h4 class="detail-section-title">参考 / 上传的视频</h4>
<div class="detail-media-stack">
<video
v-for="(url, idx) in detailUploadVideos"
:key="'uvid-' + idx"
:src="url"
controls
preload="metadata"
class="detail-video-preview"
>
您的浏览器不支持视频播放
</video>
</div>
</template>
<template v-if="detailUploadAudios.length">
<h4 class="detail-section-title">参考 / 上传的音频</h4>
<div class="detail-media-stack">
<audio
v-for="(url, idx) in detailUploadAudios"
:key="'uaud-' + idx"
:src="url"
controls
class="w-full"
/>
</div>
</template>
<template v-if="detailResultVideo">
<h4 class="detail-section-title">生成结果</h4>
<div class="detail-media-stack">
<video
:src="detailResultVideo"
controls
preload="metadata"
class="detail-video-preview detail-video-preview--large"
>
您的浏览器不支持视频播放
</video>
</div>
</template>
<template v-if="detailOtherParamsText">
<el-collapse class="detail-params-collapse">
<el-collapse-item title="其他请求参数(JSON" name="params">
<pre class="detail-json">{{ detailOtherParamsText }}</pre>
</el-collapse-item>
</el-collapse>
</template>
</div>
</el-dialog>
</div>
</template>
<script setup>
import ParamBuilder from '@/components/ParamBuilder.vue'
import TaskList from '@/components/TaskList.vue'
import Generating from '@/components/ui/Generating.vue'
import { useSharedStore } from '@/store/sharedata'
import { useVideoStore } from '@/store/video'
import { InfoFilled } from '@element-plus/icons-vue'
import { getProviderName } from '@/store/data/video_params'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { dateFormat, getThumbURL, replaceImg } from '@/utils/libs'
import { ElMessage } from 'element-plus'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { Waterfall } from 'vue-waterfall-plugin-next'
import 'vue-waterfall-plugin-next/dist/style.css'
const store = useVideoStore()
const sharedStore = useSharedStore()
const videoPlayerRef = ref(null)
const waterfallRendered = ref(false)
const waterfallOptions = sharedStore.waterfallOptions
const taskFailedImage = sharedStore.taskFailedImage
const videoWaterfallOptions = computed(() => ({
...waterfallOptions,
gutter: 2,
hasAroundGutter: false,
}))
const worksListForWaterfall = computed(() => {
return store.currentList
})
function isVideoTaskRunning(item) {
return item.status === 'pending' || item.status === 'in_progress'
}
/** TaskList:队首为执行中,其余为排队;按创建时间升序 */
const videoRunningJobsForList = computed(() => {
const rows = store.taskList.filter(isVideoTaskRunning)
return [...rows]
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
.map((row) => ({
id: row.id,
progress: row.progress ?? 0,
}))
})
const VIDEO_IMAGE_PARAM_KEYS = [
'image_urls',
'images',
'input_reference',
'image',
'image_tail',
'first_frame_image',
]
function toUrlList(v) {
if (v == null) {
return []
}
if (Array.isArray(v)) {
return v.map((x) => (typeof x === 'string' ? x.trim() : x)).filter(Boolean)
}
if (typeof v === 'string' && v.trim()) {
return [v.trim()]
}
return []
}
function pickRefUrl(ref) {
if (!ref || typeof ref !== 'object') {
return ''
}
const url = ref.url
return typeof url === 'string' && url.trim() ? url.trim() : ''
}
function collectContentUrls(params, type) {
if (!params || !Array.isArray(params.content)) {
return []
}
const out = []
for (const row of params.content) {
if (!row || typeof row !== 'object' || row.type !== type) {
continue
}
if (type === 'image_url') {
const u = pickRefUrl(row.image_url)
if (u) {
out.push(u)
}
} else if (type === 'video_url') {
const u = pickRefUrl(row.video_url)
if (u) {
out.push(u)
}
} else if (type === 'audio_url') {
const u = pickRefUrl(row.audio_url)
if (u) {
out.push(u)
}
}
}
return out
}
function uniqUrls(urls) {
return [...new Set(urls.filter(Boolean))]
}
function parseJobParams(item) {
let params = {}
try {
if (item.params) {
if (typeof item.params === 'string') {
params = JSON.parse(item.params)
} else if (typeof item.params === 'object') {
params = { ...item.params }
}
}
} catch (e) {
console.error('解析视频任务 params 失败:', e)
}
return params
}
// VideoTask JSON params
function mergeVideoTaskParams(parsed) {
if (!parsed || typeof parsed !== 'object') {
return {}
}
const inner = parsed.params
if (inner && typeof inner === 'object' && !Array.isArray(inner)) {
const { params: _nested, ...rest } = parsed
return { ...rest, ...inner }
}
return { ...parsed }
}
const detailDialogVisible = ref(false)
const currentDetail = ref(null)
const detailFlatParams = computed(() => {
const row = currentDetail.value
if (!row) {
return {}
}
return mergeVideoTaskParams(parseJobParams(row))
})
const detailUploadImages = computed(() => {
const p = detailFlatParams.value
const acc = []
for (const k of VIDEO_IMAGE_PARAM_KEYS) {
if (p[k] != null) {
acc.push(...toUrlList(p[k]))
}
}
acc.push(...collectContentUrls(p, 'image_url'))
return uniqUrls(acc.map((u) => replaceImg(u)))
})
const detailUploadVideos = computed(() => {
const p = detailFlatParams.value
const fromFields = toUrlList(p.video_url)
const fromContent = collectContentUrls(p, 'video_url')
return uniqUrls([...fromFields, ...fromContent].map((u) => replaceImg(u)))
})
const detailUploadAudios = computed(() => {
const p = detailFlatParams.value
const fromFields = toUrlList(p.audio_url)
const fromContent = collectContentUrls(p, 'audio_url')
return uniqUrls([...fromFields, ...fromContent].map((u) => replaceImg(u)))
})
const detailResultVideo = computed(() => {
const row = currentDetail.value
if (!row || row.status !== 'success' || !row.video_url) {
return ''
}
return replaceImg(row.video_url)
})
const PARAM_SHOWN_IN_DESCRIPTIONS = new Set([
'model',
'task_type',
'duration',
'size',
'mode',
'sound',
'resolution',
...VIDEO_IMAGE_PARAM_KEYS,
'prompt',
'video_url',
'audio_url',
'content',
'image_urls',
])
const detailOtherParamsText = computed(() => {
const p = detailFlatParams.value
const rest = {}
for (const k of Object.keys(p)) {
if (PARAM_SHOWN_IN_DESCRIPTIONS.has(k)) {
continue
}
rest[k] = p[k]
}
if (Object.keys(rest).length === 0) {
return ''
}
try {
return JSON.stringify(rest, null, 2)
} catch {
return ''
}
})
function showTaskDetail(item) {
currentDetail.value = { ...item }
detailDialogVisible.value = true
}
function copyPromptText(text) {
navigator.clipboard
.writeText(text)
.then(() => {
ElMessage.success('提示词已复制')
})
.catch(() => {
ElMessage.error('复制失败')
})
}
const providerOrder = computed(() =>
['sora', 'veo', 'doubao', 'keling', 'minimax', 'wan'].filter((p) => store.providers.includes(p))
@@ -305,10 +679,14 @@ const statusConfig = {
failed: { label: '失败', type: 'danger' },
}
function videoStatusTagType(status) {
return statusConfig[status]?.type || 'info'
}
//
const handlePriceParamsChange = () => {
// store watch setCurrentPowerCost
// setCurrentPowerCost API
// setCurrentPowerCost API
// watch currentPowerCost
}
@@ -321,11 +699,80 @@ const handleVideoDialogClose = () => {
store.showDialog = false
}
function handleTaskPreviewEnter(event) {
const target = event.currentTarget
if (!(target instanceof HTMLElement)) {
return
}
const videoElement = target.querySelector('.video-task-video')
if (!(videoElement instanceof HTMLVideoElement)) {
return
}
videoElement.muted = true
const playPromise = videoElement.play()
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(() => {})
}
}
function handleTaskPreviewLeave(event) {
const target = event.currentTarget
if (!(target instanceof HTMLElement)) {
return
}
const videoElement = target.querySelector('.video-task-video')
if (!(videoElement instanceof HTMLVideoElement)) {
return
}
videoElement.pause()
}
let taskMediaReadyRafId = 0
function handleTaskMediaReady() {
if (taskMediaReadyRafId) {
cancelAnimationFrame(taskMediaReadyRafId)
}
taskMediaReadyRafId = requestAnimationFrame(() => {
window.dispatchEvent(new Event('resize'))
taskMediaReadyRafId = 0
})
}
function onWaterfallAfterRender() {
waterfallRendered.value = true
if (!store.loading && !store.isOver) {
store.fetchData(store.page + 1)
}
}
watch(
() => store.loading,
(value) => {
if (value) {
waterfallRendered.value = false
}
}
)
watch(
() => store.isOver,
(value) => {
if (value) {
waterfallRendered.value = true
}
}
)
onMounted(() => {
store.init()
})
onUnmounted(() => {
if (taskMediaReadyRafId) {
cancelAnimationFrame(taskMediaReadyRafId)
taskMediaReadyRafId = 0
}
store.cleanup()
})
</script>
@@ -361,4 +808,80 @@ onUnmounted(() => {
background: rgba(0, 0, 0, 0.1);
}
}
.detail-content {
:deep(.el-descriptions__label) {
min-width: 112px;
}
}
.detail-section-title {
margin: 16px 0 8px;
font-size: 15px;
font-weight: 600;
color: var(--text-theme-color, #252f76);
}
.prompt-with-copy {
display: flex;
align-items: flex-start;
gap: 4px;
}
.inline-btn {
border: none;
padding: 0;
margin: 0;
background: transparent;
cursor: pointer;
color: inherit;
line-height: inherit;
}
.detail-media-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.detail-media-grid--image .detail-thumb {
width: 120px;
height: 120px;
border-radius: 8px;
overflow: hidden;
}
.detail-media-stack {
display: flex;
flex-direction: column;
gap: 12px;
}
.detail-video-preview {
max-width: 100%;
max-height: 220px;
border-radius: 8px;
background: #000;
}
.detail-video-preview--large {
max-height: 360px;
}
.detail-params-collapse {
margin-top: 16px;
}
.detail-json {
margin: 0;
padding: 10px 12px;
font-size: 12px;
line-height: 1.45;
max-height: 240px;
overflow: auto;
border-radius: 8px;
background: var(--el-fill-color-light, #f5f7fa);
white-space: pre-wrap;
word-break: break-all;
}
</style>
+8 -1
View File
@@ -15,6 +15,7 @@
</template>
</el-table-column>
<el-table-column prop="price" label="商品价格" />
<el-table-column prop="stripe_price" label="Stripe 价格(USD)" />
<el-table-column prop="power" label="算力" />
<el-table-column prop="sales" label="销量" />
<el-table-column prop="enabled" label="启用状态">
@@ -52,6 +53,10 @@
<el-input v-model="item.price" autocomplete="off" />
</el-form-item>
<el-form-item label="Stripe 价格(USD)" prop="stripe_price">
<el-input v-model="item.stripe_price" autocomplete="off" placeholder="Stripe 专用美元价格" />
</el-form-item>
<el-form-item label="算力:" prop="power">
<el-input v-model.number="item.power" autocomplete="off" placeholder="增加算力值" />
</el-form-item>
@@ -87,6 +92,7 @@ const title = ref('')
const rules = reactive({
name: [{ required: true, message: '请输入产品名称', trigger: 'change' }],
price: [{ required: true, message: '请输产品价格', trigger: 'change' }],
stripe_price: [{ required: true, message: '请输 Stripe 价格', trigger: 'change' }],
discount: [{ required: true, message: '请输优惠金额', trigger: 'change' }],
days: [{ required: true, message: '请输入有效期', trigger: 'change' }],
})
@@ -143,7 +149,7 @@ onMounted(() => {
const add = function () {
title.value = '新增产品'
showDialog.value = true
item.value = {}
item.value = { stripe_price: 0 }
}
const edit = function (row) {
@@ -157,6 +163,7 @@ const save = function () {
if (valid) {
showDialog.value = false
item.value['price'] = parseFloat(item.value['price'])
item.value['stripe_price'] = parseFloat(item.value['stripe_price'])
item.value['discount'] = parseFloat(item.value['discount'])
httpPost('/api/admin/product/save', item.value)
.then((res) => {
+1 -1
View File
@@ -352,7 +352,7 @@ const userEdit = function (row) {
}
const addUser = () => {
user.value = { chat_id: 0, chat_roles: [], chat_models: [] }
user.value = { chat_id: 0, chat_models: [] }
title.value = '添加用户'
showUserEditDialog.value = true
add.value = true
+1 -1
View File
@@ -75,7 +75,7 @@
</template>
<el-input v-model="jimengConfig.api_key" placeholder="请输入火山方舟服务API Key" />
<div class="text-sm mt-2 text-gray-500">
目前豆包生图 4.0 模型在即梦API中不支持需要使用火山方舟服务
目前 Seedream 生图模型统一走火山方舟 API KeySeedance 视频模型共用视频生成配置中的 ApiURL ApiKey
</div>
</el-form-item>
</div>
+18 -31
View File
@@ -42,11 +42,11 @@
</el-table-column>
<el-table-column prop="progress" label="任务进度">
<template #default="scope">
<span v-if="scope.row.progress <= 100">{{ scope.row.progress }}%</span>
<span v-if="scope.row.status !== 'failed'">{{ 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 prop="power" label="消耗积分" />
<el-table-column label="结果图片">
<template #default="scope">
<el-button
@@ -87,7 +87,7 @@
:width="300"
trigger="hover"
:content="scope.row.err_msg"
v-if="scope.row.progress === 101"
v-if="scope.row.status === 'failed'"
>
<template #reference>
<el-text type="danger">{{ substr(scope.row.err_msg, 20) }}</el-text>
@@ -157,11 +157,11 @@
<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>
<span v-if="scope.row.status !== 'failed'">{{ 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 prop="power" label="消耗积分" />
<el-table-column label="结果图片">
<template #default="scope">
<el-button
@@ -202,7 +202,7 @@
:width="300"
trigger="hover"
:content="scope.row.err_msg"
v-if="scope.row.progress === 101"
v-if="scope.row.status === 'failed'"
>
<template #reference>
<el-text type="danger">{{ substr(scope.row.err_msg, 20) }}</el-text>
@@ -283,7 +283,7 @@
<el-descriptions-item
label="生成的图片"
v-if="currentDetail.progress === 100 && currentDetail.img_url"
v-if="currentDetail.status === 'success' && currentDetail.img_url"
>
<el-image
:src="getThumbURL(currentDetail.img_url, 200, 200)"
@@ -314,7 +314,7 @@
{{ currentDetail.params?.model_name || '-' }}
</el-descriptions-item>
<el-descriptions-item label="消耗算力">
<el-descriptions-item label="消耗积分">
{{ currentDetail.power || 0 }}
</el-descriptions-item>
@@ -327,12 +327,12 @@
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ formatTime(currentDetail.created_at) }}
{{ dateFormat(currentDetail.created_at) }}
</el-descriptions-item>
<el-descriptions-item
label="错误信息"
v-if="currentDetail.progress === 101 && currentDetail.err_msg"
v-if="currentDetail.status === 'failed' && currentDetail.err_msg"
>
<el-text type="danger">{{ currentDetail.err_msg }}</el-text>
</el-descriptions-item>
@@ -343,10 +343,10 @@
</template>
<script setup>
import { showMessageError, showMessageOK } from '@/utils/dialog'
import { httpGet, httpPost } from '@/utils/http'
import { dateFormat, getThumbURL, substr } from '@/utils/libs'
import { Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { onMounted, ref } from 'vue'
//
@@ -415,7 +415,7 @@ const fetchMjData = () => {
d.loading = false
})
.catch((e) => {
ElMessage.error('获取数据失败:' + e.message)
showMessageError('获取数据失败:' + e.message)
})
}
@@ -434,18 +434,18 @@ const fetchImageData = () => {
d.loading = false
})
.catch((e) => {
ElMessage.error('获取数据失败:' + e.message)
showMessageError('获取数据失败:' + e.message)
})
}
const remove = function (row, tab) {
httpGet(`/api/admin/image/remove?id=${row.id}&tab=${tab}`)
.then(() => {
ElMessage.success('删除成功!')
showMessageOK('删除成功!')
handleChange(tab)
})
.catch((e) => {
ElMessage.error('删除失败:' + e.message)
showMessageError('删除失败:' + e.message)
})
}
@@ -482,26 +482,13 @@ const showDetail = (row) => {
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('复制成功!')
showMessageOK('复制成功!')
return
}
const textarea = document.createElement('textarea')
@@ -510,9 +497,9 @@ const copyPrompt = async (text) => {
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
ElMessage.success('复制成功!')
showMessageOK('复制成功!')
} catch {
ElMessage.error('复制失败!')
showMessageError('复制失败!')
}
}
</script>
+13 -13
View File
@@ -47,7 +47,7 @@
<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" />
<el-image v-else-if="scope.row.status === 'failed'" 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>
@@ -56,11 +56,11 @@
</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>
<span v-if="scope.row.status !== 'failed'">{{ 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="power" label="消耗积分" width="100" />
<el-table-column prop="play_times" label="播放次数" width="100" />
<el-table-column label="歌词" width="110">
<template #default="scope">
@@ -77,7 +77,7 @@
<el-table-column label="失败原因" width="180">
<template #default="scope">
<el-popover
v-if="scope.row.progress === 101"
v-if="scope.row.status === 'failed'"
placement="top-start"
title="失败原因"
:width="300"
@@ -151,12 +151,12 @@
<div class="detail-item">
<div class="detail-label">任务进度</div>
<div class="detail-value">
<span v-if="currentDetail.progress <= 100">{{ currentDetail.progress }}%</span>
<span v-if="currentDetail.status !== 'failed'">{{ 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-label">积分消耗</div>
<div class="detail-value">{{ currentDetail.power }}</div>
</div>
<div class="detail-item">
@@ -178,13 +178,13 @@
<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 v-if="currentDetail.status === 'failed' && 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 v-if="currentDetail.status === 'success' && 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" />
@@ -201,10 +201,10 @@
<script setup>
import MusicPlayer from '@/components/MusicPlayer.vue'
import { showMessageError, showMessageOK } from '@/utils/dialog'
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'
@@ -249,18 +249,18 @@ const fetchData = () => {
loading.value = false
})
.catch((e) => {
ElMessage.error('获取数据失败:' + e.message)
showMessageError('获取数据失败:' + e.message)
})
}
const remove = function (row) {
httpGet(`/api/admin/suno/remove?id=${row.id}`)
.then(() => {
ElMessage.success('删除成功!')
showMessageOK('删除成功!')
fetchData()
})
.catch((e) => {
ElMessage.error('删除失败:' + e.message)
showMessageError('删除失败:' + e.message)
})
}
@@ -272,7 +272,7 @@ const playMusic = (item) => {
const md = MarkdownIt({
breaks: true,
html: true,
html: false,
linkify: true,
})
+29 -1
View File
@@ -106,6 +106,26 @@
<el-form-item label="启用该支付通道"><el-switch v-model="epay.enabled" /></el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="Stripe" name="stripe">
<template #label>
<div class="d-flex align-items-center text-orange-600">
<i class="iconfont icon-reward"></i>
<span class="ms-2">Stripe</span>
</div>
</template>
<Alert type="info">
Stripe Checkout 采用托管收银台模式支付完成后会回跳到会员页最终支付状态以 webhook 为准
</Alert>
<el-form :model="stripe" class="mt-4" label-position="top">
<el-form-item label="Secret Key"><el-input v-model="stripe.secret_key" /></el-form-item>
<el-form-item label="Webhook Signing Secret"><el-input v-model="stripe.webhook_key" /></el-form-item>
<el-form-item label="回跳域名"><el-input v-model="stripe.domain" /></el-form-item>
<el-form-item label="货币代码"><el-input v-model="stripe.currency" placeholder="usd" /></el-form-item>
<el-form-item label="启用该支付通道"><el-switch v-model="stripe.enabled" /></el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
<div class="flex justify-center mt-6">
<el-button type="primary" @click="save" :loading="loading">提交保存</el-button>
@@ -143,6 +163,7 @@ const wxpay = ref({
domain: '',
})
const epay = ref({ enabled: false, app_id: '', private_key: '', api_url: '', domain: '' })
const stripe = ref({ enabled: false, secret_key: '', webhook_key: '', domain: '', currency: 'usd' })
onMounted(() => {
httpGet('/api/admin/config/get?key=payment')
@@ -151,6 +172,7 @@ onMounted(() => {
alipay.value = { ...alipay.value, ...(data.alipay || {}) }
wxpay.value = { ...wxpay.value, ...(data.wxpay || data.wechat || {}) }
epay.value = { ...epay.value, ...(data.epay || {}) }
stripe.value = { ...stripe.value, ...(data.stripe || {}) }
// domain
if (!alipay.value.domain) {
@@ -162,6 +184,12 @@ onMounted(() => {
if (!epay.value.domain) {
epay.value.domain = domain.value
}
if (!stripe.value.domain) {
stripe.value.domain = domain.value
}
if (!stripe.value.currency) {
stripe.value.currency = 'usd'
}
})
.catch(() => {})
.finally(() => (loading.value = false))
@@ -169,7 +197,7 @@ onMounted(() => {
const save = () => {
loading.value = true
const payload = { alipay: alipay.value, wxpay: wxpay.value, epay: epay.value }
const payload = { alipay: alipay.value, wxpay: wxpay.value, epay: epay.value, stripe: stripe.value }
httpPost('/api/admin/config/update/payment', payload)
.then(() => ElMessage.success('保存成功'))
.catch((e) => ElMessage.error(e.message))
+362 -11
View File
@@ -26,12 +26,128 @@
<el-switch v-model="gzhConfig['enabled']" />
</el-form-item>
</el-form>
<div class="tab-actions">
<el-button type="primary" @click="save">保存</el-button>
</div>
</el-tab-pane>
<el-tab-pane label="自定义菜单" name="menu">
<el-alert type="info" show-icon :closable="false" class="menu-hint">
<template #title>
菜单将同步到微信公众平台跳转链接view的域名需在公众号后台配置为业务域名或通过授权页打开小程序类型需填写 appidpagepath
与备用网页 url
</template>
</el-alert>
<div v-for="(item, i) in menuTops" :key="i" class="menu-top-card">
<div class="menu-top-head">
<span class="menu-top-label">一级菜单 {{ i + 1 }}</span>
<el-button type="danger" link @click="removeTop(i)">删除</el-button>
</div>
<el-form label-position="top">
<el-form-item label="菜单名称">
<el-input v-model="item.name" placeholder="显示在公众号底部的名称" maxlength="16" show-word-limit />
</el-form-item>
<el-form-item label="类型">
<el-radio-group v-model="item.isSubmenu">
<el-radio :label="false">直接响应链接 / 点击</el-radio>
<el-radio :label="true">展开子菜单最多 5 </el-radio>
</el-radio-group>
</el-form-item>
<template v-if="!item.isSubmenu">
<el-form-item label="动作类型">
<el-select v-model="item.type" style="width: 220px">
<el-option label="跳转网页 view" value="view" />
<el-option label="点击推事件 click" value="click" />
<el-option label="打开小程序 miniprogram" value="miniprogram" />
</el-select>
</el-form-item>
<el-form-item v-if="item.type === 'view'" label="网页链接 URL">
<el-input v-model="item.url" placeholder="https:// 开头" />
</el-form-item>
<el-form-item v-if="item.type === 'click'" label="事件 Key">
<el-input v-model="item.key" placeholder="与服务器事件匹配,勿与现有 key 冲突" />
</el-form-item>
<template v-if="item.type === 'miniprogram'">
<el-form-item label="小程序 AppID">
<el-input v-model="item.appid" />
</el-form-item>
<el-form-item label="小程序页面路径 pagepath">
<el-input v-model="item.pagepath" placeholder="如 pages/index/index" />
</el-form-item>
<el-form-item label="备用网页 url(必填)">
<el-input v-model="item.url" placeholder="无法打开小程序时跳转" />
</el-form-item>
</template>
</template>
<template v-else>
<div class="sub-menu-toolbar">
<el-button
type="primary"
plain
size="small"
:disabled="(item.sub_button || []).length >= 5"
@click="addSub(i)"
>
添加子菜单
</el-button>
</div>
<div
v-for="(sub, j) in item.sub_button"
:key="j"
class="sub-menu-row"
>
<el-divider content-position="left">子项 {{ j + 1 }}</el-divider>
<div class="sub-menu-head">
<el-button type="danger" link size="small" @click="removeSub(i, j)">删除</el-button>
</div>
<el-form-item label="子菜单名称">
<el-input v-model="sub.name" maxlength="60" show-word-limit />
</el-form-item>
<el-form-item label="动作类型">
<el-select v-model="sub.type" style="width: 220px">
<el-option label="跳转网页 view" value="view" />
<el-option label="点击推事件 click" value="click" />
<el-option label="打开小程序 miniprogram" value="miniprogram" />
</el-select>
</el-form-item>
<el-form-item v-if="sub.type === 'view'" label="网页链接 URL">
<el-input v-model="sub.url" />
</el-form-item>
<el-form-item v-if="sub.type === 'click'" label="事件 Key">
<el-input v-model="sub.key" />
</el-form-item>
<template v-if="sub.type === 'miniprogram'">
<el-form-item label="小程序 AppID">
<el-input v-model="sub.appid" />
</el-form-item>
<el-form-item label="小程序页面路径">
<el-input v-model="sub.pagepath" />
</el-form-item>
<el-form-item label="备用网页 url">
<el-input v-model="sub.url" />
</el-form-item>
</template>
</div>
</template>
</el-form>
</div>
<el-button class="add-top-btn" :disabled="menuTops.length >= 3" @click="addTop">
添加一级菜单最多 3
</el-button>
<div class="tab-actions menu-actions">
<el-button :loading="menuSaving" @click="saveMenuDraft">保存草稿</el-button>
<el-button type="primary" :loading="menuPublishing" @click="syncMenuToWechat">
同步到微信
</el-button>
<el-button :loading="menuPulling" @click="pullMenuFromWechat">从微信拉取</el-button>
</div>
</el-tab-pane>
</el-tabs>
<div style="padding: 10px">
<el-button type="primary" @click="save">保存</el-button>
</div>
</div>
</div>
</template>
@@ -45,14 +161,198 @@ const activeName = ref('gzh')
const gzhConfig = ref({ app_id: '', secret: '' })
const wechatFormRef = ref(null)
const menuTops = ref([])
const menuSaving = ref(false)
const menuPublishing = ref(false)
const menuPulling = ref(false)
function emptySub() {
return {
name: '',
type: 'view',
url: '',
key: '',
appid: '',
pagepath: '',
}
}
function emptyTop() {
return {
name: '',
isSubmenu: false,
type: 'view',
url: '',
key: '',
appid: '',
pagepath: '',
sub_button: [],
}
}
function normalizeLeaf(s) {
const t = s.type
let type = 'view'
if (t === 'click' || t === 'miniprogram') {
type = t
} else if (t && t !== 'view') {
type = s.url ? 'view' : 'click'
}
return {
name: s.name || '',
type,
url: s.url || '',
key: s.key || '',
appid: s.appid || '',
pagepath: s.pagepath || '',
}
}
function normalizeTop(b) {
if (b.sub_button && b.sub_button.length) {
return {
name: b.name || '',
isSubmenu: true,
type: 'view',
url: '',
key: '',
appid: '',
pagepath: '',
sub_button: b.sub_button.map(normalizeLeaf),
}
}
const l = normalizeLeaf(b)
return {
name: b.name || '',
isSubmenu: false,
type: l.type,
url: l.url,
key: l.key,
appid: l.appid,
pagepath: l.pagepath,
sub_button: [],
}
}
function applyMenuFromApi(payload) {
const buttons = payload?.button
if (!buttons || !buttons.length) {
menuTops.value = []
return
}
menuTops.value = buttons.map(normalizeTop)
}
function leafToApi(s) {
const o = { name: s.name, type: s.type }
if (s.type === 'view') {
o.url = s.url
} else if (s.type === 'click') {
o.key = s.key
} else if (s.type === 'miniprogram') {
o.appid = s.appid
o.pagepath = s.pagepath
o.url = s.url
}
return o
}
function menuTopsToButton() {
return menuTops.value.map((b) => {
if (b.isSubmenu) {
return {
name: b.name,
sub_button: (b.sub_button || []).map(leafToApi),
}
}
return leafToApi({
name: b.name,
type: b.type,
url: b.url,
key: b.key,
appid: b.appid,
pagepath: b.pagepath,
})
})
}
function addTop() {
if (menuTops.value.length >= 3) {
return
}
menuTops.value.push(emptyTop())
}
function removeTop(i) {
menuTops.value.splice(i, 1)
}
function addSub(topIndex) {
const item = menuTops.value[topIndex]
if (!item.sub_button) {
item.sub_button = []
}
if (item.sub_button.length >= 5) {
return
}
item.sub_button.push(emptySub())
}
function removeSub(topIndex, subIndex) {
menuTops.value[topIndex].sub_button.splice(subIndex, 1)
}
async function saveMenuDraft() {
menuSaving.value = true
try {
const body = { button: menuTopsToButton() }
await httpPost('/api/admin/config/update/wx_gzh_menu', body)
ElMessage.success('菜单草稿已保存')
} catch (e) {
ElMessage.error('保存失败:' + (e.message || String(e)))
} finally {
menuSaving.value = false
}
}
async function syncMenuToWechat() {
menuPublishing.value = true
try {
const body = { button: menuTopsToButton() }
await httpPost('/api/admin/config/update/wx_gzh_menu', body)
await httpPost('/api/admin/config/wx_gzh/menu/publish', {})
ElMessage.success('已保存并同步到微信')
} catch (e) {
ElMessage.error('同步失败:' + (e.message || String(e)))
} finally {
menuPublishing.value = false
}
}
async function pullMenuFromWechat() {
menuPulling.value = true
try {
const res = await httpGet('/api/admin/config/wx_gzh/menu/query')
applyMenuFromApi(res.data)
ElMessage.success('已从微信拉取并写入草稿')
} catch (e) {
ElMessage.error('拉取失败:' + (e.message || String(e)))
} finally {
menuPulling.value = false
}
}
onMounted(() => {
//
httpGet('/api/admin/config/get?key=wx_gzh')
.then((res) => {
gzhConfig.value = res.data || {}
Promise.all([
httpGet('/api/admin/config/get?key=wx_gzh'),
httpGet('/api/admin/config/get?key=wx_gzh_menu'),
])
.then(([r1, r2]) => {
gzhConfig.value = r1.data || {}
applyMenuFromApi(r2.data || {})
})
.catch((e) => {
ElMessage.error('加载系统配置失败: ' + e.message)
ElMessage.error('加载系统配置失败: ' + (e.message || String(e)))
})
})
@@ -74,7 +374,7 @@ const save = function () {
ElMessage.success('操作成功!')
})
.catch((e) => {
ElMessage.error('操作失败:' + e.message)
ElMessage.error('操作失败:' + (e.message || String(e)))
})
}
})
@@ -92,7 +392,58 @@ const save = function () {
width: 100%;
background-color: var(--el-bg-color);
padding: 10px 20px 40px 20px;
/* border: 1px solid var(--el-border-color); */
}
}
.menu-hint {
margin-bottom: 16px;
}
.menu-top-card {
border: 1px solid var(--el-border-color);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
background: var(--el-fill-color-blank);
}
.menu-top-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.menu-top-label {
font-weight: 600;
}
.sub-menu-toolbar {
margin-bottom: 8px;
}
.sub-menu-row {
padding-left: 8px;
border-left: 3px solid var(--el-color-primary-light-5);
margin-bottom: 12px;
}
.sub-menu-head {
display: flex;
justify-content: flex-end;
}
.add-top-btn {
margin-bottom: 20px;
}
.tab-actions {
padding: 10px 0 0;
}
.menu-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
</style>
+3 -43
View File
@@ -10,9 +10,7 @@
v-for="item in currentApps"
:key="item.id"
:app="item"
:has-role="hasRole(item.id)"
@use-role="useRole"
@update-role="updateRole"
/>
</template>
<template v-else-if="!loading && currentApps.length === 0">
@@ -45,9 +43,7 @@
v-for="item in getAppsByType(type.id)"
:key="item.id"
:app="item"
:has-role="hasRole(item.id)"
@use-role="useRole"
@update-role="updateRole"
/>
</template>
<template v-else-if="!loading && getAppsByType(type.id).length === 0">
@@ -78,8 +74,8 @@ import EmptyState from '@/components/mobile/EmptyState.vue'
import CustomTabPane from '@/components/ui/CustomTabPane.vue'
import CustomTabs from '@/components/ui/CustomTabs.vue'
import { checkSession } from '@/store/cache'
import { httpGet, httpPost } from '@/utils/http'
import { arrayContains, removeArrayItem, showLoginDialog, substr } from '@/utils/libs'
import { httpGet } from '@/utils/http'
import { showLoginDialog, substr } from '@/utils/libs'
import { showNotify } from 'vant'
import { computed, nextTick, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
@@ -89,7 +85,6 @@ const isLogin = ref(false)
const allApps = ref([]) //
const appTypes = ref([])
const loading = ref(false)
const roles = ref([])
const activeTab = ref('all')
const initialized = ref(false)
@@ -116,9 +111,8 @@ const currentApps = computed(() => {
onMounted(async () => {
try {
const user = await checkSession()
await checkSession()
isLogin.value = true
roles.value = Array.isArray(user.chat_roles) ? user.chat_roles : []
} catch (error) {
//
}
@@ -173,40 +167,6 @@ const handleTabChange = async (tabName) => {
await nextTick()
}
const updateRole = async (app, opt) => {
if (!isLogin.value) {
return showLoginDialog(router)
}
let actionTitle = ''
if (opt === 'add') {
actionTitle = '添加应用'
const exists = arrayContains(roles.value, app.id)
if (exists) {
return
}
roles.value.push(app.id)
} else {
actionTitle = '移除应用'
const exists = arrayContains(roles.value, app.id)
if (!exists) {
return
}
roles.value = removeArrayItem(roles.value, app.id)
}
try {
await httpPost('/api/app/workspace', { ids: roles.value })
showNotify({ type: 'success', message: actionTitle + '成功!' })
} catch (e) {
showNotify({ type: 'danger', message: actionTitle + '失败:' + e.message })
}
}
const hasRole = (roleId) => {
return arrayContains(roles.value, roleId, (v1, v2) => v1 === v2)
}
const useRole = (roleId) => {
if (!isLogin.value) {
return showLoginDialog(router)
+2 -38
View File
@@ -120,8 +120,8 @@
<script setup>
import { checkSession, getSystemInfo, getMenus } from '@/store/cache'
import { httpGet, httpPost } from '@/utils/http'
import { arrayContains, removeArrayItem, showLoginDialog, substr } from '@/utils/libs'
import { httpGet } from '@/utils/http'
import { showLoginDialog, substr } from '@/utils/libs'
import { ElMessage } from 'element-plus'
import { showNotify } from 'vant'
import { computed, onMounted, ref } from 'vue'
@@ -132,7 +132,6 @@ const router = useRouter()
const isLogin = ref(false)
const apps = ref([])
const loading = ref(false)
const roles = ref([])
const userAvatar = ref('/images/avatar/default.jpg')
//
@@ -225,7 +224,6 @@ onMounted(() => {
checkSession()
.then((user) => {
isLogin.value = true
roles.value = Array.isArray(user.chat_roles) ? user.chat_roles : []
userAvatar.value = user.avatar || '/images/avatar/default.jpg'
})
.catch(() => {})
@@ -256,40 +254,6 @@ const fetchApps = () => {
})
}
const updateRole = (row, opt) => {
if (!isLogin.value) {
return showLoginDialog(router)
}
let actionTitle = ''
if (opt === 'add') {
actionTitle = '添加应用'
const exists = arrayContains(roles.value, row.id)
if (exists) {
return
}
roles.value.push(row.id)
} else {
actionTitle = '移除应用'
const exists = arrayContains(roles.value, row.id)
if (!exists) {
return
}
roles.value = removeArrayItem(roles.value, row.id)
}
httpPost('/api/app/workspace', { ids: roles.value })
.then(() => {
showNotify({ type: 'success', message: actionTitle + '成功!', duration: 1000 })
})
.catch((e) => {
showNotify({ type: 'danger', message: actionTitle + '失败:' + e.message })
})
}
const hasRole = (roleId) => {
return arrayContains(roles.value, roleId, (v1, v2) => v1 === v2)
}
const useRole = (roleId) => {
if (!isLogin.value) {
return showLoginDialog(router)
+38
View File
@@ -39,6 +39,14 @@
<i class="iconfont icon-alipay"></i>
<span>支付宝</span>
</button>
<button
v-if="stripePayEnabled"
class="payment-btn stripe-btn"
@click="stripePay(item)"
>
<i class="iconfont icon-reward"></i>
<span>Stripe</span>
</button>
</div>
</div>
</div>
@@ -140,6 +148,7 @@ const currentPrice = ref(0)
const currentProduct = ref(null)
const selectedPid = ref(0)
const orderTimeout = ref(1800)
const stripePayEnabled = ref(false)
const handler = ref(null)
const title = ref('')
@@ -174,6 +183,7 @@ onMounted(() => {
if (res.data['order_pay_timeout'] > 0) {
orderTimeout.value = res.data['order_pay_timeout']
}
stripePayEnabled.value = !!res.data['stripe_pay_enabled']
})
.catch((e) => {
console.error('获取系统配置失败:', e.message)
@@ -242,6 +252,25 @@ const alipay = (product) => {
GenerateOrder('alipay')
}
const stripePay = (product) => {
if (!isLogin.value) {
store.setShowLoginDialog(true)
return
}
selectedPid.value = product.id
currentProduct.value = product
currentPrice.value = Number(product.price)
title.value = 'Stripe Checkout'
showLoadingToast({
message: '正在生成 Stripe 支付订单...',
forbidClick: true,
})
GenerateOrder('stripe')
}
function GenerateOrder(payWay, device = 'pc') {
//
httpPost('/api/payment/create', {
@@ -251,6 +280,10 @@ function GenerateOrder(payWay, device = 'pc') {
device: device,
})
.then((res) => {
if (payWay === 'stripe') {
window.location.href = res.data.pay_url
return
}
if (res.data.pay_url) {
// 使JSAPI
if (isWechat() && wxGzhConfig.value.enabled) {
@@ -551,6 +584,11 @@ const onBridgeReady = function onBridgeReady(payParams) {
background: #1395d1;
}
}
&.stripe-btn {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
color: white;
}
}
}
}
+10 -10
View File
@@ -254,7 +254,7 @@
</el-image>
<!-- 音乐播放按钮 -->
<button
v-if="item.progress === 100"
v-if="item.status === 'success'"
@click="suno.play(item)"
class="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 opacity-0 hover:opacity-100 transition-opacity"
>
@@ -262,14 +262,14 @@
</button>
<!-- 进度动画 -->
<div
v-if="item.progress < 100 && item.progress !== 101"
v-if="item.status === 'pending' || item.status === 'in_progress' || item.status === 'downloading'"
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>
@@ -287,9 +287,9 @@
</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"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -344,7 +344,7 @@
<div class="flex items-center justify-between mt-4">
<div class="flex space-x-2">
<button
v-if="item.progress === 100"
v-if="item.status === 'success'"
@click="suno.play(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"
>
@@ -352,7 +352,7 @@
<span>播放</span>
</button>
<button
v-if="item.progress === 100"
v-if="item.status === 'success'"
@click="suno.download(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"
@@ -381,7 +381,7 @@
<span>{{ item.downloading ? '下载中...' : '下载' }}</span>
</button>
<button
v-if="item.progress === 100"
v-if="item.status === 'success'"
@click="suno.extend(item)"
class="px-3 py-1.5 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 transition-colors flex items-center justify-center min-w-[60px]"
>
@@ -399,7 +399,7 @@
</div>
<!-- 进度条 -->
<div v-if="item.progress < 100 && item.progress !== 101" class="mt-4">
<div v-if="item.status === 'pending' || item.status === 'in_progress' || item.status === 'downloading'" class="mt-4">
<div class="flex justify-between text-sm text-gray-600 mb-1">
<span>生成进度</span>
<span>{{ item.progress }}%</span>
@@ -414,7 +414,7 @@
<!-- 错误信息 -->
<div
v-if="item.progress === 101"
v-if="item.status === 'failed'"
class="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg"
>
<div class="flex items-start space-x-2">
+3 -3
View File
@@ -122,7 +122,7 @@
>
<van-grid :gutter="10" :column-num="2">
<van-grid-item v-for="item in finishedJobs" :key="item.id">
<div class="failed" v-if="item.progress === 101">
<div class="failed" v-if="item.status === 'failed'">
<div class="title">任务失败</div>
<div class="opt">
<van-button size="small" @click="showDetail(item)">详情</van-button>
@@ -192,7 +192,7 @@
<van-cell title="创建时间" :value="formatTime(currentDetail.created_at)" />
<van-cell
v-if="currentDetail.progress === 100 && currentDetail.img_url"
v-if="currentDetail.status === 'success' && currentDetail.img_url"
title="生成的图片"
>
<template #value>
@@ -226,7 +226,7 @@
</van-cell>
<van-cell
v-if="currentDetail.progress === 101 && currentDetail.err_msg"
v-if="currentDetail.status === 'failed' && currentDetail.err_msg"
title="错误信息"
>
<template #value>
+3 -3
View File
@@ -258,7 +258,7 @@
>
<van-grid :gutter="10" :column-num="2">
<van-grid-item v-for="item in finishedJobs" :key="item.id" class="min-h-[270px]">
<div class="failed" v-if="item.progress === 101">
<div class="failed" v-if="item.status === 'failed'">
<div class="title">任务失败</div>
<div class="opt">
<van-button size="small" @click="showErrMsg(item)">详情</van-button>
@@ -548,11 +548,11 @@ const fetchFinishJobs = (page) => {
jobs[i]['thumb_url'] = getThumbURL(jobs[i]['img_url'], 480, 480)
}
if (jobs[i]['img_url'] === '' && jobs[i].progress === 100) {
if (jobs[i]['img_url'] === '' && jobs[i].status === 'downloading') {
hasDownload = true
}
if (jobs[i].type !== 'upscale' && jobs[i].progress === 100) {
if (jobs[i].type !== 'upscale' && jobs[i].status === 'success') {
jobs[i]['can_opt'] = true
}
}