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) {
return request({
url: '/api/ai/imgToImg',

View File

@ -272,3 +272,217 @@ input:-webkit-autofill {
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 {
// 背景色 - 浅
--mf-color-bg-3: #f8f8fa;
--his-border-color: rgb(166, 124, 82, 0.3);
// 新粗野主义风格 - 浅灰背景,硬边框
--mf-color-bg-3: #f0f0f0;
--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'] {
// 背景色 - 浅
--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)',
generateImageNow: 'Generate Now',
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',
imageFace: 'Image Face Swap',
videoFace: 'Video Face Swap',

View File

@ -8,5 +8,6 @@ export default {
fastVideo: 'Gen Video',
recharge: 'Quick Recharge',
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 zh_HK from '@/lang/zh_HK/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'
/** 各语言在界面上的显示名称 */
/** 各语言在界面上的显示名称 - 仅保留繁体中文和英文 */
export const LOCALE_NAMES = {
zh_HK: '繁体中文',
en_US: 'English',
es_ES: 'Español',
pt_BR: 'Português',
hi_IN: 'हिन्दी',
ru_RU: 'Русский',
ar_SA: 'العربية',
fr_FR: 'Français'
en_US: 'English'
}
const i18n = createI18n({
@ -28,13 +16,7 @@ const i18n = createI18n({
locale,
messages: {
zh_HK,
en_US,
es_ES,
pt_BR,
hi_IN,
ru_RU,
ar_SA,
fr_FR
en_US
}
})

View File

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

View File

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

View File

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

View File

@ -110,65 +110,52 @@ export default {
.sidebar-container {
height: 100%;
position: relative;
transition: width 0.3s;
transition: width 0.1s linear;
z-index: 12;
background-color: #000;
border-right: 1px solid; /* 宽度随你调整 */
border-image: linear-gradient(
to bottom,
#0f0f12 0%,
rgba(255,255,255, 0.7) 40%,
rgba(255,255,255, 0.7) 60%,
#0f0f12 100%
)
1 100%;
background-color: #f0f0f0;
border-right: 4px solid #2c2c2c;
box-shadow: 4px 0 0 #ff0033; /* 非对称强调 */
&.collapsed {
width: 50px !important;
width: 56px !important;
overflow: hidden;
:deep(.arco-menu) {
padding-left: 0px;
padding-left: 4px;
}
}
:deep(.arco-menu) {
background-color: transparent;
padding-left: 16px;
background-color: #f0f0f0;
padding-left: 8px;
border: 2px solid #2c2c2c;
&-item {
width: 180px;
background-color: transparent;
color: rgba(255, 255, 255, 0.7);
border-radius: 10px;
margin-bottom: 12px;
background-color: #f0f0f0;
color: #2c2c2c;
border: 2px solid #2c2c2c;
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,
&: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 {
position: absolute;
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);
}
}
display: none;
}
}
@ -176,10 +163,11 @@ export default {
.sidebar-container {
position: fixed;
left: 0px;
top: 60px;
top: 72px;
z-index: 10;
height: calc(100% - 60px);
height: calc(100% - 72px);
overflow: hidden;
border-right: 4px solid #2c2c2c;
&.collapsed {
width: 0px !important;

View File

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

View File

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

View File

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

View File

@ -68,6 +68,38 @@
v-model="text"
:placeholder="$t('common.textPlaceholder')" />
</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
class="submit"
type="primary"
@ -93,12 +125,16 @@
@click="close" />
</div>
<a-image
v-if="isResultImageUrl"
class="result-image"
fit="contain"
:src="imageUrl" />
<div v-else-if="taskIdHint" class="task-id-hint">
{{ taskIdHint }}
</div>
<div
class="action"
v-show="imageUrl">
v-show="imageUrl && isResultImageUrl">
<mf-button @click="saveImage">
<a-image
:width="20"
@ -157,6 +193,7 @@
<script>
import { mapGetters } from 'vuex'
import { nanoBananaImgToImg } from '@/api/ai'
export default {
data() {
@ -167,6 +204,10 @@ export default {
current: 1,
generateLoading: false,
imageUrl: '',
taskIdHint: '',
nanoApiType: 'v2',
aspectRatio: 'auto',
resolution: '1K',
price: null,
tags: [],
selectedTags: {},
@ -184,12 +225,27 @@ export default {
},
computed: {
...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: {
$route: {
handler(from, to) {
let type = to?.query?.type || 1
this.current = type
handler(to) {
if (to.name === 'image-to-image2') {
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,
immediate: true
@ -199,9 +255,12 @@ export default {
}
},
mounted() {
if (this.$route.name === 'image-to-image2') {
this.current = 2
}
let type = this.$datas.getQueryString('type', this.$route.fullPath)
if (type) {
this.current = type || 1
this.current = Number(type) || 1
}
let { text } = this.$route.query || {}
if (text) {
@ -369,6 +428,10 @@ export default {
this.$message.error(this.$t('common.uploadImageError'))
return
}
if (this.nanoApiType === 'pro' && !this.resolution) {
this.$message.warning(this.$t('common.proResolutionRequired'))
return
}
// if (!this.text && this.current == 2) {
// this.$message.error(this.$t('common.textError'))
// return
@ -392,20 +455,31 @@ export default {
tags.push(item.id)
})
this.generateLoading = true
this.$axios({
url: 'api/ai/imgToImg',
method: 'POST',
data: {
text: this.text,
firstUrl: this.firstUrl.url,
functionType: this.current == 1 ? '11' : '12',
tags: tags.join(',')
}
this.taskIdHint = ''
nanoBananaImgToImg({
functionType: this.current == 1 ? '11' : '12',
nanoApiType: this.nanoApiType,
imageUrl: this.firstUrl.url,
text: this.text,
tags: tags.join(','),
aspectRatio: this.aspectRatio,
resolution: this.resolution,
numImages: 1
})
.then((res) => {
this.generateLoading = false
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
} else if (res.code == -1) {
this.$confirm({
@ -451,6 +525,27 @@ export default {
overflow-y: auto;
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 {
width: 100%;
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 {
display: flex;
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.IByteService;
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.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
@ -49,6 +51,174 @@ public class ByteApiController extends BaseController {
@Value("${nanobanana.token}")
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")
@ApiOperation("文生图")
public AjaxResult promptToImg(@RequestBody ByteApiRequest request) {
@ -58,31 +228,29 @@ public class ByteApiController extends BaseController {
}
AiManager aiManager = managerService.selectAiManagerByType(functionType);
String tags = request.getTags();
String text = "";
if (StringUtils.isNotEmpty(tags)) {
List<AiTag> aiTags = aiTagService.selectAiTagListByIds(request.getTags(), aiManager.getParentIdSort());
List<String> tagPrompts = new ArrayList<>();
for (AiTag aiTag : aiTags) {
if (!aiTag.getAiId().equals(aiManager.getId())) {
return AjaxResult.error(-3, "Generation failed, please try again");
}
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);
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");
}
text = StringUtils.replacePlaceholders(aiManager.getPrompt(), tagPrompts);
} else {
text = aiManager.getPrompt();
throw e;
}
if (StringUtils.isEmpty(text)) {
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);
try {
@ -96,11 +264,9 @@ public class ByteApiController extends BaseController {
text,
nanoCallbackUrl,
request.getAspectRatio() != null ? request.getAspectRatio() : "auto",
request.getResolution() != null ? request.getResolution() : "1K"
resolution
);
// 5. 调用NanoBanana接口 (curl POST https://api.nanobananaapi.ai/...)
NanoBananaResponse nanoResponse = byteService.generateImage(nanoRequest);
NanoBananaResponse nanoResponse = byteService.generateNanoBanana(nanoType, nanoRequest);
if (nanoResponse == null || nanoResponse.getCode() != 200 || nanoResponse.getData() == null) {
aiOrderService.orderFailure(aiOrder);
@ -130,26 +296,17 @@ public class ByteApiController extends BaseController {
}
AiManager aiManager = managerService.selectAiManagerByType(functionType);
String tags = request.getTags();
String text = "";
if (StringUtils.isNotEmpty(tags)) {
List<AiTag> aiTags = aiTagService.selectAiTagListByIds(request.getTags(), aiManager.getParentIdSort());
List<String> tagPrompts = new ArrayList<>();
for (AiTag aiTag : aiTags) {
if (!aiTag.getAiId().equals(aiManager.getId())) {
return AjaxResult.error(-3, "Generation failed, please try again");
}
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);
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");
}
text = StringUtils.replacePlaceholders(aiManager.getPrompt(), tagPrompts);
} else {
text = aiManager.getPrompt();
throw e;
}
if (StringUtils.isEmpty(text)) {
@ -160,6 +317,15 @@ public class ByteApiController extends BaseController {
if (null == firstUrl) {
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);
try {
if (aiOrder == null) {
@ -180,11 +346,9 @@ public class ByteApiController extends BaseController {
imageUrls,
nanoCallbackUrl,
request.getAspectRatio() != null ? request.getAspectRatio() : "auto",
request.getResolution() != null ? request.getResolution() : "1K"
resolution
);
// 5. 调用NanoBanana接口
NanoBananaResponse nanoResponse = byteService.generateImageWithReference(nanoRequest);
NanoBananaResponse nanoResponse = byteService.generateNanoBanana(nanoType, nanoRequest);
if (nanoResponse == null || nanoResponse.getCode() != 200 || nanoResponse.getData() == null) {
aiOrderService.orderFailure(aiOrder);
@ -324,6 +488,36 @@ public class ByteApiController extends BaseController {
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")
@ApiOperation("NanoBanana生成回调")
@Anonymous

View File

@ -31,6 +31,16 @@ public class ByteApiRequest {
@ApiModelProperty(name = "标签字符串")
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 API Token (Bearer Token)
token: your_nanobanana_token_here
token: 1e95b160056f4579a9949d2516f4a463
# 回调地址,需替换为实际部署域名 (POST接口)
callbackUrl: https://your-domain.com/api/ai/nano-callback
# 生成API地址
apiUrl: https://api.nanobananaapi.ai/api/v1/nanobanana/generate-2
# NanoBanana API 基础地址(路径在代码中拼接)
apiUrl: https://api.nanobananaapi.ai
jinsha:
url: https://api.jinshapay.xyz

View File

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

View File

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

View File

@ -16,15 +16,22 @@ import com.ruoyi.ai.domain.NanoBananaResponse;
public interface IByteService {
/**
* 文生图 - 使用 NanoBanana API 异步生成返回 taskId
* 文生图 - 使用 NanoBanana API 异步生成返回 taskId默认走 v2
*/
NanoBananaResponse generateImage(NanoBananaRequest req) throws Exception;
/**
* 图生图 - 使用 NanoBanana API 异步生成返回 taskId
* 图生图 - 使用 NanoBanana API 异步生成返回 taskId默认走 v2
*/
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 替代

View File

@ -11,16 +11,19 @@ import okhttp3.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* ByteService 实现类
* 已切换为 NanoBanana API (https://api.nanobananaapi.ai)
* NanoBanana API 配置优化基础地址 https://api.nanobananaapi.ai + 路径在代码中拼接
* 原火山/Byte接口保留用于视频功能
* 符合阿里巴巴Java开发手册规范使用Lombok清晰注释异常处理
* 符合阿里巴巴Java开发手册规范使用清晰注释异常处理
*
* @author shi
* @date 2026-04-10
* @date 2026-04-23
*/
@Service
public class ByteService implements IByteService {
@ -31,7 +34,7 @@ public class ByteService implements IByteService {
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.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;
@Value("${nanobanana.token:}")
@ -49,7 +52,16 @@ public class ByteService implements IByteService {
@Override
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())) {
throw new Exception("prompt不能为空");
}
@ -57,12 +69,33 @@ public class ByteService implements IByteService {
req.setCallBackUrl(callbackUrl);
}
// 构建JSON请求体
String jsonBody = objectMapper.writeValueAsString(req);
String mode = nanoApiType == null ? "v2" : nanoApiType.trim().toLowerCase(Locale.ROOT);
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()
.url(nanoApiUrl)
.url(fullApiUrl)
.header("Authorization", "Bearer " + nanoToken)
.header("Content-Type", "application/json")
.post(RequestBody.create(
@ -74,7 +107,7 @@ public class ByteService implements IByteService {
Response response = OkHttpUtils.newCall(request).execute();
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);
}
@ -86,9 +119,70 @@ public class ByteService implements IByteService {
return objectMapper.readValue(responseBody, NanoBananaResponse.class);
}
@Override
public NanoBananaResponse generateImageWithReference(NanoBananaRequest req) throws Exception {
return generateImage(req); // 复用imageUrls已在请求中设置
/**
* 官方 /generate /generate-2 共用字段prompttypecallBackUrlimage_sizenumImagesimageUrls
*/
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