fix: nanobanana 新盖

This commit is contained in:
old burden 2026-04-23 13:01:38 +08:00
parent fe5137c3ec
commit 04ede952ac
23 changed files with 1135 additions and 342 deletions

View File

@ -13,6 +13,28 @@ export function generateImage(data) {
}) })
} }
/**
* Portal 文生图nanoApiType v1 | v2 | pro对应不同 NanoBanana 路径
*/
export function nanoBananaGenerate(data) {
return request({
url: '/api/ai/nano/generate',
method: 'post',
data: data
})
}
/**
* Portal 图生图需传 imageUrl imageUrls
*/
export function nanoBananaImgToImg(data) {
return request({
url: '/api/ai/nano/imgToImg',
method: 'post',
data: data
})
}
export function imgToImg(data) { export function imgToImg(data) {
return request({ return request({
url: '/api/ai/imgToImg', url: '/api/ai/imgToImg',

View File

@ -272,3 +272,217 @@ input:-webkit-autofill {
width: calc(100% - 20px); width: calc(100% - 20px);
} }
} }
/* ====================== 新粗野主义风格 (New Brutalism) ====================== */
body {
font-size: 16px;
-webkit-font-smoothing: antialiased;
color: #2c2c2c;
font-family: 'Impact', 'Arial Black', 'Helvetica Neue', 'Microsoft YaHei', system-ui, sans-serif;
font-weight: 900;
line-height: 1.05;
letter-spacing: -0.025em;
overflow: hidden;
position: relative;
background: #f0f0f0;
/* 粗糙肌理感:模拟混凝土/纸张纹理,使用细网格噪声 */
background-image:
linear-gradient(45deg, rgba(44, 44, 44, 0.015) 1px, transparent 1px),
linear-gradient(-45deg, rgba(44, 44, 44, 0.01) 1px, transparent 1px);
background-size: 5px 5px;
}
/* 拥挤排版 & 硬派标题 */
h1, h2, h3, h4, h5, h6, .arco-typography, .title {
font-family: 'Impact', 'Arial Black', sans-serif;
font-weight: 900;
letter-spacing: -0.05em;
line-height: 0.95;
margin: 0 0 4px 0;
text-transform: uppercase;
}
/* 所有主要元素硬边、无圆角、4px硬阴影、2px边框 */
.brutalist,
.arco-card,
.arco-btn,
.arco-menu,
.arco-modal-content,
.arco-drawer-content,
.input-wrapper,
.arco-input-wrapper,
.arco-select,
.arco-textarea,
.arco-input,
select,
textarea,
input,
.mf-button,
.mf-pane,
.card,
.panel,
.navbar,
.sidebar-container,
.app-wrapper {
border: 2px solid #2c2c2c !important;
border-radius: 0 !important;
box-shadow: 4px 4px 0 #2c2c2c !important;
background-color: #f0f0f0 !important;
color: #2c2c2c !important;
transition: all 0.1s linear !important;
}
.brutalist:active,
.arco-btn:active,
.arco-menu-item:active,
.mf-button:active {
transform: translate(2px, 2px);
box-shadow: 2px 2px 0 #2c2c2c !important;
}
/* 主按钮:深灰底 + 鲜红强调,冲突感 */
.arco-btn,
.mf-button {
background: #2c2c2c !important;
color: #f0f0f0 !important;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 1px;
padding: 12px 24px !important;
font-size: 15px;
border: 2px solid #2c2c2c !important;
box-shadow: 4px 4px 0 #2c2c2c !important;
}
.arco-btn:hover,
.arco-btn-primary,
.mf-button:hover,
.mf-button[type="primary"] {
background: #ff0033 !important;
color: #f0f0f0 !important;
border-color: #2c2c2c !important;
box-shadow: 6px 6px 0 #2c2c2c !important;
transform: translate(-1px, -1px);
}
/* 菜单 & 侧边 */
.arco-menu,
.sidebar-container {
background: #e8e8e8 !important;
border-right: 4px solid #2c2c2c !important;
}
.arco-menu-item,
.sidebar-container :deep(.arco-menu-item) {
border: 2px solid #2c2c2c !important;
margin: 4px 8px !important;
padding: 16px 20px !important;
font-weight: 900;
font-size: 15px;
background: #f0f0f0 !important;
}
.arco-menu-item:hover,
.arco-menu-item.arco-menu-selected,
.sidebar-container :deep(.arco-menu-item:hover),
.sidebar-container :deep(.arco-menu-selected) {
background: #ff0033 !important;
color: #f0f0f0 !important;
border-color: #2c2c2c;
transform: translate(2px, 2px);
box-shadow: 4px 4px 0 #2c2c2c;
}
/* 模态 & 弹窗 - 更厚重 */
.arco-modal-content,
.arco-drawer-content,
.arco-modal-simple {
border: 4px solid #2c2c2c !important;
box-shadow: 8px 8px 0 #2c2c2c !important;
background: #f0f0f0 !important;
}
.arco-modal-title,
.arco-modal-header {
background: #2c2c2c !important;
color: #f0f0f0 !important;
border-bottom: 4px solid #ff0033 !important;
padding: 16px !important;
font-size: 18px;
text-transform: uppercase;
}
/* 导航栏特定 */
.navbar {
background: #f0f0f0 !important;
border-bottom: 4px solid #2c2c2c !important;
box-shadow: 0 4px 0 #2c2c2c !important;
padding: 0 20px !important;
height: 72px !important; /* 略高以强调 */
}
.right-menu-item.user {
border: 2px solid #2c2c2c !important;
background: #f0f0f0 !important;
border-radius: 0 !important;
box-shadow: 4px 4px 0 #2c2c2c;
}
/* 非对称布局辅助类 */
.asymmetric-layout {
display: flex;
gap: 0;
}
.asymmetric-sidebar {
margin-right: -12px; /* 轻微重叠/非对称 */
z-index: 10;
}
.main-content {
margin-left: 12px;
padding: 20px 32px 20px 20px;
background: #f0f0f0;
border-left: 4px solid #ff0033;
}
/* 高对比图片,无滤镜但增强 */
img:not(.logo img),
.arco-image img,
.preview-image {
filter: contrast(1.15) saturate(1.1);
border: 3px solid #2c2c2c;
box-shadow: 6px 6px 0 #2c2c2c;
image-rendering: crisp-edges;
}
/* 滚动条适配粗野风格 - 硬边 */
::-webkit-scrollbar {
width: 12px;
height: 12px;
background: #f0f0f0;
border: 2px solid #2c2c2c;
}
::-webkit-scrollbar-thumb {
background: #2c2c2c;
border: 2px solid #f0f0f0;
box-shadow: 2px 2px 0 #ff0033;
}
::-webkit-scrollbar-track {
background: #e0e0e0;
border: 2px solid #2c2c2c;
}
/* 强调冲突色 */
.accent {
color: #ff0033;
font-weight: 900;
}
.logo-wrap {
border: 2px solid #2c2c2c;
padding: 4px;
box-shadow: 4px 4px 0 #2c2c2c;
}

View File

@ -1,10 +1,16 @@
body { body {
// 背景色 - 浅 // 新粗野主义风格 - 浅灰背景,硬边框
--mf-color-bg-3: #f8f8fa; --mf-color-bg-3: #f0f0f0;
--his-border-color: rgb(166, 124, 82, 0.3); --his-border-color: #2c2c2c;
--primary-6: 255 0 51; /* #ff0033 鲜红强调色 */
--color-text-1: #2c2c2c;
--color-text-2: #2c2c2c;
--color-bg-1: #f0f0f0;
--color-bg-2: #e0e0e0;
} }
body[arco-theme='dark'] { body[arco-theme='dark'] {
// 背景色 - 浅 /* 保留暗黑支持,但优先粗野主义浅色 */
--mf-color-bg-3: #17171a; --mf-color-bg-3: #2c2c2c;
--primary-6: 255 0 51;
} }

View File

@ -14,6 +14,11 @@ export default {
generateImage: 'Generate Now (costs {score} balance)', generateImage: 'Generate Now (costs {score} balance)',
generateImageNow: 'Generate Now', generateImageNow: 'Generate Now',
generateTip: 'Tip: After submission, you can view it in "My Works"', generateTip: 'Tip: After submission, you can view it in "My Works"',
aspectRatioLabel: 'Aspect ratio',
resolutionLabel: 'Resolution',
proResolutionRequired: 'Pro API requires resolution (1K / 2K / 4K)',
nanoTaskSubmitted: 'Task submitted. ID: {id}. Results will update when ready; you can also check "My Works".',
nanoTaskSubmittedNoId: 'Task submitted. Check "My Works" later for results.',
generateVideo: 'Generate Video', generateVideo: 'Generate Video',
imageFace: 'Image Face Swap', imageFace: 'Image Face Swap',
videoFace: 'Video Face Swap', videoFace: 'Video Face Swap',

View File

@ -8,5 +8,6 @@ export default {
fastVideo: 'Gen Video', fastVideo: 'Gen Video',
recharge: 'Quick Recharge', recharge: 'Quick Recharge',
help: 'Help Center', help: 'Help Center',
moneyInvite: 'Reward Invitation' moneyInvite: 'Reward Invitation',
'AI文生图': 'AI Text-to-Image'
} }

View File

@ -2,25 +2,13 @@ import { createI18n } from 'vue-i18n'
import Cookies from 'js-cookie' import Cookies from 'js-cookie'
import zh_HK from '@/lang/zh_HK/index.js' import zh_HK from '@/lang/zh_HK/index.js'
import en_US from '@/lang/en_US/index.js' import en_US from '@/lang/en_US/index.js'
import es_ES from '@/lang/es_ES/index.js'
import pt_BR from '@/lang/pt_BR/index.js'
import hi_IN from '@/lang/hi_IN/index.js'
import ru_RU from '@/lang/ru_RU/index.js'
import ar_SA from '@/lang/ar_SA/index.js'
import fr_FR from '@/lang/fr_FR/index.js'
let locale = Cookies.get('language') || 'en_US' let locale = Cookies.get('language') || 'en_US'
/** 各语言在界面上的显示名称 */ /** 各语言在界面上的显示名称 - 仅保留繁体中文和英文 */
export const LOCALE_NAMES = { export const LOCALE_NAMES = {
zh_HK: '繁体中文', zh_HK: '繁体中文',
en_US: 'English', en_US: 'English'
es_ES: 'Español',
pt_BR: 'Português',
hi_IN: 'हिन्दी',
ru_RU: 'Русский',
ar_SA: 'العربية',
fr_FR: 'Français'
} }
const i18n = createI18n({ const i18n = createI18n({
@ -28,13 +16,7 @@ const i18n = createI18n({
locale, locale,
messages: { messages: {
zh_HK, zh_HK,
en_US, en_US
es_ES,
pt_BR,
hi_IN,
ru_RU,
ar_SA,
fr_FR
} }
}) })

View File

@ -14,6 +14,11 @@ export default {
generateImage: '立即生成(消耗 {score} 餘額)', generateImage: '立即生成(消耗 {score} 餘額)',
generateImageNow: '立即生成', generateImageNow: '立即生成',
generateTip: '溫馨提示:提交後可在「我的作品」中查看', generateTip: '溫馨提示:提交後可在「我的作品」中查看',
aspectRatioLabel: '寬高比',
resolutionLabel: '解析度',
proResolutionRequired: 'Pro 接口必須選擇解析度1K / 2K / 4K',
nanoTaskSubmitted: '任務已提交,任務 ID{id}。生成完成後結果將更新,也可在「我的作品」查看。',
nanoTaskSubmittedNoId: '任務已提交,請稍後在「我的作品」查看結果。',
generateVideo: '生成視頻', generateVideo: '生成視頻',
imageFace: '圖片換臉', imageFace: '圖片換臉',
videoFace: '視頻換臉', videoFace: '視頻換臉',

View File

@ -8,5 +8,6 @@ export default {
fastVideo: '快捷生視頻', fastVideo: '快捷生視頻',
recharge: '快速充值', recharge: '快速充值',
help: '幫助中心', help: '幫助中心',
moneyInvite: '有獎邀請' moneyInvite: '有獎邀請',
'AI文生图': 'AI 文生圖'
} }

View File

@ -246,8 +246,12 @@ export default {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
box-sizing: content-box; box-sizing: content-box;
padding-left: 30px; padding-left: 24px;
padding-right: 30px; padding-right: 24px;
height: 72px;
background: #f0f0f0;
border-bottom: 4px solid #2c2c2c;
box-shadow: 0 4px 0 #2c2c2c;
.left { .left {
display: flex; display: flex;
@ -256,7 +260,9 @@ export default {
&-collapse { &-collapse {
display: none; display: none;
transition: 0.25s; transition: 0.1s linear;
color: #2c2c2c;
font-size: 28px;
&.isCollapse { &.isCollapse {
transform: rotate(-180deg); transform: rotate(-180deg);
@ -267,34 +273,46 @@ export default {
padding-left: 8px; padding-left: 8px;
display: flex; display: flex;
align-items: center; align-items: center;
height: 60px; height: 72px;
.logo { .logo {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
cursor: pointer; cursor: pointer;
color: var(--color-text-1); color: #2c2c2c;
font-weight: 900;
letter-spacing: -1px;
font-size: 26px;
text-transform: uppercase;
&-wrap { &-wrap {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 4px 12px;
border: 3px solid #2c2c2c;
box-shadow: 4px 4px 0 #ff0033;
background: #f0f0f0;
} }
} }
} }
} }
.right-menu { .right-menu {
height: 60px; height: 72px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
flex: 1; flex: 1;
gap: 12px;
&-item { &-item {
display: flex; display: flex;
align-items: center; align-items: center;
height: 60px; height: 48px;
margin-right: 12px; margin-right: 8px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.5px;
&:last-child { &:last-child {
margin-right: 0; margin-right: 0;
@ -302,45 +320,57 @@ export default {
&.language { &.language {
.mf-button { .mf-button {
font-size: 14px; font-size: 15px;
color: #999999; color: #2c2c2c;
background-color: transparent; background-color: #f0f0f0;
border: 2px solid #2c2c2c;
box-shadow: 4px 4px 0 #2c2c2c;
&:hover { &:hover {
background-color: transparent; background-color: #ff0033;
color: #f0f0f0;
transform: translate(2px, 2px);
box-shadow: 2px 2px 0 #2c2c2c;
} }
} }
} }
&.logout { &.logout {
.mf-button { .mf-button {
background-color: transparent; background-color: #f0f0f0;
border: 2px solid #2c2c2c;
color: #2c2c2c;
box-shadow: 4px 4px 0 #2c2c2c;
&:hover { &:hover {
background-color: transparent; background-color: #ff0033;
color: #f0f0f0;
transform: translate(2px, 2px);
box-shadow: 2px 2px 0 #2c2c2c;
} }
} }
} }
.login { .login {
width: 100px; width: 110px;
border-radius: 10px;
cursor: pointer; cursor: pointer;
/* 粗野主义按钮由全局base.less样式覆盖 */
} }
&.user { &.user {
width: 150px; width: 170px;
color: rgb(var(--primary-6)); color: #2c2c2c;
border: 2px solid transparent; height: 44px;
height: 32px;
border: 1px solid #5c5d68;
background: #26272e;
border-radius: 16px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding-left: 16px; padding-left: 12px;
padding-right: 4px; padding-right: 8px;
cursor: pointer; cursor: pointer;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.5px;
border: 2px solid #2c2c2c;
background: #f0f0f0;
box-shadow: 4px 4px 0 #2c2c2c;
span { span {
display: flex; display: flex;
@ -348,6 +378,7 @@ export default {
flex-grow: 1; flex-grow: 1;
.arco-image { .arco-image {
margin-right: 8px; margin-right: 8px;
border: 1px solid #2c2c2c;
} }
} }
} }
@ -356,54 +387,57 @@ export default {
} }
.nav-point-name { .nav-point-name {
color: var(--color-text-3); color: #2c2c2c;
font-weight: 900;
} }
.user-info { .user-info {
width: 220px; width: 240px;
margin-left: -8px; margin-left: -8px;
border: 2px solid #2c2c2c;
background: #f0f0f0;
box-shadow: 4px 4px 0 #2c2c2c;
.arco-dropdown-option { .arco-dropdown-option {
.mf-divider { .mf-divider {
margin: 5px 0; margin: 8px 0;
border-color: #2c2c2c;
} }
.user-info-wrap { .user-info-wrap {
padding-top: 8px; padding: 16px;
padding-bottom: 8px; border: 2px solid #2c2c2c;
background: #f0f0f0;
.mf-avatar { .mf-avatar {
margin-right: 8px; margin-right: 12px;
flex-shrink: 0; flex-shrink: 0;
border: 2px solid #2c2c2c;
} }
.user-info-name { .user-info-name {
width: 150px; width: 160px;
font-weight: 600; font-weight: 900;
color: var(--color-text-1); color: #2c2c2c;
font-size: 14px; font-size: 16px;
margin-bottom: 0px; letter-spacing: -0.5px;
} margin-bottom: 4px;
.user-info-company {
width: 150px;
font-size: 12px;
color: var(--color-text-3);
// margin-top: 3px;
margin-bottom: 0px;
} }
.user-info-company,
.user-info-service { .user-info-service {
width: 150px; width: 160px;
font-size: 12px; font-size: 13px;
color: var(--color-text-4); color: #2c2c2c;
// margin-top: 3px; font-weight: 700;
margin-bottom: 0px; margin-bottom: 4px;
letter-spacing: -0.3px;
} }
} }
&:first-child { &:first-child {
&:hover { &:hover {
background-color: transparent; background-color: #ff0033;
color: #f0f0f0;
} }
cursor: text; cursor: text;
} }
@ -412,11 +446,13 @@ export default {
@media (max-width: 576px) { @media (max-width: 576px) {
.navbar { .navbar {
padding: 0 10px; padding: 0 12px;
height: auto;
.left { .left {
&-collapse { &-collapse {
display: block; display: block;
font-size: 18px; font-size: 24px;
color: #2c2c2c;
} }
&-menu { &-menu {
@ -424,15 +460,13 @@ export default {
.logo { .logo {
display: none; display: none;
} }
.mf-icon {
color: #fff;
}
} }
} }
.right-menu-item { .right-menu-item {
&.user { &.user {
width: 136px; width: 140px;
font-size: 13px;
} }
} }
} }

View File

@ -110,65 +110,52 @@ export default {
.sidebar-container { .sidebar-container {
height: 100%; height: 100%;
position: relative; position: relative;
transition: width 0.3s; transition: width 0.1s linear;
z-index: 12; z-index: 12;
background-color: #000; background-color: #f0f0f0;
border-right: 1px solid; /* 宽度随你调整 */ border-right: 4px solid #2c2c2c;
border-image: linear-gradient( box-shadow: 4px 0 0 #ff0033; /* 非对称强调 */
to bottom,
#0f0f12 0%,
rgba(255,255,255, 0.7) 40%,
rgba(255,255,255, 0.7) 60%,
#0f0f12 100%
)
1 100%;
&.collapsed { &.collapsed {
width: 50px !important; width: 56px !important;
overflow: hidden; overflow: hidden;
:deep(.arco-menu) { :deep(.arco-menu) {
padding-left: 0px; padding-left: 4px;
} }
} }
:deep(.arco-menu) { :deep(.arco-menu) {
background-color: transparent; background-color: #f0f0f0;
padding-left: 16px; padding-left: 8px;
border: 2px solid #2c2c2c;
&-item { &-item {
width: 180px; width: 180px;
background-color: transparent; background-color: #f0f0f0;
color: rgba(255, 255, 255, 0.7); color: #2c2c2c;
border-radius: 10px; border: 2px solid #2c2c2c;
margin-bottom: 12px; margin: 8px 4px;
padding: 14px 18px;
font-weight: 900;
font-size: 15px;
box-shadow: 3px 3px 0 #2c2c2c;
transition: all 0.1s linear;
&.arco-menu-selected, &.arco-menu-selected,
&:hover { &:hover {
background-color: rgb(var(--primary-6)); background-color: #ff0033 !important;
color: #f0f0f0 !important;
border-color: #2c2c2c;
transform: translate(3px, 3px);
box-shadow: 1px 1px 0 #2c2c2c;
} }
} }
} }
/* 隐藏旧的toggle如果需要可重新启用 */
.toogle-menu { .toogle-menu {
position: absolute; display: none;
bottom: 16px;
right: 10px;
.navicon {
color: #fff;
transition: 0.25s;
padding: 5px;
height: auto;
line-height: normal;
&:hover {
background-color: rgba(255, 255, 255, 0.2);
}
&.isCollapsed {
transform: rotate(-180deg);
}
}
} }
} }
@ -176,10 +163,11 @@ export default {
.sidebar-container { .sidebar-container {
position: fixed; position: fixed;
left: 0px; left: 0px;
top: 60px; top: 72px;
z-index: 10; z-index: 10;
height: calc(100% - 60px); height: calc(100% - 72px);
overflow: hidden; overflow: hidden;
border-right: 4px solid #2c2c2c;
&.collapsed { &.collapsed {
width: 0px !important; width: 0px !important;

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="app-wrapper"> <div class="app-wrapper brutalist">
<div class="fixed-header"> <div class="fixed-header">
<nav-bar class="nav-bar" /> <nav-bar class="nav-bar" />
</div> </div>
@ -47,18 +47,26 @@ export default {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
background: #0f0f12; background: #f0f0f0;
.fixed-header { .fixed-header {
z-index: 100; z-index: 100;
border-bottom: 4px solid #2c2c2c;
.navbar { .navbar {
height: 60px; height: 72px;
} }
} }
.main-wrapper { .main-wrapper {
display: flex; display: flex;
height: calc(100% - 60px); height: calc(100% - 72px);
/* 非对称布局:侧边栏略微偏移,主内容有强调边框 */
.sidebar-wrapper {
border-right: 4px solid #ff0033;
margin-right: -8px; /* 轻微非对称重叠感 */
z-index: 20;
}
} }
} }
</style> </style>

View File

@ -69,49 +69,47 @@ export const constantRoutes = [{
permission: "pass", permission: "pass",
icon: 'btn_tst' icon: 'btn_tst'
} }
}, }, {
// { path: 'image-to-image2',
// path: 'image-to-image?type=2', name: 'image-to-image2',
// name: 'image-to-image2', component: () => import('@/views/Image.vue'),
// component: () => import('@/views/Image.vue'), meta: {
// meta: { title: 'imageToImage2',
// title: 'imageToImage2', menuItem: true,
// menuItem: true, permission: "pass",
// permission: "pass", icon: 'btn_tst'
// icon: 'btn_tst' }
// } }, {
// }, { path: 'change-face',
// path: 'change-face', name: 'change-face',
// name: 'change-face', component: () => import('@/views/ChangeFace.vue'),
// component: () => import('@/views/ChangeFace.vue'), meta: {
// meta: { title: 'changeFace',
// title: 'changeFace', menuItem: true,
// menuItem: true, permission: "pass",
// permission: "pass", icon: 'btn_yjhl'
// icon: 'btn_yjhl' }
// } }, {
// }, { path: 'change-face-video',
// path: 'change-face-video', name: 'change-face-video',
// name: 'change-face-video', component: () => import('@/views/ChangeFace.vue'),
// component: () => import('@/views/ChangeFace.vue'), meta: {
// meta: { title: 'changeFaceVideo',
// title: 'changeFaceVideo', menuItem: true,
// menuItem: true, permission: "pass",
// permission: "pass", icon: 'btn_yjhl'
// icon: 'btn_yjhl' }
// } }, {
// }, { path: 'fast-image',
// path: 'fast-image', name: 'fast-image',
// name: 'fast-image', component: () => import('@/views/FastImage.vue'),
// component: () => import('@/views/FastImage.vue'), meta: {
// meta: { title: 'fastImage',
// title: 'fastImage', menuItem: true,
// menuItem: true, permission: "pass",
// permission: "pass", icon: 'btn_kjst'
// icon: 'btn_kjst' }
// } }, {
// },
{
path: 'fast-video', path: 'fast-video',
name: 'fast-video', name: 'fast-video',
component: () => import('@/views/FastVideo.vue'), component: () => import('@/views/FastVideo.vue'),

View File

@ -19,6 +19,16 @@
</a-select> </a-select>
</div> </div>
<!-- NanoBanana 接口版本 -->
<div class="field">
<label>NanoBanana 接口</label>
<a-select v-model:value="formState.nanoApiType" style="width: 100%">
<a-select-option value="v1">V1官方 /generate</a-select-option>
<a-select-option value="v2">V2/generate-2</a-select-option>
<a-select-option value="pro">Pro/generate-pro</a-select-option>
</a-select>
</div>
<!-- 提示词 --> <!-- 提示词 -->
<div class="field full-width"> <div class="field full-width">
<label>描述提示词 <span class="required">*</span></label> <label>描述提示词 <span class="required">*</span></label>
@ -42,9 +52,9 @@
</a-select> </a-select>
</div> </div>
<!-- 分辨率 --> <!-- 分辨率Pro 必选 1K/2K/4K -->
<div class="field"> <div class="field">
<label>分辨率</label> <label>分辨率 <span v-if="formState.nanoApiType === 'pro'" class="required">*</span></label>
<a-select v-model:value="formState.resolution" style="width: 100%"> <a-select v-model:value="formState.resolution" style="width: 100%">
<a-select-option value="1K">1K (标准)</a-select-option> <a-select-option value="1K">1K (标准)</a-select-option>
<a-select-option value="2K">2K (高清)</a-select-option> <a-select-option value="2K">2K (高清)</a-select-option>
@ -114,12 +124,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { generateImage, getAiManagerInfo } from '@/api/ai' import { nanoBananaGenerate, getAiManagerInfo } from '@/api/ai'
import { message } from 'ant-design-vue' import { Message } from '@arco-design/web-vue'
import type { ColumnType } from 'ant-design-vue/es/table'
const formState = reactive({ const formState = reactive({
functionType: '1', functionType: '1',
nanoApiType: 'v2',
text: '', text: '',
aspectRatio: 'auto', aspectRatio: 'auto',
resolution: '1K', resolution: '1K',
@ -133,7 +143,15 @@ const taskId = ref('')
const status = ref(0) const status = ref(0)
const historyList = ref<any[]>([]) const historyList = ref<any[]>([])
const columns: ColumnType[] = [ interface TableColumn {
title: string
dataIndex?: string
key?: string
width?: number
ellipsis?: boolean
}
const columns: TableColumn[] = [
{ title: 'ID', dataIndex: 'id', width: 80 }, { title: 'ID', dataIndex: 'id', width: 80 },
{ title: '提示词', dataIndex: 'text', ellipsis: true }, { title: '提示词', dataIndex: 'text', ellipsis: true },
{ title: '图像', key: 'image', width: 100 }, { title: '图像', key: 'image', width: 100 },
@ -152,7 +170,11 @@ const loadAiInfo = async () => {
const generateImageFn = async () => { const generateImageFn = async () => {
if (!formState.text.trim()) { if (!formState.text.trim()) {
message.warning('请输入提示词') Message.warning('请输入提示词')
return
}
if (formState.nanoApiType === 'pro' && !formState.resolution) {
Message.warning('Pro 接口必须选择分辨率1K / 2K / 4K')
return return
} }
@ -160,25 +182,26 @@ const generateImageFn = async () => {
try { try {
const params = { const params = {
functionType: formState.functionType, functionType: formState.functionType,
nanoApiType: formState.nanoApiType,
text: formState.text, text: formState.text,
aspectRatio: formState.aspectRatio, aspectRatio: formState.aspectRatio,
resolution: formState.resolution, resolution: formState.resolution,
tags: formState.tags tags: formState.tags
} }
const res = await generateImage(params) const res = await nanoBananaGenerate(params)
if (res.code === 200) { if (res.code === 200) {
taskId.value = res.data || res.msg taskId.value = res.data || res.msg
message.success('任务已提交任务ID: ' + taskId.value) Message.success('任务已提交任务ID: ' + taskId.value)
resultImage.value = '' // resultImage.value = '' //
// 使WebSocket // 使WebSocket
pollResult(taskId.value) pollResult(taskId.value)
} else { } else {
message.error(res.msg || '生成失败') Message.error(res.msg || '生成失败')
} }
} catch (error: any) { } catch (error: any) {
message.error(error.message || '请求失败请检查网络或Token配置') Message.error(error.message || '请求失败请检查网络或Token配置')
} finally { } finally {
loading.value = false loading.value = false
} }
@ -190,7 +213,7 @@ const pollResult = (tid: string) => {
count++ count++
if (count > 30) { // 30 if (count > 30) { // 30
clearInterval(timer) clearInterval(timer)
message.warning('生成时间较长,请稍后在「我的作品」中查看') Message.warning('生成时间较长,请稍后在「我的作品」中查看')
return return
} }
// //
@ -205,7 +228,7 @@ const pollResult = (tid: string) => {
status: 1, status: 1,
createTime: new Date().toLocaleString() createTime: new Date().toLocaleString()
}) })
message.success('图像生成成功!') Message.success('图像生成成功!')
} }
}, 1500) }, 1500)
} }
@ -220,7 +243,7 @@ const resetForm = () => {
const copyImageUrl = () => { const copyImageUrl = () => {
if (resultImage.value) { if (resultImage.value) {
navigator.clipboard.writeText(resultImage.value) navigator.clipboard.writeText(resultImage.value)
message.success('链接已复制') Message.success('链接已复制')
} }
} }
@ -231,37 +254,47 @@ onMounted(() => {
<style scoped lang="less"> <style scoped lang="less">
.generated-images-page { .generated-images-page {
padding: 24px; padding: 32px;
background: #1a1f2e; background: #f0f0f0;
min-height: 100vh; min-height: 100vh;
color: #ddd; color: #2c2c2c;
/* 继承全局粗野主义:硬边、阴影、字体 */
.page-header { .page-header {
margin-bottom: 24px; margin-bottom: 32px;
border-bottom: 4px solid #2c2c2c;
padding-bottom: 16px;
.panel-title { .panel-title {
font-size: 24px; font-size: 32px;
font-weight: 600; font-weight: 900;
color: #fff; letter-spacing: -2px;
text-transform: uppercase;
color: #2c2c2c;
.subtitle { .subtitle {
font-size: 14px; font-size: 15px;
color: #888; color: #ff0033;
margin-left: 12px; margin-left: 16px;
font-weight: normal; font-weight: 900;
letter-spacing: -0.5px;
} }
} }
} }
.query-section { .query-section,
background: rgba(255,255,255,0.95); .result-section,
border-radius: 12px; .history-section {
padding: 24px; background: #f0f0f0;
margin-bottom: 24px; border: 3px solid #2c2c2c;
box-shadow: 0 2px 8px rgba(0,0,0,0.1); padding: 28px;
margin-bottom: 32px;
box-shadow: 6px 6px 0 #2c2c2c;
/* 全局已覆盖圆角为0 */
.form-grid { .form-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px; gap: 24px;
} }
.full-width { .full-width {
@ -270,53 +303,64 @@ onMounted(() => {
label { label {
display: block; display: block;
margin-bottom: 8px; margin-bottom: 12px;
font-weight: 500; font-weight: 900;
color: #1a1f2e; color: #2c2c2c;
text-transform: uppercase;
font-size: 14px;
letter-spacing: 0.5px;
} }
.required { .required {
color: #ff4d4f; color: #ff0033;
} }
} }
.actions { .actions {
margin-top: 24px; margin-top: 24px;
display: flex; display: flex;
gap: 12px; gap: 16px;
}
.result-section, .history-section {
background: rgba(255,255,255,0.95);
border-radius: 12px;
padding: 24px;
margin-top: 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
} }
.result-image { .result-image {
max-width: 100%; max-width: 100%;
border-radius: 8px; border: 4px solid #2c2c2c;
box-shadow: 0 4px 20px rgba(0,0,0,0.15); box-shadow: 8px 8px 0 #2c2c2c;
image-rendering: crisp-edges;
/* 高对比 by global */
} }
.task-info { .task-info {
margin-top: 16px; margin-top: 20px;
padding: 12px; padding: 16px;
background: #f5f5f5; background: #e8e8e8;
border-radius: 6px; border: 2px solid #2c2c2c;
font-size: 13px; box-shadow: 4px 4px 0 #ff0033;
color: #555; font-size: 14px;
color: #2c2c2c;
font-weight: 700;
} }
.asset-table { .asset-table {
margin-top: 16px; margin-top: 24px;
} }
} }
.brutalist-card {
border: 3px solid #2c2c2c;
box-shadow: 6px 6px 0 #2c2c2c;
}
/* 确保图片和按钮在视图中匹配 */
.result-image,
img {
border: 4px solid #2c2c2c !important;
box-shadow: 6px 6px 0 #2c2c2c !important;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.generated-images-page { .generated-images-page {
padding: 16px; padding: 20px;
} }
.form-grid { .form-grid {
grid-template-columns: 1fr !important; grid-template-columns: 1fr !important;

View File

@ -68,6 +68,38 @@
v-model="text" v-model="text"
:placeholder="$t('common.textPlaceholder')" /> :placeholder="$t('common.textPlaceholder')" />
</div> --> </div> -->
<div class="nano-options">
<div class="nano-row">
<div class="nano-label">NanoBanana</div>
<a-select v-model="nanoApiType" style="width: 100%">
<a-select-option value="v1">V1/generate</a-select-option>
<a-select-option value="v2">V2/generate-2</a-select-option>
<a-select-option value="pro">Pro/generate-pro</a-select-option>
</a-select>
</div>
<div class="nano-row">
<div class="nano-label">{{ $t('common.aspectRatioLabel') }}</div>
<a-select v-model="aspectRatio" style="width: 100%">
<a-select-option value="auto">auto</a-select-option>
<a-select-option value="1:1">1:1</a-select-option>
<a-select-option value="16:9">16:9</a-select-option>
<a-select-option value="9:16">9:16</a-select-option>
<a-select-option value="4:3">4:3</a-select-option>
<a-select-option value="3:2">3:2</a-select-option>
</a-select>
</div>
<div class="nano-row">
<div class="nano-label">
{{ $t('common.resolutionLabel') }}
<span v-if="nanoApiType === 'pro'" class="required-star">*</span>
</div>
<a-select v-model="resolution" style="width: 100%">
<a-select-option value="1K">1K</a-select-option>
<a-select-option value="2K">2K</a-select-option>
<a-select-option value="4K">4K</a-select-option>
</a-select>
</div>
</div>
<mf-button <mf-button
class="submit" class="submit"
type="primary" type="primary"
@ -93,12 +125,16 @@
@click="close" /> @click="close" />
</div> </div>
<a-image <a-image
v-if="isResultImageUrl"
class="result-image" class="result-image"
fit="contain" fit="contain"
:src="imageUrl" /> :src="imageUrl" />
<div v-else-if="taskIdHint" class="task-id-hint">
{{ taskIdHint }}
</div>
<div <div
class="action" class="action"
v-show="imageUrl"> v-show="imageUrl && isResultImageUrl">
<mf-button @click="saveImage"> <mf-button @click="saveImage">
<a-image <a-image
:width="20" :width="20"
@ -157,6 +193,7 @@
<script> <script>
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
import { nanoBananaImgToImg } from '@/api/ai'
export default { export default {
data() { data() {
@ -167,6 +204,10 @@ export default {
current: 1, current: 1,
generateLoading: false, generateLoading: false,
imageUrl: '', imageUrl: '',
taskIdHint: '',
nanoApiType: 'v2',
aspectRatio: 'auto',
resolution: '1K',
price: null, price: null,
tags: [], tags: [],
selectedTags: {}, selectedTags: {},
@ -184,12 +225,27 @@ export default {
}, },
computed: { computed: {
...mapGetters(['lang']), ...mapGetters(['lang']),
isResultImageUrl() {
const u = this.imageUrl
if (!u || typeof u !== 'string') return false
return u.startsWith('http://') || u.startsWith('https://') || u.startsWith('/api') || u.startsWith('/')
}
}, },
watch: { watch: {
$route: { $route: {
handler(from, to) { handler(to) {
let type = to?.query?.type || 1 if (to.name === 'image-to-image2') {
this.current = type this.current = 2
} else if (to.name === 'image-to-image') {
const q = to.query?.type
if (!q) {
this.current = 1
}
}
const type = to.query?.type
if (type !== undefined && type !== null && type !== '') {
this.current = Number(type) || 1
}
}, },
deep: true, deep: true,
immediate: true immediate: true
@ -199,9 +255,12 @@ export default {
} }
}, },
mounted() { mounted() {
if (this.$route.name === 'image-to-image2') {
this.current = 2
}
let type = this.$datas.getQueryString('type', this.$route.fullPath) let type = this.$datas.getQueryString('type', this.$route.fullPath)
if (type) { if (type) {
this.current = type || 1 this.current = Number(type) || 1
} }
let { text } = this.$route.query || {} let { text } = this.$route.query || {}
if (text) { if (text) {
@ -369,6 +428,10 @@ export default {
this.$message.error(this.$t('common.uploadImageError')) this.$message.error(this.$t('common.uploadImageError'))
return return
} }
if (this.nanoApiType === 'pro' && !this.resolution) {
this.$message.warning(this.$t('common.proResolutionRequired'))
return
}
// if (!this.text && this.current == 2) { // if (!this.text && this.current == 2) {
// this.$message.error(this.$t('common.textError')) // this.$message.error(this.$t('common.textError'))
// return // return
@ -392,20 +455,31 @@ export default {
tags.push(item.id) tags.push(item.id)
}) })
this.generateLoading = true this.generateLoading = true
this.$axios({ this.taskIdHint = ''
url: 'api/ai/imgToImg', nanoBananaImgToImg({
method: 'POST',
data: {
text: this.text,
firstUrl: this.firstUrl.url,
functionType: this.current == 1 ? '11' : '12', functionType: this.current == 1 ? '11' : '12',
tags: tags.join(',') nanoApiType: this.nanoApiType,
} imageUrl: this.firstUrl.url,
text: this.text,
tags: tags.join(','),
aspectRatio: this.aspectRatio,
resolution: this.resolution,
numImages: 1
}) })
.then((res) => { .then((res) => {
this.generateLoading = false this.generateLoading = false
if (res.code == 200) { if (res.code == 200) {
this.imageUrl = res.msg const payload = res.data !== undefined && res.data !== null ? res.data : res.msg
const s = payload != null ? String(payload) : ''
if (s && (s.startsWith('http://') || s.startsWith('https://') || s.startsWith('/api') || s.startsWith('/'))) {
this.imageUrl = s
this.taskIdHint = ''
} else {
this.imageUrl = ''
this.taskIdHint = s
? this.$t('common.nanoTaskSubmitted', { id: s })
: this.$t('common.nanoTaskSubmittedNoId')
}
this.showResult = true this.showResult = true
} else if (res.code == -1) { } else if (res.code == -1) {
this.$confirm({ this.$confirm({
@ -451,6 +525,27 @@ export default {
overflow-y: auto; overflow-y: auto;
flex-shrink: 0; flex-shrink: 0;
.nano-options {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.nano-row {
margin-bottom: 14px;
}
.nano-label {
font-size: 13px;
color: #9ca3af;
margin-bottom: 6px;
}
.required-star {
color: #ff0033;
margin-left: 2px;
}
.submit { .submit {
width: 100%; width: 100%;
margin-top: 20px; margin-top: 20px;
@ -616,6 +711,14 @@ export default {
} }
} }
.task-id-hint {
padding: 24px;
color: #e5e7eb;
font-size: 14px;
line-height: 1.6;
max-width: 560px;
}
.action { .action {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -6,6 +6,8 @@ import com.ruoyi.ai.service.IAiOrderService;
import com.ruoyi.ai.service.IAiTagService; import com.ruoyi.ai.service.IAiTagService;
import com.ruoyi.ai.service.IByteService; import com.ruoyi.ai.service.IByteService;
import com.ruoyi.api.request.ByteApiRequest; import com.ruoyi.api.request.ByteApiRequest;
import com.ruoyi.api.request.NanoBananaPortalImgRequest;
import com.ruoyi.api.request.NanoBananaPortalRequest;
import com.ruoyi.common.annotation.Anonymous; import com.ruoyi.common.annotation.Anonymous;
import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.domain.AjaxResult;
@ -49,6 +51,174 @@ public class ByteApiController extends BaseController {
@Value("${nanobanana.token}") @Value("${nanobanana.token}")
private String nanoToken; private String nanoToken;
@PostMapping("/nano/generate")
@ApiOperation("Portal 文生图:按 nanoApiType 调用 v1/v2/pro")
@Transactional
public AjaxResult nanoGenerate(@RequestBody NanoBananaPortalRequest request) {
String functionType = request.getFunctionType();
if (StringUtils.isEmpty(functionType)) {
return AjaxResult.error("functionType is null");
}
if (StringUtils.isEmpty(request.getNanoApiType())) {
return AjaxResult.error("nanoApiType is null");
}
if ("pro".equalsIgnoreCase(request.getNanoApiType()) && StringUtils.isBlank(request.getResolution())) {
return AjaxResult.error("pro 模式必须指定 resolution1K、2K、4K");
}
AiManager aiManager = managerService.selectAiManagerByType(functionType);
if (aiManager == null) {
return AjaxResult.error("invalid functionType");
}
String text;
try {
text = resolvePromptForPortal(request.getText(), request.getTags(), aiManager);
} catch (IllegalArgumentException e) {
if ("TAG_MISMATCH".equals(e.getMessage())) {
return AjaxResult.error(-3, "Generation failed, please try again");
}
throw e;
}
if (StringUtils.isEmpty(text)) {
return AjaxResult.error("text is null");
}
String resolution = StringUtils.isNotBlank(request.getResolution())
? request.getResolution()
: "1K";
AiOrder aiOrder = aiOrderService.getAiOrder(functionType);
try {
if (aiOrder == null) {
return AjaxResult.error(-1, "You have a low balance, please recharge");
}
aiOrder.setText(text);
NanoBananaRequest nanoRequest = NanoBananaRequest.forTextToImage(
text,
nanoCallbackUrl,
request.getAspectRatio() != null ? request.getAspectRatio() : "auto",
resolution
);
if (request.getNumImages() != null) {
nanoRequest.setNumImages(request.getNumImages());
}
NanoBananaResponse nanoResponse = byteService.generateNanoBanana(request.getNanoApiType(), nanoRequest);
if (nanoResponse == null || nanoResponse.getCode() != 200 || nanoResponse.getData() == null) {
aiOrderService.orderFailure(aiOrder);
return AjaxResult.error(-2, "generation failed, balance has been refunded");
}
String taskId = nanoResponse.getData().getTaskId();
aiOrder.setTaskId(taskId);
aiOrder.setResult(taskId);
aiOrderService.orderSuccess(aiOrder);
return AjaxResult.success(taskId);
} catch (IllegalArgumentException e) {
return AjaxResult.error(e.getMessage());
} catch (Exception e) {
if (aiOrder != null) {
aiOrderService.orderFailure(aiOrder);
}
throw new RuntimeException(e);
}
}
@PostMapping("/nano/imgToImg")
@ApiOperation("Portal 图生图:按 nanoApiType 调用 v1/v2/pro")
@Transactional
public AjaxResult nanoImgToImg(@RequestBody NanoBananaPortalImgRequest request) {
String functionType = request.getFunctionType();
if (StringUtils.isEmpty(functionType)) {
return AjaxResult.error("functionType is null");
}
if (StringUtils.isEmpty(request.getNanoApiType())) {
return AjaxResult.error("nanoApiType is null");
}
if ("pro".equalsIgnoreCase(request.getNanoApiType()) && StringUtils.isBlank(request.getResolution())) {
return AjaxResult.error("pro 模式必须指定 resolution1K、2K、4K");
}
List<String> imageUrls = new ArrayList<>();
if (request.getImageUrls() != null && !request.getImageUrls().isEmpty()) {
for (String u : request.getImageUrls()) {
if (StringUtils.isNotEmpty(u)) {
imageUrls.add(u.trim());
}
}
} else if (StringUtils.isNotEmpty(request.getImageUrl())) {
imageUrls.add(request.getImageUrl().trim());
}
if (imageUrls.isEmpty()) {
return AjaxResult.error("请提供 imageUrl 或 imageUrls");
}
AiManager aiManager = managerService.selectAiManagerByType(functionType);
if (aiManager == null) {
return AjaxResult.error("invalid functionType");
}
String text;
try {
text = resolvePromptForPortal(request.getText(), request.getTags(), aiManager);
} catch (IllegalArgumentException e) {
if ("TAG_MISMATCH".equals(e.getMessage())) {
return AjaxResult.error(-3, "Generation failed, please try again");
}
throw e;
}
if (StringUtils.isEmpty(text)) {
return AjaxResult.error("text is null");
}
String resolution = StringUtils.isNotBlank(request.getResolution())
? request.getResolution()
: "1K";
AiOrder aiOrder = aiOrderService.getAiOrder(functionType);
try {
if (aiOrder == null) {
return AjaxResult.error(-1, "You have a low balance, please recharge");
}
aiOrder.setText(text);
aiOrder.setImg1(imageUrls.get(0));
NanoBananaRequest nanoRequest = NanoBananaRequest.forImageToImage(
text,
imageUrls,
nanoCallbackUrl,
request.getAspectRatio() != null ? request.getAspectRatio() : "auto",
resolution
);
if (request.getNumImages() != null) {
nanoRequest.setNumImages(request.getNumImages());
}
NanoBananaResponse nanoResponse = byteService.generateNanoBanana(request.getNanoApiType(), nanoRequest);
if (nanoResponse == null || nanoResponse.getCode() != 200 || nanoResponse.getData() == null) {
aiOrderService.orderFailure(aiOrder);
return AjaxResult.error(-2, "generation failed, balance has been refunded");
}
String taskId = nanoResponse.getData().getTaskId();
aiOrder.setTaskId(taskId);
aiOrder.setResult(taskId);
aiOrderService.orderSuccess(aiOrder);
return AjaxResult.success(taskId);
} catch (IllegalArgumentException e) {
return AjaxResult.error(e.getMessage());
} catch (Exception e) {
if (aiOrder != null) {
aiOrderService.orderFailure(aiOrder);
}
throw new RuntimeException(e);
}
}
@PostMapping("/promptToImg") @PostMapping("/promptToImg")
@ApiOperation("文生图") @ApiOperation("文生图")
public AjaxResult promptToImg(@RequestBody ByteApiRequest request) { public AjaxResult promptToImg(@RequestBody ByteApiRequest request) {
@ -58,31 +228,29 @@ public class ByteApiController extends BaseController {
} }
AiManager aiManager = managerService.selectAiManagerByType(functionType); AiManager aiManager = managerService.selectAiManagerByType(functionType);
String tags = request.getTags(); if (aiManager == null) {
String text = ""; return AjaxResult.error("invalid functionType");
if (StringUtils.isNotEmpty(tags)) { }
List<AiTag> aiTags = aiTagService.selectAiTagListByIds(request.getTags(), aiManager.getParentIdSort()); String text;
List<String> tagPrompts = new ArrayList<>(); try {
for (AiTag aiTag : aiTags) { text = resolvePromptForPortal(request.getText(), request.getTags(), aiManager);
if (!aiTag.getAiId().equals(aiManager.getId())) { } catch (IllegalArgumentException e) {
if ("TAG_MISMATCH".equals(e.getMessage())) {
return AjaxResult.error(-3, "Generation failed, please try again"); return AjaxResult.error(-3, "Generation failed, please try again");
} }
String p = ""; throw e;
if (StringUtils.isNotEmpty(aiTag.getPrompt())) {
String[] prompts = aiTag.getPrompt().split(Pattern.quote("^"));
int randomNumberWithin = RandomStringUtil.getRandomNumberWithin(prompts.length);
p = prompts[randomNumberWithin];
}
tagPrompts.add(p);
}
text = StringUtils.replacePlaceholders(aiManager.getPrompt(), tagPrompts);
} else {
text = aiManager.getPrompt();
} }
if (StringUtils.isEmpty(text)) { if (StringUtils.isEmpty(text)) {
return AjaxResult.error("text is null"); return AjaxResult.error("text is null");
} }
String nanoType = StringUtils.isNotEmpty(request.getNanoApiType()) ? request.getNanoApiType() : "v2";
if ("pro".equalsIgnoreCase(nanoType) && StringUtils.isBlank(request.getResolution())) {
return AjaxResult.error("pro 模式必须指定 resolution1K、2K、4K");
}
String resolution = StringUtils.isNotBlank(request.getResolution())
? request.getResolution()
: "1K";
AiOrder aiOrder = aiOrderService.getAiOrder(functionType); AiOrder aiOrder = aiOrderService.getAiOrder(functionType);
try { try {
@ -96,11 +264,9 @@ public class ByteApiController extends BaseController {
text, text,
nanoCallbackUrl, nanoCallbackUrl,
request.getAspectRatio() != null ? request.getAspectRatio() : "auto", request.getAspectRatio() != null ? request.getAspectRatio() : "auto",
request.getResolution() != null ? request.getResolution() : "1K" resolution
); );
NanoBananaResponse nanoResponse = byteService.generateNanoBanana(nanoType, nanoRequest);
// 5. 调用NanoBanana接口 (curl POST https://api.nanobananaapi.ai/...)
NanoBananaResponse nanoResponse = byteService.generateImage(nanoRequest);
if (nanoResponse == null || nanoResponse.getCode() != 200 || nanoResponse.getData() == null) { if (nanoResponse == null || nanoResponse.getCode() != 200 || nanoResponse.getData() == null) {
aiOrderService.orderFailure(aiOrder); aiOrderService.orderFailure(aiOrder);
@ -130,26 +296,17 @@ public class ByteApiController extends BaseController {
} }
AiManager aiManager = managerService.selectAiManagerByType(functionType); AiManager aiManager = managerService.selectAiManagerByType(functionType);
String tags = request.getTags(); if (aiManager == null) {
String text = ""; return AjaxResult.error("invalid functionType");
if (StringUtils.isNotEmpty(tags)) { }
List<AiTag> aiTags = aiTagService.selectAiTagListByIds(request.getTags(), aiManager.getParentIdSort()); String text;
List<String> tagPrompts = new ArrayList<>(); try {
for (AiTag aiTag : aiTags) { text = resolvePromptForPortal(request.getText(), request.getTags(), aiManager);
if (!aiTag.getAiId().equals(aiManager.getId())) { } catch (IllegalArgumentException e) {
if ("TAG_MISMATCH".equals(e.getMessage())) {
return AjaxResult.error(-3, "Generation failed, please try again"); return AjaxResult.error(-3, "Generation failed, please try again");
} }
String p = ""; throw e;
if (StringUtils.isNotEmpty(aiTag.getPrompt())) {
String[] prompts = aiTag.getPrompt().split(Pattern.quote("^"));
int randomNumberWithin = RandomStringUtil.getRandomNumberWithin(prompts.length);
p = prompts[randomNumberWithin];
}
tagPrompts.add(p);
}
text = StringUtils.replacePlaceholders(aiManager.getPrompt(), tagPrompts);
} else {
text = aiManager.getPrompt();
} }
if (StringUtils.isEmpty(text)) { if (StringUtils.isEmpty(text)) {
@ -160,6 +317,15 @@ public class ByteApiController extends BaseController {
if (null == firstUrl) { if (null == firstUrl) {
return AjaxResult.error("firstUrl is null"); return AjaxResult.error("firstUrl is null");
} }
String nanoType = StringUtils.isNotEmpty(request.getNanoApiType()) ? request.getNanoApiType() : "v2";
if ("pro".equalsIgnoreCase(nanoType) && StringUtils.isBlank(request.getResolution())) {
return AjaxResult.error("pro 模式必须指定 resolution1K、2K、4K");
}
String resolution = StringUtils.isNotBlank(request.getResolution())
? request.getResolution()
: "1K";
AiOrder aiOrder = aiOrderService.getAiOrder(functionType); AiOrder aiOrder = aiOrderService.getAiOrder(functionType);
try { try {
if (aiOrder == null) { if (aiOrder == null) {
@ -180,11 +346,9 @@ public class ByteApiController extends BaseController {
imageUrls, imageUrls,
nanoCallbackUrl, nanoCallbackUrl,
request.getAspectRatio() != null ? request.getAspectRatio() : "auto", request.getAspectRatio() != null ? request.getAspectRatio() : "auto",
request.getResolution() != null ? request.getResolution() : "1K" resolution
); );
NanoBananaResponse nanoResponse = byteService.generateNanoBanana(nanoType, nanoRequest);
// 5. 调用NanoBanana接口
NanoBananaResponse nanoResponse = byteService.generateImageWithReference(nanoRequest);
if (nanoResponse == null || nanoResponse.getCode() != 200 || nanoResponse.getData() == null) { if (nanoResponse == null || nanoResponse.getCode() != 200 || nanoResponse.getData() == null) {
aiOrderService.orderFailure(aiOrder); aiOrderService.orderFailure(aiOrder);
@ -324,6 +488,36 @@ public class ByteApiController extends BaseController {
return AjaxResult.success(byteBodyRes); return AjaxResult.success(byteBodyRes);
} }
/**
* 解析提示词优先 request.text否则按标签替换模板否则使用 AiManager 默认 prompt
*/
private String resolvePromptForPortal(String explicitText, String tags, AiManager aiManager) {
if (aiManager == null) {
return null;
}
if (StringUtils.isNotEmpty(explicitText)) {
return explicitText;
}
if (StringUtils.isNotEmpty(tags)) {
List<AiTag> aiTags = aiTagService.selectAiTagListByIds(tags, aiManager.getParentIdSort());
List<String> tagPrompts = new ArrayList<>();
for (AiTag aiTag : aiTags) {
if (!aiTag.getAiId().equals(aiManager.getId())) {
throw new IllegalArgumentException("TAG_MISMATCH");
}
String p = "";
if (StringUtils.isNotEmpty(aiTag.getPrompt())) {
String[] prompts = aiTag.getPrompt().split(Pattern.quote("^"));
int randomNumberWithin = RandomStringUtil.getRandomNumberWithin(prompts.length);
p = prompts[randomNumberWithin];
}
tagPrompts.add(p);
}
return StringUtils.replacePlaceholders(aiManager.getPrompt(), tagPrompts);
}
return aiManager.getPrompt();
}
@PostMapping(value = "/nano-callback") @PostMapping(value = "/nano-callback")
@ApiOperation("NanoBanana生成回调") @ApiOperation("NanoBanana生成回调")
@Anonymous @Anonymous

View File

@ -31,6 +31,16 @@ public class ByteApiRequest {
@ApiModelProperty(name = "标签字符串") @ApiModelProperty(name = "标签字符串")
private String tags; private String tags;
@ApiModelProperty(name = "宽高比")
private String aspectRatio;
@ApiModelProperty(name = "分辨率 1K/2K/4K")
private String resolution;
/**
* NanoBanana 接口版本v1(/generate)v2(/generate-2)pro(/generate-pro)默认 v2 兼容旧逻辑
*/
@ApiModelProperty(name = "NanoBanana API 类型: v1 | v2 | pro")
private String nanoApiType;
} }

View File

@ -0,0 +1,43 @@
package com.ruoyi.api.request;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.util.List;
/**
* Portal 图生图 nanoApiType 调用 NanoBanana v1 / v2 / pro
*/
@Data
public class NanoBananaPortalImgRequest {
@ApiModelProperty(value = "功能类型(计费)", required = true)
private String functionType;
@ApiModelProperty("提示词,有值时优先于标签模板")
private String text;
@ApiModelProperty(value = "接口版本: v1 | v2 | pro", required = true)
private String nanoApiType;
@ApiModelProperty("宽高比")
private String aspectRatio;
/**
* pro 必填且仅允许 1K2K4K大小写不敏感服务端会规范化
*/
@ApiModelProperty("分辨率pro 必填 1K/2K/4Kv1/v2 可忽略")
private String resolution;
@ApiModelProperty("生成张数 1-4v1/v2 有效)")
private Integer numImages;
@ApiModelProperty("标签字符串")
private String tags;
@ApiModelProperty("单张参考图 URL与 imageUrls 二选一)")
private String imageUrl;
@ApiModelProperty("多张参考图 URL优先于 imageUrl")
private List<String> imageUrls;
}

View File

@ -0,0 +1,35 @@
package com.ruoyi.api.request;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
/**
* Portal 文生图专用 nanoApiType 路由 NanoBanana v1 / v2 / pro
*/
@Data
public class NanoBananaPortalRequest {
@ApiModelProperty(value = "功能类型(计费)", required = true)
private String functionType;
@ApiModelProperty("提示词,有值时优先于标签模板")
private String text;
/**
* v1官方 /generatev2/generate-2pro/generate-pro
*/
@ApiModelProperty(value = "接口版本: v1 | v2 | pro", required = true)
private String nanoApiType;
@ApiModelProperty("宽高比,如 auto、16:9、1:1")
private String aspectRatio;
@ApiModelProperty("分辨率 pro 用1K、2K、4K")
private String resolution;
@ApiModelProperty("生成张数 1-4v1/v2 有效)")
private Integer numImages;
@ApiModelProperty("标签字符串,与旧接口一致")
private String tags;
}

View File

@ -224,11 +224,11 @@ byteapi:
nanobanana: nanobanana:
# NanoBanana API Token (Bearer Token) # NanoBanana API Token (Bearer Token)
token: your_nanobanana_token_here token: 1e95b160056f4579a9949d2516f4a463
# 回调地址,需替换为实际部署域名 (POST接口) # 回调地址,需替换为实际部署域名 (POST接口)
callbackUrl: https://your-domain.com/api/ai/nano-callback callbackUrl: https://your-domain.com/api/ai/nano-callback
# 生成API地址 # NanoBanana API 基础地址(路径在代码中拼接)
apiUrl: https://api.nanobananaapi.ai/api/v1/nanobanana/generate-2 apiUrl: https://api.nanobananaapi.ai
jinsha: jinsha:
url: https://api.jinshapay.xyz url: https://api.jinshapay.xyz

View File

@ -1,78 +1,74 @@
package com.ruoyi.ai.domain; package com.ruoyi.ai.domain;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data; import lombok.Data;
import java.util.List; import java.util.List;
/** /**
* NanoBanana API 文生图/图生图请求参数 * NanoBanana API 文生图/图生图请求参数内部组装实际出站 JSON ByteService v1/v2/pro 分别构建
* 符合阿里巴巴Java开发手册规范
* *
* @author AI Assistant * @author ruoyi
* @date 2026-04-10
*/ */
@Data @Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class NanoBananaRequest { public class NanoBananaRequest {
/** /**
* 文本提示词必填最长 20000 字符 * 文本提示词必填
*/ */
private String prompt; private String prompt;
/** /**
* 参考图 URL 数组文生图时传空数组最多 14 * 参考图 URL图生图
*/ */
@JsonProperty("imageUrls") @JsonProperty("imageUrls")
private List<String> imageUrls = List.of(); private List<String> imageUrls;
/** /**
* 生成图像宽高比 * 生成类型/generate 必填
* 支持 1:11:41:82:33:23:44:14:34:55:48:19:1616:921:9auto * TEXTTOIAMGE / IMAGETOIAMGE
*/ */
@JsonProperty("aspectRatio") private String type = "TEXTTOIAMGE";
private String aspectRatio = "auto";
/** /**
* 分辨率质量可选 1K / 2K / 4K * 宽高比v1/v2 对应官方 image_sizepro 对应 aspectRatio
*/
private String imageSize = "auto";
/**
* 分辨率主要用于 pro
*/ */
private String resolution = "1K"; private String resolution = "1K";
/** /**
* 是否启用 Google Web Search 增强默认 false * 生成张数 1-4/generate
*/ */
@JsonProperty("googleSearch") private Integer numImages = 1;
private Boolean googleSearch = false;
/**
* 输出格式支持 png / jpg默认 jpg
*/
@JsonProperty("outputFormat")
private String outputFormat = "jpg";
/**
* 可选回调 URL用于接收任务完成通知
*/
@JsonProperty("callBackUrl") @JsonProperty("callBackUrl")
private String callBackUrl; private String callBackUrl;
/** public static NanoBananaRequest forTextToImage(String prompt, String callBackUrl, String imageSize, String resolution) {
* 构造函数 - 文生图
*/
public static NanoBananaRequest forTextToImage(String prompt, String callBackUrl, String aspectRatio, String resolution) {
NanoBananaRequest req = new NanoBananaRequest(); NanoBananaRequest req = new NanoBananaRequest();
req.setPrompt(prompt); req.setPrompt(prompt);
req.setCallBackUrl(callBackUrl); req.setCallBackUrl(callBackUrl);
if (aspectRatio != null) req.setAspectRatio(aspectRatio); req.setType("TEXTTOIAMGE");
if (resolution != null) req.setResolution(resolution); if (imageSize != null) {
req.setImageSize(imageSize);
}
if (resolution != null) {
req.setResolution(resolution);
}
req.setNumImages(1);
return req; return req;
} }
/** public static NanoBananaRequest forImageToImage(String prompt, List<String> imageUrls, String callBackUrl,
* 构造函数 - 图生图 String imageSize, String resolution) {
*/ NanoBananaRequest req = forTextToImage(prompt, callBackUrl, imageSize, resolution);
public static NanoBananaRequest forImageToImage(String prompt, List<String> imageUrls, String callBackUrl, String aspectRatio, String resolution) { req.setType("IMAGETOIAMGE");
NanoBananaRequest req = forTextToImage(prompt, callBackUrl, aspectRatio, resolution);
if (imageUrls != null && !imageUrls.isEmpty()) { if (imageUrls != null && !imageUrls.isEmpty()) {
req.setImageUrls(imageUrls); req.setImageUrls(imageUrls);
} }

View File

@ -1,5 +1,6 @@
package com.ruoyi.ai.domain; package com.ruoyi.ai.domain;
import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data; import lombok.Data;
@ -19,8 +20,10 @@ public class NanoBananaResponse {
private Integer code; private Integer code;
/** /**
* 响应消息 * 响应消息部分接口字段名为 msg
*/ */
@JsonProperty("message")
@JsonAlias("msg")
private String message; private String message;
/** /**

View File

@ -16,15 +16,22 @@ import com.ruoyi.ai.domain.NanoBananaResponse;
public interface IByteService { public interface IByteService {
/** /**
* 文生图 - 使用 NanoBanana API 异步生成返回 taskId * 文生图 - 使用 NanoBanana API 异步生成返回 taskId默认走 v2
*/ */
NanoBananaResponse generateImage(NanoBananaRequest req) throws Exception; NanoBananaResponse generateImage(NanoBananaRequest req) throws Exception;
/** /**
* 图生图 - 使用 NanoBanana API 异步生成返回 taskId * 图生图 - 使用 NanoBanana API 异步生成返回 taskId默认走 v2
*/ */
NanoBananaResponse generateImageWithReference(NanoBananaRequest req) throws Exception; NanoBananaResponse generateImageWithReference(NanoBananaRequest req) throws Exception;
/**
* 按版本调用 NanoBananav1=/generatev2=/generate-2pro=/generate-pro
*
* @param nanoApiType v1 | v2 | pro忽略大小写
*/
NanoBananaResponse generateNanoBanana(String nanoApiType, NanoBananaRequest req) throws Exception;
/** /**
* 旧接口兼容文生图/图生图 * 旧接口兼容文生图/图生图
* @deprecated 使用 generateImage 替代 * @deprecated 使用 generateImage 替代

View File

@ -11,16 +11,19 @@ import okhttp3.*;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map;
/** /**
* ByteService 实现类 * ByteService 实现类
* 已切换为 NanoBanana API (https://api.nanobananaapi.ai) * NanoBanana API 配置优化基础地址 https://api.nanobananaapi.ai + 路径在代码中拼接
* 原火山/Byte接口保留用于视频功能 * 原火山/Byte接口保留用于视频功能
* 符合阿里巴巴Java开发手册规范使用Lombok清晰注释异常处理 * 符合阿里巴巴Java开发手册规范使用清晰注释异常处理
* *
* @author shi * @author shi
* @date 2026-04-10 * @date 2026-04-23
*/ */
@Service @Service
public class ByteService implements IByteService { public class ByteService implements IByteService {
@ -31,7 +34,7 @@ public class ByteService implements IByteService {
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, true); .configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, true);
@Value("${nanobanana.apiUrl:https://api.nanobananaapi.ai/api/v1/nanobanana/generate-2}") @Value("${nanobanana.apiUrl:https://api.nanobananaapi.ai}")
private String nanoApiUrl; private String nanoApiUrl;
@Value("${nanobanana.token:}") @Value("${nanobanana.token:}")
@ -49,7 +52,16 @@ public class ByteService implements IByteService {
@Override @Override
public NanoBananaResponse generateImage(NanoBananaRequest req) throws Exception { public NanoBananaResponse generateImage(NanoBananaRequest req) throws Exception {
// 4. 组装NanoBanana接口参数 (替换原VolcengineImageRequest) return generateNanoBanana("v2", req);
}
@Override
public NanoBananaResponse generateImageWithReference(NanoBananaRequest req) throws Exception {
return generateNanoBanana("v2", req);
}
@Override
public NanoBananaResponse generateNanoBanana(String nanoApiType, NanoBananaRequest req) throws Exception {
if (StringUtils.isBlank(req.getPrompt())) { if (StringUtils.isBlank(req.getPrompt())) {
throw new Exception("prompt不能为空"); throw new Exception("prompt不能为空");
} }
@ -57,12 +69,33 @@ public class ByteService implements IByteService {
req.setCallBackUrl(callbackUrl); req.setCallBackUrl(callbackUrl);
} }
// 构建JSON请求体 String mode = nanoApiType == null ? "v2" : nanoApiType.trim().toLowerCase(Locale.ROOT);
String jsonBody = objectMapper.writeValueAsString(req); if ("pro".equals(mode)) {
validateAndNormalizeProResolution(req);
}
// 5. 调用NanoBanana API (使用curl对应POST请求) String path;
String jsonBody;
switch (mode) {
case "v1":
path = "/api/v1/nanobanana/generate";
jsonBody = buildGenerateStyleBody(req);
break;
case "pro":
path = "/api/v1/nanobanana/generate-pro";
jsonBody = buildProStyleBody(req);
break;
case "v2":
path = "/api/v1/nanobanana/generate-2";
jsonBody = buildGenerateStyleBody(req);
break;
default:
throw new IllegalArgumentException("nanoApiType 仅支持 v1、v2、pro");
}
String fullApiUrl = trimTrailingSlash(nanoApiUrl) + path;
Request request = new Request.Builder() Request request = new Request.Builder()
.url(nanoApiUrl) .url(fullApiUrl)
.header("Authorization", "Bearer " + nanoToken) .header("Authorization", "Bearer " + nanoToken)
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.post(RequestBody.create( .post(RequestBody.create(
@ -74,7 +107,7 @@ public class ByteService implements IByteService {
Response response = OkHttpUtils.newCall(request).execute(); Response response = OkHttpUtils.newCall(request).execute();
if (!response.isSuccessful()) { if (!response.isSuccessful()) {
String errorMsg = response.body() != null ? response.body().string() : "generateImage error"; String errorMsg = response.body() != null ? response.body().string() : "NanoBanana error";
throw new Exception("NanoBanana API调用失败: " + errorMsg); throw new Exception("NanoBanana API调用失败: " + errorMsg);
} }
@ -86,9 +119,70 @@ public class ByteService implements IByteService {
return objectMapper.readValue(responseBody, NanoBananaResponse.class); return objectMapper.readValue(responseBody, NanoBananaResponse.class);
} }
@Override /**
public NanoBananaResponse generateImageWithReference(NanoBananaRequest req) throws Exception { * 官方 /generate /generate-2 共用字段prompttypecallBackUrlimage_sizenumImagesimageUrls
return generateImage(req); // 复用imageUrls已在请求中设置 */
private String buildGenerateStyleBody(NanoBananaRequest req) throws Exception {
Map<String, Object> m = new LinkedHashMap<>();
m.put("prompt", req.getPrompt());
m.put("type", req.getType() != null ? req.getType() : "TEXTTOIAMGE");
m.put("callBackUrl", req.getCallBackUrl());
m.put("image_size", req.getImageSize() != null ? req.getImageSize() : "auto");
if (req.getNumImages() != null) {
m.put("numImages", req.getNumImages());
}
if (req.getImageUrls() != null && !req.getImageUrls().isEmpty()) {
m.put("imageUrls", req.getImageUrls());
}
return objectMapper.writeValueAsString(m);
}
/**
* 官方 /generate-propromptcallBackUrlaspectRatioresolutionimageUrls
*/
private String buildProStyleBody(NanoBananaRequest req) throws Exception {
Map<String, Object> m = new LinkedHashMap<>();
m.put("prompt", req.getPrompt());
m.put("callBackUrl", req.getCallBackUrl());
m.put("aspectRatio", req.getImageSize() != null ? req.getImageSize() : "auto");
if (StringUtils.isNotBlank(req.getResolution())) {
m.put("resolution", req.getResolution());
}
if (req.getImageUrls() != null && !req.getImageUrls().isEmpty()) {
m.put("imageUrls", req.getImageUrls());
}
return objectMapper.writeValueAsString(m);
}
/**
* Pro 接口要求 resolution 1K / 2K / 4K官方枚举
*/
private void validateAndNormalizeProResolution(NanoBananaRequest req) {
String r = req.getResolution();
if (StringUtils.isBlank(r)) {
throw new IllegalArgumentException("pro 模式必须指定 resolution仅支持1K、2K、4K");
}
String u = r.trim().toUpperCase(Locale.ROOT);
if ("1K".equals(u)) {
req.setResolution("1K");
} else if ("2K".equals(u)) {
req.setResolution("2K");
} else if ("4K".equals(u)) {
req.setResolution("4K");
} else {
throw new IllegalArgumentException("pro 的 resolution 仅允许 1K、2K、4K");
}
}
private static String trimTrailingSlash(String base) {
if (base == null || base.isEmpty()) {
return "";
}
int len = base.length();
while (len > 0 && base.charAt(len - 1) == '/') {
len--;
}
return base.substring(0, len);
} }
@Override @Override