优化逻辑

This commit is contained in:
王佑琳 2026-03-26 16:09:04 +08:00
parent ddb1c367b5
commit 70529ccd47
34 changed files with 1782 additions and 1328 deletions

4
components.d.ts vendored
View File

@ -15,7 +15,6 @@ declare module 'vue' {
DialogBox: typeof import('./src/components/dialogBox/index.vue')['default']
ElButton: typeof import('element-plus/es')['ElButton']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElUpload: typeof import('element-plus/es')['ElUpload']
IEpCalendar: typeof import('~icons/ep/calendar')['default']
@ -26,13 +25,14 @@ declare module 'vue' {
ImageUploader: typeof import('./src/components/dialogBox/imageUploader/index.vue')['default']
Img: typeof import('./src/components/Img/index.vue')['default']
Model: typeof import('./src/components/dialogBox/model/index.vue')['default']
Pattern: typeof import('./src/components/dialogBox/pattern/index.vue')['default']
Popover: typeof import('./src/components/Popover/index.vue')['default']
Proportion: typeof import('./src/components/dialogBox/proportion/index.vue')['default']
Quantity: typeof import('./src/components/dialogBox/quantity/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
Select: typeof import('./src/components/Select/index.vue')['default']
Time: typeof import('./src/components/dialogBox/Time/index.vue')['default']
VirtualScroller: typeof import('./src/components/virtual-scroller/VirtualScroller.vue')['default']
VirtualScrollerItem: typeof import('./src/components/virtual-scroller/VirtualScrollerItem.vue')['default']
}
}

371
out.txt
View File

@ -1,140 +1,231 @@
2026-02-04 11:48:53 INFO [XNIO-1 task-2] t.c.starter.log.interceptor.handler.LogInterceptor - [0][1171053593383952384] [POST] /business/collect
2026-02-04 11:48:53 ERROR [XNIO-1 task-2] t.c.s.w.a.r.DefaultBeforeControllerAdviceProcessImpl - [0][1171053593383952384] [POST] /business/collect
org.springframework.dao.DataIntegrityViolationException:
### Error updating database. Cause: java.sql.SQLException: Field 'create_user' doesn't have a default value
### The error may exist in top/continew/admin/business/mapper/CollectMapper.java (best guess)
### The error may involve top.continew.admin.business.mapper.CollectMapper.insert-Inline
### The error occurred while setting parameters
### SQL: INSERT INTO t_collect ( user_id, url, clothes_id ) VALUES ( ?, ?, ? )
### Cause: java.sql.SQLException: Field 'create_user' doesn't have a default value
; Field 'create_user' doesn't have a default value
at org.springframework.jdbc.support.SQLErrorCodeSQLExceptionTranslator.doTranslate(SQLErrorCodeSQLExceptionTranslator.java:258)
at org.springframework.jdbc.support.AbstractFallbackSQLExceptionTranslator.translate(AbstractFallbackSQLExceptionTranslator.java:107)
at org.mybatis.spring.MyBatisExceptionTranslator.translateExceptionIfPossible(MyBatisExceptionTranslator.java:92)
at org.mybatis.spring.SqlSessionTemplate$SqlSessionInterceptor.invoke(SqlSessionTemplate.java:439)
at jdk.proxy2/jdk.proxy2.$Proxy153.insert(Unknown Source)
at org.mybatis.spring.SqlSessionTemplate.insert(SqlSessionTemplate.java:272)
at com.baomidou.mybatisplus.core.override.MybatisMapperMethod.execute(MybatisMapperMethod.java:59)
at com.baomidou.mybatisplus.core.override.MybatisMapperProxy$PlainMethodInvoker.invoke(MybatisMapperProxy.java:152)
at com.baomidou.mybatisplus.core.override.MybatisMapperProxy.invoke(MybatisMapperProxy.java:89)
at jdk.proxy2/jdk.proxy2.$Proxy191.insert(Unknown Source)
at top.continew.starter.extension.crud.service.impl.BaseServiceImpl.add(BaseServiceImpl.java:156)
at jdk.internal.reflect.GeneratedMethodAccessor528.invoke(Unknown Source)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.base/java.lang.reflect.Method.invoke(Method.java:569)
at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:354)
at org.springframework.aop.framework.ReflectiveMethodInvocation.invokeJoinpoint(ReflectiveMethodInvocation.java:196)
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163)
at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768)
at org.springframework.transaction.interceptor.TransactionInterceptor$1.proceedWithInvocation(TransactionInterceptor.java:123)
at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:392)
at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119)
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184)
at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768)
at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:720)
at top.continew.admin.business.service.impl.CollectServiceImpl$$SpringCGLIB$$0.add(<generated>)
at top.continew.admin.controller.business.CollectController.addCollect(CollectController.java:52)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.base/java.lang.reflect.Method.invoke(Method.java:569)
at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:255)
at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:188)
at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:118)
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:926)
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:831)
at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1089)
at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:979)
at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014)
at org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:914)
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:547)
at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:885)
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:614)
at io.undertow.servlet.handlers.ServletHandler.handleRequest(ServletHandler.java:74)
at io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:129)
at org.springframework.web.filter.CorsFilter.doFilterInternal(CorsFilter.java:91)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
at io.undertow.servlet.core.ManagedFilter.doFilter(ManagedFilter.java:67)
at io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:131)
at top.continew.starter.log.interceptor.handler.LogFilter.doFilterInternal(LogFilter.java:82)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
at io.undertow.servlet.core.ManagedFilter.doFilter(ManagedFilter.java:67)
at io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:131)
at cn.dev33.satoken.filter.SaPathCheckFilterForJakartaServlet.doFilter(SaPathCheckFilterForJakartaServlet.java:55)
at io.undertow.servlet.core.ManagedFilter.doFilter(ManagedFilter.java:67)
at io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:131)
at org.springframework.web.filter.ServerHttpObservationFilter.doFilterInternal(ServerHttpObservationFilter.java:107)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
at io.undertow.servlet.core.ManagedFilter.doFilter(ManagedFilter.java:67)
at io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:131)
at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
at io.undertow.servlet.core.ManagedFilter.doFilter(ManagedFilter.java:67)
at io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:131)
at top.continew.starter.web.autoconfigure.trace.TLogServletFilter.doFilter(TLogServletFilter.java:59)
at io.undertow.servlet.core.ManagedFilter.doFilter(ManagedFilter.java:67)
at io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:131)
at io.undertow.servlet.handlers.FilterHandler.handleRequest(FilterHandler.java:84)
at io.undertow.servlet.handlers.security.ServletSecurityRoleHandler.handleRequest(ServletSecurityRoleHandler.java:62)
at io.undertow.servlet.handlers.ServletChain$1.handleRequest(ServletChain.java:68)
at io.undertow.servlet.handlers.ServletDispatchingHandler.handleRequest(ServletDispatchingHandler.java:36)
at io.undertow.servlet.handlers.RedirectDirHandler.handleRequest(RedirectDirHandler.java:68)
at io.undertow.servlet.handlers.security.SSLInformationAssociationHandler.handleRequest(SSLInformationAssociationHandler.java:117)
at io.undertow.servlet.handlers.security.ServletAuthenticationCallHandler.handleRequest(ServletAuthenticationCallHandler.java:57)
at io.undertow.server.handlers.PredicateHandler.handleRequest(PredicateHandler.java:43)
at io.undertow.security.handlers.AbstractConfidentialityHandler.handleRequest(AbstractConfidentialityHandler.java:46)
at io.undertow.servlet.handlers.security.ServletConfidentialityConstraintHandler.handleRequest(ServletConfidentialityConstraintHandler.java:64)
at io.undertow.security.handlers.AuthenticationMechanismsHandler.handleRequest(AuthenticationMechanismsHandler.java:60)
at io.undertow.servlet.handlers.security.CachedAuthenticatedSessionHandler.handleRequest(CachedAuthenticatedSessionHandler.java:77)
at io.undertow.security.handlers.AbstractSecurityContextAssociationHandler.handleRequest(AbstractSecurityContextAssociationHandler.java:43)
at io.undertow.server.handlers.PredicateHandler.handleRequest(PredicateHandler.java:43)
at io.undertow.servlet.handlers.SendErrorPageHandler.handleRequest(SendErrorPageHandler.java:52)
at io.undertow.server.handlers.PredicateHandler.handleRequest(PredicateHandler.java:43)
at io.undertow.servlet.handlers.ServletInitialHandler.handleFirstRequest(ServletInitialHandler.java:276)
at io.undertow.servlet.handlers.ServletInitialHandler$2.call(ServletInitialHandler.java:135)
at io.undertow.servlet.handlers.ServletInitialHandler$2.call(ServletInitialHandler.java:132)
at io.undertow.servlet.core.ServletRequestContextThreadSetupAction$1.call(ServletRequestContextThreadSetupAction.java:48)
at io.undertow.servlet.core.ContextClassLoaderSetupAction$1.call(ContextClassLoaderSetupAction.java:43)
at io.undertow.servlet.handlers.ServletInitialHandler.dispatchRequest(ServletInitialHandler.java:256)
at io.undertow.servlet.handlers.ServletInitialHandler$1.handleRequest(ServletInitialHandler.java:101)
at io.undertow.server.Connectors.executeRootHandler(Connectors.java:393)
at io.undertow.server.HttpServerExchange$1.run(HttpServerExchange.java:859)
at org.jboss.threads.ContextHandler$1.runWith(ContextHandler.java:18)
at org.jboss.threads.EnhancedQueueExecutor$Task.run(EnhancedQueueExecutor.java:2513)
at org.jboss.threads.EnhancedQueueExecutor$ThreadBody.run(EnhancedQueueExecutor.java:1538)
at org.xnio.XnioWorker$WorkerThreadFactory$1$1.run(XnioWorker.java:1282)
at java.base/java.lang.Thread.run(Thread.java:840)
Caused by: java.sql.SQLException: Field 'create_user' doesn't have a default value
at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:130)
at com.mysql.cj.jdbc.exceptions.SQLExceptionsMapping.translateException(SQLExceptionsMapping.java:122)
at com.mysql.cj.jdbc.ClientPreparedStatement.executeInternal(ClientPreparedStatement.java:912)
at com.mysql.cj.jdbc.ClientPreparedStatement.execute(ClientPreparedStatement.java:354)
at com.zaxxer.hikari.pool.ProxyPreparedStatement.execute(ProxyPreparedStatement.java:44)
at com.zaxxer.hikari.pool.HikariProxyPreparedStatement.execute(HikariProxyPreparedStatement.java)
at com.p6spy.engine.wrapper.PreparedStatementWrapper.execute(PreparedStatementWrapper.java:362)
at org.apache.ibatis.executor.statement.PreparedStatementHandler.update(PreparedStatementHandler.java:48)
at org.apache.ibatis.executor.statement.RoutingStatementHandler.update(RoutingStatementHandler.java:75)
at jdk.internal.reflect.GeneratedMethodAccessor215.invoke(Unknown Source)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.base/java.lang.reflect.Method.invoke(Method.java:569)
at org.apache.ibatis.plugin.Plugin.invoke(Plugin.java:61)
at jdk.proxy2/jdk.proxy2.$Proxy260.update(Unknown Source)
at org.apache.ibatis.executor.SimpleExecutor.doUpdate(SimpleExecutor.java:50)
at org.apache.ibatis.executor.BaseExecutor.update(BaseExecutor.java:117)
at org.apache.ibatis.executor.CachingExecutor.update(CachingExecutor.java:76)
at jdk.internal.reflect.GeneratedMethodAccessor214.invoke(Unknown Source)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.base/java.lang.reflect.Method.invoke(Method.java:569)
at org.apache.ibatis.plugin.Invocation.proceed(Invocation.java:61)
at com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor.intercept(MybatisPlusInterceptor.java:106)
at org.apache.ibatis.plugin.Plugin.invoke(Plugin.java:59)
at jdk.proxy2/jdk.proxy2.$Proxy258.update(Unknown Source)
at org.apache.ibatis.session.defaults.DefaultSqlSession.update(DefaultSqlSession.java:197)
at org.apache.ibatis.session.defaults.DefaultSqlSession.insert(DefaultSqlSession.java:184)
at jdk.internal.reflect.GeneratedMethodAccessor221.invoke(Unknown Source)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.base/java.lang.reflect.Method.invoke(Method.java:569)
at org.mybatis.spring.SqlSessionTemplate$SqlSessionInterceptor.invoke(SqlSessionTemplate.java:425)
... 93 common frames omitted
2026-02-04 11:48:53 INFO [XNIO-1 task-2] t.c.starter.log.interceptor.handler.LogInterceptor - [0][1171053593383952384] [POST] /business/collect 200 3ms
{
"code": "0",
"msg": "ok",
"success": true,
"timestamp": 1774247913094,
"data": {
"list": [
{
"id": "793783789028385477",
"createUser": "779313132404212546",
"createTime": "2025-12-23 10:13:23",
"updateUser": null,
"updateTime": null,
"userId": "779313132404212546",
"platformId": 10000,
"taskId": "d7265fba-2a9c-4787-9212-0a543aa0079d",
"title": "生成图片",
"taskType": 1,
"chargeType": 1,
"type": null,
"fileType": null,
"modelName": "Flux",
"accountType": "sub_account_1",
"consumptionPoints": 1,
"fileUrl": "https://sxwz.xueai.art/file/2025/12/23/6949fac3e4b0ca6c273243e6.png",
"platformCode": "10000",
"result": null,
"tokens": null
},
{
"id": "801756644529676772",
"createUser": "779313132404212546",
"createTime": "2026-01-14 10:14:40",
"updateUser": null,
"updateTime": null,
"userId": "779313132404212546",
"platformId": 10000,
"taskId": "be7fa32f-a64c-4502-9241-5b299aba3007",
"title": "生成图片",
"taskType": 1,
"chargeType": 1,
"type": null,
"fileType": null,
"modelName": "Flux",
"accountType": "sub_account_1",
"consumptionPoints": 1,
"fileUrl": "https://sxwz.xueai.art/file/2026/1/14/6966fc0fe4b0ca6c27324493.png",
"platformCode": "10000",
"result": null,
"tokens": null
},
{
"id": "801757022436467181",
"createUser": "779313132404212546",
"createTime": "2026-01-14 10:16:10",
"updateUser": null,
"updateTime": null,
"userId": "779313132404212546",
"platformId": 10000,
"taskId": "a11eca7f-a974-40a5-9ba7-633e59c34203",
"title": "生成图片",
"taskType": 1,
"chargeType": 1,
"type": null,
"fileType": null,
"modelName": "Flux",
"accountType": "sub_account_1",
"consumptionPoints": 1,
"fileUrl": "https://sxwz.xueai.art/file/2026/1/14/6966fc6ae4b0ca6c27324494.png",
"platformCode": "10000",
"result": null,
"tokens": null
},
{
"id": "808634443160891624",
"createUser": "779313132404212546",
"createTime": "2026-02-02 01:44:35",
"updateUser": null,
"updateTime": null,
"userId": "779313132404212546",
"platformId": 10000,
"taskId": "wKAM2KIQQ_riW0pTAAAL",
"title": "图片生成",
"taskType": 1,
"chargeType": 1,
"type": null,
"fileType": null,
"modelName": "Flux",
"accountType": "sub_account_1",
"consumptionPoints": 1,
"fileUrl": "https://sxwz.xueai.art/file/2026/2/2/69800182bd04d39e54d076dd.png",
"platformCode": "10000",
"result": null,
"tokens": null
},
{
"id": "808634548240789744",
"createUser": "779313132404212546",
"createTime": "2026-02-02 01:45:00",
"updateUser": null,
"updateTime": null,
"userId": "779313132404212546",
"platformId": 10000,
"taskId": "ULX6qR65vnBzhx52AAAN",
"title": "图片生成",
"taskType": 1,
"chargeType": 1,
"type": null,
"fileType": null,
"modelName": "Flux",
"accountType": "sub_account_1",
"consumptionPoints": 1,
"fileUrl": "https://sxwz.xueai.art/file/2026/2/2/6980019bbd04d39e54d076de.png",
"platformCode": "10000",
"result": null,
"tokens": null
},
{
"id": "808643919486136683",
"createUser": "779313132404212546",
"createTime": "2026-02-02 02:22:14",
"updateUser": null,
"updateTime": null,
"userId": "779313132404212546",
"platformId": 10000,
"taskId": "WiVJhf1U-CPFyyEiAAAb",
"title": "图片生成",
"taskType": 1,
"chargeType": 1,
"type": null,
"fileType": null,
"modelName": "Flux",
"accountType": "sub_account_1",
"consumptionPoints": 1,
"fileUrl": "https://sxwz.xueai.art/file/2026/2/2/69800a56bd04d39e54d076e7.png",
"platformCode": "10000",
"result": null,
"tokens": null
},
{
"id": "808666588717789637",
"createUser": "779313132404212546",
"createTime": "2026-02-02 03:52:19",
"updateUser": null,
"updateTime": null,
"userId": "779313132404212546",
"platformId": 10000,
"taskId": "gH7baTTfPSfawbcUAAAf",
"title": "图片生成",
"taskType": 1,
"chargeType": 1,
"type": null,
"fileType": null,
"modelName": "Flux",
"accountType": "sub_account_1",
"consumptionPoints": 1,
"fileUrl": "https://sxwz.xueai.art/file/2026/2/2/69801f73bd04d39e54d076e9.png",
"platformCode": "10000",
"result": null,
"tokens": null
},
{
"id": "808703764197290551",
"createUser": "779313132404212546",
"createTime": "2026-02-02 06:20:02",
"updateUser": null,
"updateTime": null,
"userId": "779313132404212546",
"platformId": 10000,
"taskId": "xpW7ykBix5s4b3j6AAAz",
"title": "图片生成",
"taskType": 1,
"chargeType": 1,
"type": null,
"fileType": null,
"modelName": "Flux",
"accountType": "main_account",
"consumptionPoints": 9,
"fileUrl": "https://sxwz.xueai.art/file/2026/2/2/69804212bd04d39e54d076f3.png",
"platformCode": "10000",
"result": null,
"tokens": null
},
{
"id": "819160907316737522",
"createUser": "779313132404212546",
"createTime": "2026-03-03 02:53:00",
"updateUser": null,
"updateTime": null,
"userId": "779313132404212546",
"platformId": 10000,
"taskId": "LYLIzOdaCCBZ8qV-AACt",
"title": "图片生成",
"taskType": 1,
"chargeType": 1,
"type": null,
"fileType": null,
"modelName": "Flux",
"accountType": "main_account",
"consumptionPoints": 15,
"fileUrl": "https://sxwz.xueai.art/file/2026/3/3/69a64d0bbd04d39e54d0772b.png",
"platformCode": "10000",
"result": null,
"tokens": null
},
{
"id": "821520405306029717",
"createUser": "779313132404212546",
"createTime": "2026-03-09 15:08:48",
"updateUser": null,
"updateTime": null,
"userId": "779313132404212546",
"platformId": 10000,
"taskId": "eIKMWxL9GzzeAfvsAAC_",
"title": "图片生成",
"taskType": 1,
"chargeType": 1,
"type": null,
"fileType": null,
"modelName": "Flux",
"accountType": "main_account",
"consumptionPoints": 8,
"fileUrl": "https://sxwz.xueai.art/file/2026/3/9/69aee27fbd04d39e54d07748.png",
"platformCode": "10000",
"result": null,
"tokens": null
}
],
"total": 45
}
}

View File

@ -2,5 +2,10 @@ import service from '@/utils/request'
// 获取生成历史列表
export function getGenerateHistoryList(query) {
return service.get('/taskRecordHistory/list', { params: query })
return service.get('/taskRecordHistory', { params: query })
}
// 取消或收藏
export function cancelOrCollect(query) {
return service.post('/collect/toggle', query)
}

View File

@ -0,0 +1,4 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="9" cy="9" r="6.5" stroke="black"/>
<path d="M9 6V10" stroke="black" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 208 B

View File

@ -0,0 +1,4 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5 2H15C15.5523 2 16 2.44772 16 3V5.5M16 12.5V15C16 15.5523 15.5523 16 15 16H12.5M5.5 16H3C2.44772 16 2 15.5523 2 15V12.5M2 5.5V3C2 2.44772 2.44772 2 3 2H5.5" stroke="#000F33" stroke-linecap="round"/>
<path d="M8.53107 5.26725C8.69215 4.83194 9.30785 4.83194 9.46893 5.26725L10.0313 6.78706C10.2339 7.3345 10.6655 7.76612 11.2129 7.96869L12.7327 8.53107C13.1681 8.69215 13.1681 9.30785 12.7327 9.46893L11.2129 10.0313C10.6655 10.2339 10.2339 10.6655 10.0313 11.2129L9.46893 12.7327C9.30785 13.1681 8.69215 13.1681 8.53107 12.7327L7.96869 11.2129C7.76612 10.6655 7.3345 10.2339 6.78706 10.0313L5.26725 9.46893C4.83194 9.30785 4.83194 8.69215 5.26725 8.53107L6.78706 7.96869C7.3345 7.76612 7.76612 7.3345 7.96869 6.78706L8.53107 5.26725Z" fill="#000F33"/>
</svg>

After

Width:  |  Height:  |  Size: 869 B

View File

@ -0,0 +1,4 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.5" y="2.5" width="5" height="13" rx="0.5" stroke="#666666"/>
<rect x="10.5" y="2.5" width="5" height="13" rx="0.5" stroke="#666666"/>
</svg>

After

Width:  |  Height:  |  Size: 248 B

View File

@ -0,0 +1,5 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="6" y="2" width="6" height="14" rx="1" stroke="#666666"/>
<path d="M2 2H3C3.55228 2 4 2.44772 4 3V15C4 15.5523 3.55228 16 3 16H2" stroke="#666666" stroke-linecap="round"/>
<path d="M16 2H15C14.4477 2 14 2.44772 14 3V15C14 15.5523 14.4477 16 15 16H16" stroke="#666666" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 404 B

View File

@ -0,0 +1,4 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5 2H15C15.5523 2 16 2.44772 16 3V5.5M16 12.5V15C16 15.5523 15.5523 16 15 16H12.5M5.5 16H3C2.44772 16 2 15.5523 2 15V12.5M2 5.5V3C2 2.44772 2.44772 2 3 2H5.5" stroke="#666666" stroke-linecap="round"/>
<path d="M10 10.1765C10.7712 10.1765 11.5127 10.456 12.0707 10.957C12.6288 11.458 12.9603 12.142 12.9967 12.867L13 13H5C5 12.2512 5.31607 11.533 5.87868 11.0035C6.44129 10.4739 7.20435 10.1765 8 10.1765H10ZM9 5C9.66304 5 10.2989 5.2479 10.7678 5.68916C11.2366 6.13042 11.5 6.7289 11.5 7.35294C11.5 7.97698 11.2366 8.57546 10.7678 9.01672C10.2989 9.45798 9.66304 9.70588 9 9.70588C8.33696 9.70588 7.70107 9.45798 7.23223 9.01672C6.76339 8.57546 6.5 7.97698 6.5 7.35294C6.5 6.7289 6.76339 6.13042 7.23223 5.68916C7.70107 5.2479 8.33696 5 9 5Z" fill="#666666"/>
</svg>

After

Width:  |  Height:  |  Size: 875 B

View File

@ -62,8 +62,6 @@
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
const props = defineProps({
modelValue: {
type: [String, Number],
@ -252,7 +250,7 @@ onBeforeUnmount(() => {
background-color: #FFFFFF;
border-radius: 16px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
padding: 10px 8px;
padding: 20px 10px;
z-index: 1000;
display: flex;
flex-direction: column;
@ -303,15 +301,11 @@ onBeforeUnmount(() => {
}
.dropdown-item {
font-family: 'Microsoft YaHei';
font-size: 14px;
color: #999999;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
padding: 0px 20px;
padding: 0px 10px;
min-width: 120px;
text-align: left;
white-space: nowrap;
width: 100%;
box-sizing: border-box;
@ -319,6 +313,13 @@ onBeforeUnmount(() => {
height: 36px;
display: flex;
align-items: center;
color: #666;
font-family: "Microsoft YaHei";
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: normal;
}
.dropdown-item:hover {
@ -326,10 +327,14 @@ onBeforeUnmount(() => {
}
.dropdown-item.selected {
color: #333333;
color: #000F33;
font-weight: 400;
background-color: #F8F9FA;
border-radius: 10px;
.option-label {
color: #000F33;
}
}
.option-content {
@ -363,7 +368,7 @@ onBeforeUnmount(() => {
}
.option-group {
margin-bottom: 10px;
/* margin-bottom: 10px; */
&:last-child {
margin-bottom: 0;
@ -371,10 +376,13 @@ onBeforeUnmount(() => {
}
.group-title {
margin-left: 10px;
margin-bottom: 5px;
color: #999;
font-family: "Microsoft YaHei";
font-size: 12px;
font-weight: 600;
color: #666;
padding: 5px 20px 8px;
margin-bottom: 4px;
font-style: normal;
font-weight: 400;
line-height: normal;
}
</style>

View File

@ -157,7 +157,8 @@
</template>
<script setup>
import { ref, watch, nextTick, computed } from 'vue'
import { generate } from '@/utils/websocket'
import { useDisplayStore } from '@/stores'
const props = defineProps({
visible: {
@ -167,11 +168,21 @@ const props = defineProps({
image: {
type: String,
default: ''
},
referenceImages: {
type: Array,
default: () => []
},
source: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:visible', 'send'])
const useDisplay = useDisplayStore()
const canvasRef = ref(null)
const editableDivRef = ref(null)
const brushTextareaRef = ref(null)
@ -241,16 +252,24 @@ watch(() => props.visible, (newVal) => {
if (newVal) {
currentImage.value = props.image
bgImage.value = null
shapes.value = []
inputText.value = ''
history.value = []
historyIndex.value = -1
promptHistory.value = []
promptHistoryIndex.value = -1
allReferenceImages.value = []
allReferenceImages.value = props.referenceImages.map(img => img.url || img)
brushPanelVisible.value = false
isPanelOpen.value = false
currentShapeDescription.value = ''
selectedReferenceImages.value = []
currentEditingShapeIndex.value = -1
currentEditingContent.value = ''
nextTick(() => {
initCanvas()
})
}
})
}, { immediate: true })
const initCanvas = () => {
const canvas = canvasRef.value
@ -511,14 +530,45 @@ const handleClose = () => {
promptHistoryIndex.value = -1
}
const handleSend = () => {
const handleSend = async () => {
if (!inputText.value) {
ElMessage.error('请输入提示词')
return
}
const canvas = canvasRef.value
const imageData = canvas.toDataURL('image/png')
const imgs = []
if (imageData) {
imgs.push({ name: 'image_1', url: imageData })
}
allReferenceImages.value.forEach((img, index) => {
imgs.push({ name: `image_${index + 2}`, url: img })
})
const data = {
AIGC: 'Painting',
platform: 'runninghub',
file_type: 'image',
modelName: 'flux',
params: [
{ name: 'prompt', data: inputText.value },
{ name: 'quantity', data: 1 },
{ name: 'aspect_ratio', data: '16:9' },
{ name: 'resolution', data: '1k' }
],
imgs
}
emit('send', {
image: imageData,
text: inputText.value,
shapes: shapes.value
})
await generate('text', data)
handleClose()
}

View File

@ -0,0 +1,80 @@
<template>
<Select
v-model="quantity"
:options="quantityOptions"
class="quantity-select"
position="top"
>
<template #prefix>
<img src="@/assets/dialog/time.svg" alt="" style="width: 20px;">
</template>
<template #header>
<span class="header">选择视频生成时长</span>
</template>
</Select>
</template>
<script setup>
import Select from '@/components/Select/index.vue'
const props = defineProps({
modelValue: {
type: Number,
default: 1
}
})
const emit = defineEmits(['update:modelValue'])
const quantity = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const quantityOptions = [
{ value: 5, label: '5s' },
{ value: 6, label: '6s' },
{ value: 7, label: '7s' },
{ value: 8, label: '8s' },
{ value: 9, label: '9s' },
{ value: 10, label: '10s' }
]
</script>
<style lang="less" scoped>
.quantity-select {
:deep(.select-header) {
height: 40px;
padding: 0 15px;
border-radius: 10px;
border: 1px solid rgba(0, 0, 0, 0.10);
background: #ffffff;
&:hover {
background: #E5E7EB;
}
}
:deep(.select-text) {
font-size: 14px;
}
:deep(.dropdown-menu) {
min-width: 136px;
}
:deep(.dropdown-item) {
min-width: 80px;
justify-content: center;
}
}
.header{
margin-left: 10px;
color: #999;
font-family: "Microsoft YaHei";
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: normal;
}
</style>

View File

@ -5,11 +5,11 @@
v-for="(item, index) in localPreviewList"
:key="item.uid"
class="image-item"
@click.stop
@click.stop="handleImageClick(index)"
>
<img :src="item.url" class="uploaded-image" alt="上传的图片" />
<div class="image-index">{{ index + 1 }}</div>
<div class="delete-icon" @click="handleDelete(index)">
<div class="delete-icon" @click.stop="handleDelete(index)">
<i-ep-close />
</div>
</div>
@ -46,7 +46,7 @@ const props = defineProps({
}
})
const emit = defineEmits(['update:modelValue'])
const emit = defineEmits(['update:modelValue', 'open-canvas'])
const uploadurl = import.meta.env.VITE_API_WORKFLOW_UPLOAD
const uploadRef = ref(null)
@ -116,6 +116,23 @@ const handleDelete = (index) => {
emit('update:modelValue', [...imageList.value])
}
const handleImageClick = (clickedIndex) => {
const clickedImage = imageList.value[clickedIndex]
if (!clickedImage) return
const otherImages = imageList.value
.filter((_, index) => index !== clickedIndex)
.map((img, index) => ({
...img,
displayIndex: index + 2
}))
emit('open-canvas', {
mainImage: clickedImage,
referenceImages: otherImages
})
}
const handleSelect = async (url) => {
const initialFile = await fetch(url)
const blob = await initialFile.blob()

View File

@ -7,9 +7,9 @@
<div class="sender-top">
<div v-if="useDisplay.Sender_variant === 'default'" class="scroll-to-bottom-text" @click.stop="handleScrollToBottom">回到底部<img src="@/assets/dialog/ArrowDown.svg"></div>
<div v-show="type !== 'text'" class="upload-img-container">
<div v-show="modelType !== 'text'" class="upload-img-container">
<div class="reference-diagram">
<ImageUploader ref="referenceDiagramRef" v-model="referenceImages" :limit="4" />
<ImageUploader ref="referenceDiagramRef" v-model="referenceImages" :limit="4" @open-canvas="handleOpenCanvas" />
</div>
</div>
</div>
@ -17,15 +17,16 @@
<Sender :key="useDisplay.Sender_variant" v-model="prompt" :variant="useDisplay.Sender_variant" :placeholder="promptPlaceholder" :submit-btn-disabled="isgerenate.value" :auto-size="autoSizeConfig">
<template #prefix>
<div v-show="useDisplay.Sender_variant !== 'default' && props.type === 'painting'" class="prefix-self-wrap">
<Model v-model="model" v-model:typeValue="type" />
<Model v-model="model" v-model:typeValue="modelType" :type="props.type" />
<Proportion v-model="proportion" v-model:resolution="resolution" />
<Quantity v-model="quantity" />
</div>
<div v-show="useDisplay.Sender_variant !== 'default' && props.type === 'video'" class="prefix-self-wrap">
<Model v-model="model" v-model:typeValue="type" />
<Proportion v-model="proportion" v-model:resolution="resolution" />
<Quantity v-model="quantity" />
<Model v-model="model" v-model:typeValue="modelType" :type="props.type" />
<Pattern v-model="videoPattern" />
<Proportion v-model="proportion" v-model:resolution="resolution" :type="props.type" />
<Time v-model="time" />
</div>
</template>
@ -50,8 +51,10 @@
<script setup>
import Proportion from './proportion/index.vue'
import Quantity from './quantity/index.vue'
import Pattern from './pattern/index.vue'
import Model from './model/index.vue'
import ImageUploader from './imageUploader/index.vue'
import Time from './Time/index.vue'
import { Sender } from 'vue-element-plus-x'
import { useDisplayStore } from '@/stores'
import { generate } from '@/utils/websocket'
@ -71,20 +74,29 @@ const props = defineProps({
default: 'painting'
}
})
const emit = defineEmits(['open-canvas'])
const router = useRouter()
const useDisplay = useDisplayStore()
const type = ref('text')
const isgerenate = ref(false)
const prompt = ref('一个女孩在树下吃苹果')
const model = ref('flux')
const proportion = ref('4:3')
const modelType = ref('text')
//
const prompt = ref('一个女孩在树下吃苹果')
const promptPlaceholder = '描述你想生成的画面和动作。'
const proportion = ref('16:9')
const referenceImages = ref([])
//
const quantity = ref(1)
const resolution = ref('1k')
const promptPlaceholder = '描述你想生成的画面和动作。'
const referenceImages = ref([])
const isgerenate = ref(false)
//
const time = ref(5)
const videoPattern = ref('全能参考')
const autoSizeConfig = computed(() => {
if (useDisplay.Sender_variant !== 'default') {
@ -123,7 +135,7 @@ const handleStart = async () => {
],
imgs
}
await generate(type.value, data)
await generate(modelType.value, data)
console.log('生成中', isgerenate.value)
}
@ -138,14 +150,23 @@ const handleScrollToBottom = () => {
useDisplay.scrollToBottom()
}
const handleOpenCanvas = (data) => {
emit('open-canvas', data)
}
watch(() => useDisplay.isSubGerenate, (newValue) => {
console.log('生成状态', newValue)
isgerenate.value = newValue
// handleScrollToBottom()
}, { immediate: true })
watch(() => type.value, (newValue) => {
console.log('type.value', newValue)
watch(() => modelType.value, (newValue) => {
console.log('modelType.value', newValue)
})
onMounted(() => {
if(props.type === 'video'){
model.value = 'Vidu Q3-T2V'
}
})
</script>
@ -262,7 +283,6 @@ watch(() => type.value, (newValue) => {
:deep(.el-popover.el-popper){
border-radius: 20px;
}
//
.select{

View File

@ -13,6 +13,8 @@
<script setup>
import Select from '@/components/Select/index.vue'
import paintingConfig from '@/config/modelConfig/painting.json'
import videoConfig from '@/config/modelConfig/video.json'
const props = defineProps({
modelValue: {
@ -22,6 +24,10 @@ const props = defineProps({
typeValue: {
type: String,
default: 'text'
},
type: {
type: String,
default: 'painting'
}
})
@ -36,47 +42,62 @@ const model = computed({
}
})
const generateModels = [
{ value: 'flux', label: 'flux' },
{ value: 'zImage', label: 'Z-image' },
{ value: 'jimeng', label: 'jimeng' },
{ value: 'QwenImage', label: 'QwenImage' }
]
const editModels = [
{ value: 'BananaPro', label: 'Banana-Pro' },
{ value: 'Qwen-image', label: 'Qwen-image' },
{ value: 'Kontext', label: 'Kontext' },
{ value: 'Jimeng_4.0', label: 'Jimeng.4.0' }
]
const visionModels = [
{ value: 'Qwen3.5plus', label: 'Qwen3.5plus' }
]
const modelGroups = [
{
label: '生成模型',
options: generateModels
},
{
label: '编辑模型',
options: editModels
},
{
label: '视觉理解模型',
options: visionModels
const getModelsByType = (type) => {
if (type === 'video') {
return videoConfig.video || []
}
]
const config = paintingConfig
return {
generate: config.generate || [],
edit: config.edit || [],
vision: config.vision || []
}
}
const modelData = computed(() => {
return getModelsByType(props.type)
})
const isVideo = computed(() => props.type === 'video')
const generateModels = computed(() => modelData.value.generate || [])
const editModels = computed(() => modelData.value.edit || [])
const visionModels = computed(() => modelData.value.vision || [])
const modelGroups = computed(() => {
if (isVideo.value) {
return [{
label: '选择模型',
options: modelData.value
}]
}
return [
{
label: '生成模型',
options: generateModels.value
},
{
label: '编辑模型',
options: editModels.value
},
{
label: '视觉理解模型',
options: visionModels.value
}
]
})
const getModelType = (value) => {
if (generateModels.find(m => m.value === value)) {
if (isVideo.value) {
return 'text'
}
if (editModels.find(m => m.value === value)) {
if (generateModels.value.find(m => m.value === value)) {
return 'text'
}
if (editModels.value.find(m => m.value === value)) {
return 'image'
}
if (visionModels.find(m => m.value === value)) {
if (visionModels.value.find(m => m.value === value)) {
return 'vision'
}
return 'text'
@ -98,7 +119,7 @@ const getModelType = (value) => {
}
:deep(.select-text) {
font-size: 14px;
font-size: 14px;
}
:deep(.dropdown-menu) {

View File

@ -0,0 +1,93 @@
<template>
<Select
v-model="quantity"
:options="quantityOptions"
class="quantity-select"
position="top"
>
<template #prefix>
<img :src="selectedIcon" alt="" style="width: 20px;">
</template>
<template #option="{ option }">
<div class="option-content-custom">
<img :src="option.icon" alt="" style="width: 20px;">
<span v-if="option.labelText" class="option-label-text">{{ option.labelText }}</span>
</div>
</template>
</Select>
</template>
<script setup>
import Select from '@/components/Select/index.vue'
import videoPattern1 from '@/assets/dialog/videoPattern1.svg'
import videoPattern2 from '@/assets/dialog/videoPattern2.svg'
import videoPattern3 from '@/assets/dialog/videoPattern3.svg'
import videoPattern4 from '@/assets/dialog/videoPattern4.svg'
const props = defineProps({
modelValue: {
type: String,
default: '全能参考'
}
})
const emit = defineEmits(['update:modelValue'])
const quantity = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const quantityOptions = [
{ value: '全能参考', label: '全能参考', labelText: '全能参考', icon: videoPattern1 },
{ value: '首尾帧', label: '首尾帧', labelText: '首尾帧', icon: videoPattern2 },
{ value: '智能多帧', label: '智能多帧', labelText: '智能多帧', icon: videoPattern3 },
{ value: '主体参考', label: '主体参考', labelText: '主体参考', icon: videoPattern4 }
]
const selectedIcon = computed(() => {
const option = quantityOptions.find(opt => opt.value === quantity.value)
return option ? option.icon : videoPattern1
})
</script>
<style lang="less" scoped>
.quantity-select {
:deep(.select-header) {
height: 40px;
padding: 0 15px;
border-radius: 10px;
border: 1px solid rgba(0, 0, 0, 0.10);
background: #ffffff;
&:hover {
background: #E5E7EB;
}
}
:deep(.select-text) {
font-size: 14px;
}
:deep(.dropdown-menu) {
min-width: 140px;
}
:deep(.dropdown-item) {
min-width: 80px;
justify-content: start;
}
}
.option-content-custom {
display: flex;
align-items: center;
justify-content: start;
gap: 8px;
}
.option-label-text {
font-size: 14px;
color: inherit;
}
</style>

View File

@ -3,7 +3,7 @@
<div class="proportion-container">
<div class="section">
<h3>选择比例</h3>
<div class="proportion-options">
<div class="proportion-options" :style="{ marginBottom: props.type === 'video' ? '0px' : '20px' }">
<div
v-for="item in proportionOptions"
:key="item.value"
@ -17,7 +17,7 @@
</div>
</div>
<div class="section">
<div v-if="type === 'painting'" class="section">
<h3>选择分辨率</h3>
<div class="resolution-options">
<div
@ -32,7 +32,7 @@
</div>
</div>
<div class="section">
<div v-if="type === 'painting'" class="section">
<h3>尺寸(px)</h3>
<div class="size-inputs">
<div class="input-group">
@ -68,6 +68,10 @@ const props = defineProps({
resolution: {
type: String,
default: '2k'
},
type: {
type: String,
default: 'painting'
}
})
@ -87,11 +91,11 @@ const proportionOptions = [
{ value: '智能', label: '智能' },
{ value: '21:9', label: '21:9' },
{ value: '16:9', label: '16:9' },
{ value: '3:2', label: '3:2' },
// { value: '3:2', label: '3:2' },
{ value: '4:3', label: '4:3' },
{ value: '1:1', label: '1:1' },
{ value: '3:4', label: '3:4' },
{ value: '2:3', label: '2:3' },
// { value: '2:3', label: '2:3' },
{ value: '9:16', label: '9:16' }
]
@ -232,24 +236,25 @@ watch(() => [props.modelValue, props.resolution], () => {
}
.proportion-container{
padding: 10px;
padding: 20px;
}
.section{
margin-bottom: 20px;
border-radius: 20px;
&:last-child{
margin-bottom: 0;
}
h3{
font-size: 14px;
font-weight: 500;
font-family: "Microsoft YaHei";
font-size: 12px;
font-weight: 400;
margin-bottom: 12px;
color: #666;
color: #999;
}
}
.proportion-options{
display: flex;
flex-wrap: nowrap;
@ -257,6 +262,7 @@ watch(() => [props.modelValue, props.resolution], () => {
margin-bottom: 16px;
background-color: #F8F9FA;
padding: 5px;
border-radius: 10px;
}
.proportion-item{
@ -272,15 +278,16 @@ watch(() => [props.modelValue, props.resolution], () => {
transition: all 0.2s ease;
border-radius: 5px;
text-align: bottom;
color: #999;
&::before{
content: '';
width: var(--width, 20px);
height: var(--height, 20px);
background: #f0f0f0;
background: #F5F6F7;
border-radius: 4px;
transition: all 0.2s ease;
border: 2px solid #999;
}
&:hover{
@ -288,8 +295,12 @@ watch(() => [props.modelValue, props.resolution], () => {
}
&.active{
color: #000F33;
background: #ffffff;
}
&.active::before{
border-color: #000F33;
}
}
.resolution-options{

View File

@ -1,45 +1,35 @@
# 高性能虚拟滚动组件
# VirtualScroller 虚拟滚动组件
一个基于 Vue 3 Composition API 开发的高性能虚拟滚动组件,支持从底部开始渲染(容器向上增大),适用于聊天记录、历史列表等场景
一个高性能的虚拟滚动组件,支持未知高度子组件渲染和滚动方向反转功能
## 特性
### 核心功能
- ✅ 虚拟滚动核心,只渲染可视区域内容
- ✅ 支持未知高度内容,自动测量和缓存高度
- ✅ 插槽式组件插入,灵活易用
- ✅ 两种渲染模式:顶部模式(向下滚动)和底部模式(向上滚动)
- ✅ 滚动触发加载更多事件
- ✅ 性能优化,防止滚动抖动
### 补充功能
- ✅ 自定义渲染函数
- ✅ 动态数据更新
- ✅ 滚动位置保存和恢复
- ✅ 自定义滚动阈值
### 外部接口
- ✅ 丰富的 Props 配置
- ✅ 完整的方法暴露
- 🚀 **高性能虚拟滚动** - 仅渲染可视区域内的元素,支持大数据量渲染
- 🔄 **滚动方向反转** - 通过双重 CSS 旋转实现向上滚动效果
- 📏 **未知高度支持** - 动态测量子组件高度,无需预设固定高度
- 🎯 **精确滚动控制** - 提供滚动到指定索引、顶部、底部等 API
- 📱 **响应式设计** - 适配不同屏幕尺寸
- ⚡ **60fps 流畅滚动** - 优化的渲染策略确保流畅体验
## 安装
组件位于 `@/components/virtual-scroller`,无需额外安装依赖。
组件位于 `src/components/virtual-scroller/` 目录下,无需额外安装依赖。
## 快速开始
### 基本使用
## 基础用法
```vue
<template>
<VirtualScroller
:data="list"
:item-height="100"
:estimated-height="100"
render-mode="bottom"
:buffer="5"
class="scroller"
@scroll="handleScroll"
>
<template #default="{ item, index }">
<div>{{ item.text }}</div>
<div class="item" style="transform: rotate(180deg)">
{{ item.name }}
</div>
</template>
</VirtualScroller>
</template>
@ -48,128 +38,185 @@
import { VirtualScroller } from '@/components/virtual-scroller'
const list = ref([
{ id: 1, text: '消息 1' },
{ id: 2, text: '消息 2' }
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
])
const handleScroll = (event) => {
console.log('滚动事件', event)
}
</script>
```
### Props 说明
## Props
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| data | Array | [] | 列表数据 |
| itemHeight | Number | 80 | 预估列表项高度 |
| estimatedHeight | Number | 80 | 未知高度内容的预估高度 |
| renderMode | String | 'bottom' | 渲染模式,'top' \| 'bottom' |
| scrollThreshold | Number | 100 | 滚动触发阈值 |
| onLoadMore | Function | null | 加载更多回调函数 |
| renderItem | Function | null | 自定义渲染函数 |
| keyExtractor | Function | (item, index) => index | 列表项 key 生成函数 |
| cacheHeight | Boolean | true | 是否缓存已测量的高度 |
| buffer | Number | 5 | 预渲染缓冲区数量 |
| 属性名 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| `data` | `Array` | `[]` | 数据源数组(必填) |
| `itemKey` | `string \| Function` | `'id'` | 用于标识每个项目的键名或函数 |
| `estimatedHeight` | `number` | `100` | 预估的项目高度(像素) |
| `buffer` | `number` | `3` | 可视区域外预渲染的项目数量 |
| `height` | `string \| number` | `'100%'` | 滚动容器高度 |
| `width` | `string \| number` | `'100%'` | 滚动容器宽度 |
| `renderMode` | `'default' \| 'top'` | `'default'` | 渲染模式,`top` 模式会自动滚动到页面底部 |
### 事件说明
## Events
| 事件名 | 参数 | 说明 |
|--------|------|------|
| scroll | event | 滚动事件 |
| load-more | - | 加载更多事件 |
| item-height-change | index, height | 列表项高度变化时触发 |
| `scroll` | `event: Event` | 滚动事件,包含 `distanceToPageTop`、`distanceToPageBottom`、`isAtPageTop`、`isAtPageBottom` 属性 |
| `scroll-start` | - | 滚动到**页面顶部**时触发 |
| `scroll-end` | - | 滚动到**页面底部**时触发 |
### 暴露方法
## Expose Methods
通过 `ref` 可以调用以下方法:
通过 `ref` 可以访问以下方法:
```vue
<template>
<VirtualScroller ref="scrollerRef" :data="list" />
</template>
<script setup>
const scrollerRef = ref(null)
// 滚动到底部
scrollerRef.value?.scrollToBottom()
// 滚动到顶部
scrollerRef.value?.scrollToTop()
// 滚动到指定索引
scrollerRef.value?.scrollToIndex(10)
scrollerRef.value.scrollToIndex(10)
// 更新数据
scrollerRef.value?.updateData(newData)
// 滚动到页面底部(最新数据位置)
scrollerRef.value.scrollToBottom()
// 获取当前滚动位置
const position = scrollerRef.value?.getScrollPosition()
// 滚动到页面顶部(最旧数据位置
scrollerRef.value.scrollToTop()
// 设置滚动位置
scrollerRef.value?.setScrollPosition(500)
// 判断是否在页面底部
const atBottom = scrollerRef.value.isAtPageBottom()
// 更新指定项高度
scrollerRef.value?.updateItemHeight(5, 200)
// 清除高度缓存
scrollerRef.value?.clearHeightCache()
// 判断是否在页面顶部
const atTop = scrollerRef.value.isAtPageTop()
</script>
```
## 渲染模式说明
| 方法名 | 参数 | 返回值 | 说明 |
|--------|------|--------|------|
| `scrollToIndex` | `index: number, behavior?: ScrollBehavior` | `void` | 滚动到指定索引的项目 |
| `scrollToBottom` | `behavior?: ScrollBehavior` | `void` | 滚动到**页面底部**(最新数据) |
| `scrollToTop` | `behavior?: ScrollBehavior` | `void` | 滚动到**页面顶部**(最旧数据) |
| `getScrollElement` | - | `HTMLElement \| null` | 获取滚动容器 DOM 元素 |
| `getVisibleIndices` | - | `number[]` | 获取当前可视项目的索引数组 |
| `resetMeasurements` | - | `void` | 重置所有高度测量缓存 |
| `isAtPageBottom` | - | `boolean` | 判断是否在页面底部 |
| `isAtPageTop` | - | `boolean` | 判断是否在页面顶部 |
### 顶部模式 (renderMode: 'top')
## 滚动方向反转原理
内容从顶部开始显示,向下滚动加载更多。适用于普通列表。
组件通过 CSS `transform: rotate(180deg)` 实现滚动方向反转:
1. **容器旋转**:滚动容器应用 `transform: rotate(180deg)`
2. **内容反向旋转**:子组件内部应用 `transform: rotate(180deg)` 抵消旋转
### 坐标映射关系
由于容器旋转 180 度,坐标系统发生反转:
| 页面概念 | 组件内部 scrollTop |
|----------|-------------------|
| 页面顶部(最旧数据) | `scrollTop = scrollHeight - clientHeight` |
| 页面底部(最新数据) | `scrollTop = 0` |
### 滚轮方向处理
组件内部处理了滚轮方向映射:
- 用户**向上**滚动滚轮 → 页面内容**向上**滚动
- 用户**向下**滚动滚轮 → 页面内容**向下**滚动
## 使用示例
```vue
<VirtualScroller
:data="list"
render-mode="top"
:on-load-more="loadMore"
>
<template #default="{ item }">
<div>{{ item.text }}</div>
</template>
</VirtualScroller>
<template>
<div class="container">
<VirtualScroller
ref="scrollerRef"
:data="messageList"
:estimated-height="80"
:buffer="5"
height="600px"
render-mode="top"
@scroll="handleScroll"
@scroll-start="loadMore"
>
<template #default="{ item, index }">
<MessageItem
:key="item.id"
:item="item"
style="transform: rotate(180deg)"
/>
</template>
</VirtualScroller>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { VirtualScroller } from '@/components/virtual-scroller'
import MessageItem from './MessageItem.vue'
const scrollerRef = ref(null)
const messageList = ref([])
const page = ref(1)
const fetchMessages = async () => {
const response = await fetch(`/api/messages?page=${page.value}`)
const data = await response.json()
if (page.value === 1) {
messageList.value = data
} else {
messageList.value = [...data, ...messageList.value]
}
}
const loadMore = () => {
page.value++
fetchMessages()
}
const handleScroll = (event) => {
const { distanceToPageTop, distanceToPageBottom, isAtPageTop, isAtPageBottom } = event
console.log('距离页面顶部:', distanceToPageTop)
console.log('距离页面底部:', distanceToPageBottom)
}
onMounted(() => {
fetchMessages()
})
</script>
```
### 底部模式 (renderMode: 'bottom') ⭐ 核心特性
## 性能优化建议
内容从底部开始显示,向上滚动加载更多,**容器垂直向上增大**。适用于聊天记录、历史列表等场景。
1. **合理设置 `estimatedHeight`**:预估高度越接近实际高度,重排越少
2. **适当调整 `buffer`**:较大的 buffer 会预渲染更多元素,减少白屏但增加内存占用
3. **使用唯一的 `itemKey`**:确保每个项目有唯一标识,避免不必要的重渲染
4. **避免复杂计算**:在插槽中避免复杂计算,使用计算属性或缓存
```vue
<VirtualScroller
:data="list"
render-mode="bottom"
:on-load-more="loadMoreHistory"
>
<template #default="{ item }">
<Message :item="item" />
</template>
</VirtualScroller>
```
## 常见问题
## 性能优化
### Q: 子组件显示倒置怎么办?
- 使用 `requestAnimationFrame` 优化渲染
- 节流处理滚动事件16ms
- 高度缓存机制,避免重复测量
- 使用 Intersection Observer 优化可见性检测
- 只渲染可视区域 + 缓冲区内容
A: 在子组件上添加 `style="transform: rotate(180deg)"` 来抵消容器的旋转。
## 注意事项
### Q: 如何判断是否滚动到页面底部?
1. `renderMode: 'bottom'` 模式下,数据建议按时间倒序排列(最新的在最后)
2. 列表项需要有明确的高度或能被正确测量
3. 使用图片等异步内容时,建议设置 `min-height` 防止布局抖动
A: 使用 `isAtPageBottom()` 方法或监听 `scroll-end` 事件。
## 文件结构
### Q: scrollToBottom 和 scrollToTop 的方向?
```
virtual-scroller/
├── index.js # 组件入口
├── VirtualScroller.vue # 主组件
├── VirtualScrollerItem.vue # 列表项组件
├── useVirtualScroller.js # 组合式 API 钩子
└── README.md # 本文档
```
A:
- `scrollToBottom()` - 滚动到**页面底部**(最新数据位置)
- `scrollToTop()` - 滚动到**页面顶部**(最旧数据位置)
## 浏览器兼容性
- Chrome >= 64
- Firefox >= 69
- Safari >= 13.1
- Edge >= 79
需要浏览器支持 `ResizeObserver` API。

View File

@ -1,140 +1,690 @@
<script setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted, defineExpose } from 'vue'
import useVirtualScroller from './useVirtualScroller'
import VirtualScrollerItem from './VirtualScrollerItem.vue'
const props = defineProps({
data: {
type: Array,
default: () => []
},
itemHeight: {
type: Number,
default: 80
},
renderMode: {
type: String,
default: 'bottom',
validator: (value) => ['top', 'bottom'].includes(value)
},
scrollThreshold: {
type: Number,
default: 100
},
onLoadMore: {
type: Function,
default: null
},
renderItem: {
type: Function,
default: null
},
keyExtractor: {
type: Function,
default: (item, index) => index
},
estimatedHeight: {
type: Number,
default: 80
},
cacheHeight: {
type: Boolean,
default: true
},
buffer: {
type: Number,
default: 5
}
})
const emit = defineEmits(['scroll', 'load-more', 'item-height-change'])
const scrollerRef = ref(null)
const {
visibleData,
totalHeight,
scrollToBottom,
scrollToTop,
scrollToIndex,
updateData,
getScrollPosition,
setScrollPosition,
updateItemHeight,
clearHeightCache,
handleScroll: _handleScroll,
initScroll
} = useVirtualScroller(props, emit, scrollerRef)
const handleScroll = (event) => {
_handleScroll(event)
emit('scroll', event)
}
const handleItemHeightChange = (index, height) => {
updateItemHeight(index, height)
emit('item-height-change', index, height)
}
defineExpose({
scrollToBottom,
scrollToTop,
scrollToIndex,
updateData,
getScrollPosition,
setScrollPosition,
updateItemHeight,
clearHeightCache
})
onMounted(() => {
nextTick(() => {
initScroll()
})
})
</script>
<template>
<div
ref="scrollerRef"
ref="containerRef"
class="virtual-scroller"
@scroll="handleScroll"
:style="containerStyle"
>
<div
class="virtual-scroller-content"
:style="{ height: `${totalHeight}px`, position: 'relative' }"
ref="wrapperRef"
class="virtual-scroller-wrapper"
:style="wrapperStyle"
>
<VirtualScrollerItem
v-for="(item, index) in visibleData"
:key="keyExtractor(item.item, item.index)"
:item="item.item"
:index="item.index"
:top="item.top"
:estimated-height="estimatedHeight"
:cache-height="cacheHeight"
@height-change="(height) => handleItemHeightChange(item.index, height)"
<div
ref="renderContainerRef"
class="virtual-scroller-render-container"
:style="renderContainerStyle"
>
<template v-if="renderItem">
<component :is="renderItem(item.item, item.index)" />
</template>
<slot v-else :item="item.item" :index="item.index">
<div>{{ item.item }}</div>
</slot>
</VirtualScrollerItem>
<div class="virtual-scroller-spacer" :style="{ height: `${totalSize}px` }"></div>
<div
class="virtual-scroller-bottom-placeholder"
:style="bottomPlaceholderStyle"
>
<slot name="bottom-placeholder" />
</div>
<div
v-for="renderItem in pool"
:key="renderItem.nr.key"
:ref="el => setItemRef(el, renderItem.nr.key)"
class="virtual-scroller-item"
:style="getItemStyle(renderItem)"
:data-index="renderItem.nr.index"
:data-key="renderItem.nr.key"
>
<slot
name="default"
:item="renderItem.item"
:index="renderItem.nr.index"
/>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.virtual-scroller {
width: 100%;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
position: relative;
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowReactive, watch, markRaw } from 'vue'
const props = defineProps({
data: {
type: Array,
required: true,
default: () => []
},
itemKey: {
type: [String, Function],
default: 'id'
},
estimatedHeight: {
type: Number,
default: null
},
minItemSize: {
type: [Number, String],
default: null
},
buffer: {
type: Number,
default: 200
},
height: {
type: [String, Number],
default: '100%'
},
width: {
type: [String, Number],
default: '100%'
},
renderMode: {
type: String,
default: 'default',
validator: (value) => ['default', 'top'].includes(value)
},
bottomPlaceholderHeight: {
type: Number,
default: 350
}
})
const emit = defineEmits(['scroll', 'scroll-start', 'scroll-end', 'resize', 'update'])
const containerRef = ref(null)
const wrapperRef = ref(null)
const renderContainerRef = ref(null)
const itemRefs = new Map()
const resizeObserver = ref(null)
const scrollTop = ref(0)
const isScrolling = ref(false)
const scrollTimeout = ref(null)
const isInitialized = ref(false)
const pendingScrollToBottom = ref(false)
let uid = 0
const pool = ref([])
const viewMap = new Map()
const unusedViews = new Map()
const itemSizeMap = ref(new Map())
const totalSize = ref(0)
let $_startIndex = 0
let $_endIndex = 0
let $_scrollDirty = false
let $_lastUpdateScrollPosition = 0
const containerStyle = computed(() => {
return {
height: '100%',
width: '100%',
position: 'relative'
}
})
const getItemKey = (item, index) => {
if (typeof props.itemKey === 'function') {
return props.itemKey(item, index)
}
if (typeof props.itemKey === 'string' && item && typeof item === 'object') {
return item[props.itemKey] ?? index
}
return index
}
.virtual-scroller-content {
width: 100%;
position: relative;
const minSize = computed(() => {
if (props.minItemSize) {
return typeof props.minItemSize === 'string' ? parseInt(props.minItemSize, 10) : props.minItemSize
}
return props.estimatedHeight || 50
})
const sizes = computed(() => {
const sizesMap = {
'-1': { accumulator: 0 }
}
const items = props.data
let accumulator = 0
let computedMinSize = 10000
for (let i = 0, l = items.length; i < l; i++) {
const key = getItemKey(items[i], i)
let size = itemSizeMap.value.get(key)
if (size === undefined) {
size = minSize.value
}
if (size < computedMinSize) {
computedMinSize = size
}
accumulator += size
sizesMap[i] = { accumulator, size }
}
return sizesMap
})
const wrapperStyle = computed(() => ({
direction: 'rtl',
height: '100%',
position: 'relative',
scrollbarWidth: 'auto',
overflow: 'hidden',
transform: 'rotate(180deg)',
width: '100%'
}))
const renderContainerStyle = computed(() => ({
direction: 'ltr',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
bottom: 0,
left: 0,
overflowX: 'hidden',
overflowY: 'auto',
position: 'absolute',
right: 0,
top: 0,
width: '100%'
}))
const bottomPlaceholderStyle = computed(() => ({
position: 'absolute',
left: 0,
right: 0,
top: 0,
width: '100%',
height: `${props.bottomPlaceholderHeight}px`,
transform: `translateY(0px)`,
zIndex: 1
}))
const getItemStyle = (renderItem) => {
return {
position: 'absolute',
left: 0,
right: 0,
top: 0,
width: '100%',
transform: `translateY(${renderItem.position}px)`,
willChange: 'transform'
}
}
const setItemRef = (el, key) => {
if (el) {
itemRefs.set(key, el)
} else {
itemRefs.delete(key)
}
}
const addView = (index, item, key) => {
const nr = markRaw({
id: uid++,
index,
used: true,
key
})
const view = shallowReactive({
item,
position: 0,
nr
})
pool.value.push(view)
return view
}
const unuseView = (view) => {
view.nr.used = false
view.position = -9999
}
const getScroll = () => {
const el = renderContainerRef.value
if (!el) return { start: 0, end: 0 }
return {
start: el.scrollTop,
end: el.scrollTop + el.clientHeight
}
}
const updateVisibleItems = (checkItem = false, checkPositionDiff = false) => {
const items = props.data
const count = items.length
const sizesMap = sizes.value
const keyField = typeof props.itemKey === 'string' ? props.itemKey : 'id'
if (!count) {
pool.value = []
viewMap.clear()
totalSize.value = props.bottomPlaceholderHeight
return { continuous: true }
}
const el = renderContainerRef.value
if (!el) return { continuous: true }
const scrollHeight = el.scrollHeight
const scrollTop = el.scrollTop
const clientHeight = el.clientHeight
const visualScrollStart = scrollHeight - scrollTop - clientHeight
const visualScrollEnd = scrollHeight - scrollTop
if (checkPositionDiff) {
let positionDiff = visualScrollStart - $_lastUpdateScrollPosition
if (positionDiff < 0) positionDiff = -positionDiff
if (positionDiff < minSize.value) {
return { continuous: true }
}
}
$_lastUpdateScrollPosition = visualScrollStart
const buffer = props.buffer
let scrollStart = visualScrollStart - buffer
let scrollEnd = visualScrollEnd + buffer
scrollStart -= props.bottomPlaceholderHeight
scrollEnd += props.bottomPlaceholderHeight
scrollStart = Math.max(0, scrollStart)
let startIndex = 0
let endIndex = count - 1
let newTotalSize = 0
let a = 0
let b = count - 1
let i = ~~(count / 2)
let oldI
do {
oldI = i
const h = sizesMap[i]?.accumulator || 0
if (h < scrollStart) {
a = i
} else if (i < count - 1 && (sizesMap[i + 1]?.accumulator || 0) > scrollStart) {
b = i
}
i = ~~((a + b) / 2)
} while (i !== oldI)
i < 0 && (i = 0)
startIndex = i
newTotalSize = sizesMap[count - 1]?.accumulator || count * minSize.value
for (endIndex = i; endIndex < count && (sizesMap[endIndex]?.accumulator || 0) < scrollEnd; endIndex++);
if (endIndex === -1) {
endIndex = count - 1
} else {
endIndex++
endIndex > count && (endIndex = count)
}
totalSize.value = newTotalSize + props.bottomPlaceholderHeight
const continuous = startIndex <= $_endIndex && endIndex >= $_startIndex
if (continuous) {
for (let j = 0, l = pool.value.length; j < l; j++) {
const view = pool.value[j]
if (view.nr.used) {
if (checkItem) {
const newKey = getItemKey(view.item, view.nr.index)
let newIndex = -1
for (let k = 0; k < count; k++) {
if (getItemKey(items[k], k) === newKey) {
newIndex = k
break
}
}
view.nr.index = newIndex
}
if (
view.nr.index == null ||
view.nr.index < startIndex ||
view.nr.index >= endIndex
) {
unuseView(view)
}
}
}
}
for (let j = startIndex; j < endIndex; j++) {
const item = items[j]
if (!item) continue
const key = getItemKey(item, j)
let view = viewMap.get(key)
if (!view) {
view = addView(j, item, key)
viewMap.set(key, view)
} else {
if (!view.nr.used) {
view.nr.used = true
}
}
view.item = item
view.nr.index = j
view.nr.key = key
const prevSize = j > 0 ? sizesMap[j - 1] : { accumulator: 0 }
view.position = (prevSize?.accumulator || 0) + props.bottomPlaceholderHeight
}
pool.value = pool.value.filter(v => v.nr.used)
$_startIndex = startIndex
$_endIndex = endIndex
emit('update', startIndex, endIndex)
nextTick(() => {
observeVisibleItems()
})
return { continuous }
}
const measureItem = (key, element) => {
if (!element) return
const firstChild = element.firstElementChild
const targetElement = firstChild || element
const height = targetElement.getBoundingClientRect().height
if (height > 0) {
const currentSize = itemSizeMap.value.get(key)
if (currentSize !== height) {
const newSizes = new Map(itemSizeMap.value)
newSizes.set(key, height)
itemSizeMap.value = newSizes
}
}
}
const setupResizeObserver = () => {
if (resizeObserver.value) {
resizeObserver.value.disconnect()
}
resizeObserver.value = new ResizeObserver((entries) => {
let hasChanges = false
for (const entry of entries) {
const key = entry.target.dataset.key
if (key !== undefined) {
const oldSize = itemSizeMap.value.get(key)
measureItem(key, entry.target)
const newSize = itemSizeMap.value.get(key)
if (oldSize !== newSize) {
hasChanges = true
}
}
}
if (hasChanges) {
updateVisibleItems(false)
}
})
}
const handleWheel = (event) => {
if (!renderContainerRef.value) return
const { deltaY } = event
const el = renderContainerRef.value
el.scrollBy({
top: -deltaY,
behavior: 'instant'
})
event.preventDefault()
}
const handleScroll = (event) => {
if (!$_scrollDirty) {
$_scrollDirty = true
requestAnimationFrame(() => {
$_scrollDirty = false
updateVisibleItems(false, true)
})
}
const target = event.target
scrollTop.value = target.scrollTop
isScrolling.value = true
if (scrollTimeout.value) {
clearTimeout(scrollTimeout.value)
}
scrollTimeout.value = setTimeout(() => {
isScrolling.value = false
}, 150)
const st = target.scrollTop
const scrollHeight = target.scrollHeight
const clientHeight = target.clientHeight
const distanceToContainerTop = st
const distanceToContainerBottom = scrollHeight - st - clientHeight
const distanceToPageTop = distanceToContainerBottom
const distanceToPageBottom = distanceToContainerTop
const isAtPageTop = distanceToPageTop <= 0
const isAtPageBottom = distanceToPageBottom <= 0
emit('scroll', {
target,
scrollTop: st,
scrollHeight,
clientHeight,
distanceToPageTop,
distanceToPageBottom,
isAtPageTop,
isAtPageBottom
})
if (isAtPageTop) {
emit('scroll-start')
}
if (isAtPageBottom) {
emit('scroll-end')
}
}
const scrollToIndex = (index, behavior = 'auto') => {
if (!renderContainerRef.value || index < 0 || index >= props.data.length) return
const sizesMap = sizes.value
const offset = index > 0 ? (sizesMap[index - 1]?.accumulator || 0) : 0
renderContainerRef.value.scrollTo({
top: offset,
behavior
})
}
const scrollToBottom = (behavior = 'smooth') => {
if (!renderContainerRef.value) {
pendingScrollToBottom.value = true
return
}
requestAnimationFrame(() => {
if (!renderContainerRef.value) return
renderContainerRef.value.scrollTo({
top: 0,
behavior
})
})
}
const scrollToTop = (behavior = 'smooth') => {
if (!renderContainerRef.value) return
requestAnimationFrame(() => {
if (!renderContainerRef.value) return
const scrollHeight = renderContainerRef.value.scrollHeight
renderContainerRef.value.scrollTo({
top: scrollHeight,
behavior
})
})
}
const getScrollElement = () => renderContainerRef.value
const getVisibleIndices = () => {
const indices = []
for (let i = $_startIndex; i < $_endIndex; i++) {
indices.push(i)
}
return indices
}
const resetMeasurements = () => {
itemSizeMap.value = new Map()
itemRefs.clear()
pool.value = []
viewMap.clear()
scrollTop.value = 0
$_startIndex = 0
$_endIndex = 0
}
const isAtPageBottom = () => {
if (!renderContainerRef.value) return false
const { scrollTop } = renderContainerRef.value
return scrollTop <= 0
}
const isAtPageTop = () => {
if (!renderContainerRef.value) return false
const { scrollTop, scrollHeight, clientHeight } = renderContainerRef.value
return scrollHeight - scrollTop - clientHeight <= 0
}
const observeVisibleItems = () => {
if (!resizeObserver.value) return
resizeObserver.value.disconnect()
for (const [key, element] of itemRefs) {
if (element) {
resizeObserver.value.observe(element)
}
}
}
watch(() => props.data, () => {
itemSizeMap.value = new Map()
pool.value = []
viewMap.clear()
itemRefs.clear()
$_startIndex = 0
$_endIndex = 0
nextTick(() => {
updateVisibleItems(true)
})
}, { deep: true })
watch(sizes, () => {
updateVisibleItems(false)
}, { deep: true })
onMounted(() => {
setupResizeObserver()
isInitialized.value = true
nextTick(() => {
if (pendingScrollToBottom.value) {
pendingScrollToBottom.value = false
scrollToBottom()
}
updateVisibleItems(true)
})
})
onBeforeUnmount(() => {
if (resizeObserver.value) {
resizeObserver.value.disconnect()
}
if (scrollTimeout.value) {
clearTimeout(scrollTimeout.value)
}
itemRefs.clear()
viewMap.clear()
})
defineExpose({
scrollToIndex,
scrollToBottom,
scrollToTop,
getScrollElement,
getVisibleIndices,
resetMeasurements,
containerRef,
isAtPageBottom,
isAtPageTop
})
</script>
<style lang="less" scoped>
.virtual-scroller {
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
.virtual-scroller-wrapper {
contain: content;
}
.virtual-scroller-spacer {
flex-shrink: 0;
width: 100%;
}
.virtual-scroller-placeholder {
width: 100%;
}
.virtual-scroller-render-container {
contain: layout style;
}
.virtual-scroller-item {
contain: layout style;
backface-visibility: hidden;
perspective: 1000px;
}
.virtual-scroller-bottom-placeholder {
contain: layout style;
}
}
</style>

View File

@ -1,96 +0,0 @@
<script setup>
import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue'
const props = defineProps({
item: {
type: [Object, String, Number],
default: null
},
index: {
type: Number,
required: true
},
top: {
type: Number,
default: 0
},
estimatedHeight: {
type: Number,
default: 80
},
cacheHeight: {
type: Boolean,
default: true
}
})
const emit = defineEmits(['height-change'])
const itemRef = ref(null)
const measuredHeight = ref(props.estimatedHeight)
let heightCache = props.estimatedHeight
let resizeObserver = null
const measureHeight = () => {
if (itemRef.value) {
const rect = itemRef.value.getBoundingClientRect()
const newHeight = rect.height
if (newHeight !== measuredHeight.value && newHeight > 0) {
measuredHeight.value = newHeight
emit('height-change', newHeight)
}
}
}
const initResizeObserver = () => {
if (typeof ResizeObserver !== 'undefined' && itemRef.value) {
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const newHeight = entry.contentRect.height
if (newHeight !== measuredHeight.value && newHeight > 0) {
measuredHeight.value = newHeight
emit('height-change', newHeight)
}
}
})
resizeObserver.observe(itemRef.value)
}
}
onMounted(() => {
nextTick(() => {
measureHeight()
initResizeObserver()
})
})
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = null
}
})
</script>
<template>
<div
ref="itemRef"
class="virtual-scroller-item"
:style="{
position: 'absolute',
top: `${top}px`,
left: 0,
right: 0,
minHeight: `${estimatedHeight}px`
}"
>
<slot :item="item" :index="index"></slot>
</div>
</template>
<style scoped>
.virtual-scroller-item {
width: 100%;
box-sizing: border-box;
}
</style>

View File

@ -1,11 +1,4 @@
import VirtualScroller from './VirtualScroller.vue'
import VirtualScrollerItem from './VirtualScrollerItem.vue'
import useVirtualScroller from './useVirtualScroller'
export {
VirtualScroller,
VirtualScrollerItem,
useVirtualScroller
}
export { VirtualScroller }
export default VirtualScroller

View File

@ -1,246 +0,0 @@
import { ref, computed, watch, nextTick } from 'vue'
export default function useVirtualScroller(props, emit, scrollerRef) {
const heights = ref(new Map())
const positions = ref([])
const scrollTop = ref(0)
const clientHeight = ref(0)
const isLoading = ref(false)
let lastScrollTime = 0
let animationFrameId = null
const savedScrollPosition = ref(null)
const getHeight = (index) => {
return heights.value.get(index) || props.estimatedHeight
}
const initPositions = () => {
const data = props.data || []
positions.value = []
let top = 0
for (let i = 0; i < data.length; i++) {
const height = getHeight(i)
positions.value.push({
index: i,
top,
bottom: top + height,
height
})
top += height
}
}
const totalHeight = computed(() => {
if (positions.value.length === 0) return 0
return positions.value[positions.value.length - 1].bottom
})
const getVisibleRange = () => {
const data = props.data || []
if (data.length === 0) return { start: 0, end: 0 }
let start = 0
let end = data.length
const currentScrollTop = scrollTop.value
const currentClientHeight = clientHeight.value
for (let i = 0; i < positions.value.length; i++) {
if (positions.value[i].bottom > currentScrollTop) {
start = i
break
}
}
for (let i = start; i < positions.value.length; i++) {
if (positions.value[i].top > currentScrollTop + currentClientHeight) {
end = i
break
}
}
start = Math.max(0, start - props.buffer)
end = Math.min(data.length, end + props.buffer)
return { start, end }
}
const visibleData = computed(() => {
const { start, end } = getVisibleRange()
const data = props.data || []
const result = []
for (let i = start; i < end; i++) {
result.push({
item: data[i],
index: i,
top: positions.value[i]?.top || 0
})
}
return result
})
const handleScroll = (event) => {
const now = Date.now()
const throttleTime = 16
if (now - lastScrollTime < throttleTime) return
lastScrollTime = now
if (animationFrameId) {
cancelAnimationFrame(animationFrameId)
}
animationFrameId = requestAnimationFrame(() => {
const target = event.target
scrollTop.value = target.scrollTop
clientHeight.value = target.clientHeight
checkLoadMore(target)
})
}
const checkLoadMore = (target) => {
if (isLoading.value || !props.onLoadMore) return
const { scrollTop: st, scrollHeight, clientHeight: ch } = target
const distanceToTop = st
const distanceToBottom = scrollHeight - st - ch
if (props.renderMode === 'bottom') {
if (distanceToTop <= props.scrollThreshold) {
loadMore()
}
} else {
if (distanceToBottom <= props.scrollThreshold) {
loadMore()
}
}
}
const loadMore = async () => {
if (isLoading.value || !props.onLoadMore) return
isLoading.value = true
emit('load-more')
try {
await props.onLoadMore()
} finally {
isLoading.value = false
}
}
const scrollToBottom = () => {
nextTick(() => {
if (scrollerRef.value) {
scrollerRef.value.scrollTop = scrollerRef.value.scrollHeight
}
})
}
const scrollToTop = () => {
nextTick(() => {
if (scrollerRef.value) {
scrollerRef.value.scrollTop = 0
}
})
}
const scrollToIndex = (index) => {
nextTick(() => {
if (scrollerRef.value && positions.value[index]) {
scrollerRef.value.scrollTop = positions.value[index].top
}
})
}
const updateData = (newData) => {
initPositions()
}
const getScrollPosition = () => {
if (scrollerRef.value) {
return scrollerRef.value.scrollTop
}
return 0
}
const setScrollPosition = (position) => {
nextTick(() => {
if (scrollerRef.value) {
scrollerRef.value.scrollTop = position
}
})
}
const updateItemHeight = (index, height) => {
if (!props.cacheHeight) return
const oldHeight = heights.value.get(index) || props.estimatedHeight
if (oldHeight === height) return
heights.value.set(index, height)
const oldScrollTop = scrollerRef.value?.scrollTop || 0
const oldScrollHeight = scrollerRef.value?.scrollHeight || 0
initPositions()
nextTick(() => {
if (scrollerRef.value && props.renderMode === 'bottom') {
const newScrollHeight = scrollerRef.value.scrollHeight
const heightDiff = newScrollHeight - oldScrollHeight
if (heightDiff > 0) {
scrollerRef.value.scrollTop = oldScrollTop + heightDiff
}
}
})
}
const clearHeightCache = () => {
heights.value.clear()
initPositions()
}
const initScroll = () => {
initPositions()
nextTick(() => {
if (scrollerRef.value) {
clientHeight.value = scrollerRef.value.clientHeight
if (props.renderMode === 'bottom') {
scrollToBottom()
}
}
})
}
watch(() => props.data, () => {
const oldScrollTop = scrollerRef.value?.scrollTop || 0
const oldScrollHeight = scrollerRef.value?.scrollHeight || 0
initPositions()
nextTick(() => {
if (scrollerRef.value && props.renderMode === 'bottom') {
const newScrollHeight = scrollerRef.value.scrollHeight
const heightDiff = newScrollHeight - oldScrollHeight
if (heightDiff > 0) {
scrollerRef.value.scrollTop = oldScrollTop + heightDiff
}
}
})
}, { deep: true, immediate: true })
return {
visibleData,
totalHeight,
scrollToBottom,
scrollToTop,
scrollToIndex,
updateData,
getScrollPosition,
setScrollPosition,
updateItemHeight,
clearHeightCache,
handleScroll,
initScroll
}
}

View File

@ -0,0 +1,17 @@
{
"generate": [
{ "value": "flux", "label": "flux" },
{ "value": "zImage", "label": "Z-image" },
{ "value": "jimeng", "label": "jimeng" },
{ "value": "QwenImage", "label": "QwenImage" }
],
"edit": [
{ "value": "BananaPro", "label": "Banana-Pro" },
{ "value": "Qwen-image", "label": "Qwen-image" },
{ "value": "Kontext", "label": "Kontext" },
{ "value": "Jimeng_4.0", "label": "Jimeng.4.0" }
],
"vision": [
{ "value": "Qwen3.5plus", "label": "Qwen3.5plus" }
]
}

View File

@ -0,0 +1,8 @@
{
"video": [
{ "value": "FlashHead", "label": "FlashHead" },
{ "value": "LTX2.3-T2V", "label": "LTX 2.3-T2V" },
{ "value": "Vidu Q3-I2V", "label": "Vidu Q3-I2V" },
{ "value": "Vidu Q3-T2V", "label": "Vidu Q3-T2V" }
]
}

View File

@ -3,6 +3,9 @@ const DisplayStoreSetup = () => {
const scrollerRef = ref(null)
const tempList = ref([])
const isSubGerenate = ref(false)
const currentPage = ref(0)
const hasMoreData = ref(true)
const isLoading = ref(false)
const addGeneratingItem = (item) => {
const newItem = {
@ -15,34 +18,47 @@ const DisplayStoreSetup = () => {
files: [],
...item
}
tempList.value.push(newItem)
tempList.value.unshift(newItem)
return newItem
}
const updateItemToSuccess = (taskId, fileUrl) => {
const updateItemToSuccess = (taskId, fileUrls) => {
const index = tempList.value.findIndex(item => item.id === taskId)
if (index !== -1) {
tempList.value[index].status = 'success'
tempList.value[index].files = [fileUrl]
tempList.value[index].files = Array.isArray(fileUrls) ? fileUrls : [fileUrls]
}
}
const initHistoryList = (historyList) => {
tempList.value = historyList
currentPage.value = 1
hasMoreData.value = true
}
const prependHistoryList = (historyList) => {
tempList.value = [...historyList, ...tempList.value]
}
const appendHistoryList = (historyList) => {
tempList.value = [...tempList.value, ...historyList]
}
const resetPagination = () => {
currentPage.value = 0
hasMoreData.value = true
isLoading.value = false
}
const scrollToBottom = async () => {
console.log('store - 滚动到底部')
const refValue = scrollerRef.value
if (!refValue) {
console.log('store - scrollerRef 不存在')
return
}
try {
if (typeof refValue.scrollToBottom === 'function') {
console.log('store - 使用新组件 scrollToBottom')
await nextTick()
refValue.scrollToBottom()
return
@ -52,18 +68,16 @@ const DisplayStoreSetup = () => {
if (scrollerEl) {
const viewport = scrollerEl.querySelector('.vue-recycle-scroller__viewport')
if (viewport) {
console.log('store - 原生滚动, scrollHeight:', viewport.scrollHeight)
viewport.scrollTop = viewport.scrollHeight
}
}
if (typeof refValue.scrollToItem === 'function' && tempList.value && tempList.value.length > 0) {
console.log('store - scrollToItem, index:', tempList.value.length - 1)
await nextTick()
refValue.scrollToItem(tempList.value.length - 1)
}
} catch (error) {
console.error('store - 滚动出错:', error)
console.error('滚动出错:', error)
}
}
@ -72,9 +86,15 @@ const DisplayStoreSetup = () => {
scrollerRef,
tempList,
isSubGerenate,
currentPage,
hasMoreData,
isLoading,
addGeneratingItem,
updateItemToSuccess,
initHistoryList,
prependHistoryList,
appendHistoryList,
resetPagination,
scrollToBottom
}
}

View File

@ -20,7 +20,8 @@ export async function createTask(data, type, taskId, token) {
// 获取结果
export async function getTask(result) {
if (result.code === 0 && result.msg === 'success' && Array.isArray(result.data) && result.data.length > 0) {
return { type: true, url: result.data[0].fileUrl }
const urls = result.data.map(item => item.fileUrl)
return { type: true, urls: urls }
}
return { type: false, message: result.data?.exception_message || '生成失败' }
}
}

View File

@ -5,6 +5,17 @@ import { getToken } from '@/utils/auth'
import { createTask, getTask } from '@/utils/createTask'
import { userError } from '@/utils/tokenError'
export function getChargeType(chargeType) {
switch (chargeType) {
case 'painting':
return 1
case 'video':
return 2
default:
return 2
}
}
export function websocketError(code, msg) {
let message
switch (code) {
@ -50,7 +61,6 @@ export function websocketSuccess() {
export async function generate(type, data) {
const progress_text = ref('')
const message = ref('')
const previewUrl = ref('')
const useDisplay = useDisplayStore()
const token = getToken()
const taskId = crypto.randomUUID()
@ -138,10 +148,9 @@ export async function generate(type, data) {
console.log('收到服务器消息:', res)
const result = await getTask(res)
if (result.type) {
previewUrl.value = result.url
if (currentTaskId) {
useDisplay.updateItemToSuccess(currentTaskId, result.url)
useDisplay.updateItemToSuccess(currentTaskId, result.urls)
}
websocketSuccess()

View File

@ -1,5 +1,5 @@
<template>
<div style="width: 100%;display: flex;justify-content: center;align-items: center;">
<div style="width: 100%;display: flex;justify-content: center;align-items: center;transform: rotate(180deg);">
<div class="primary-box" :class="{ 'none-primary-box': props.item.status === 'none' }">
<!-- 标题 -->
<div class="title">
@ -61,15 +61,16 @@
<div class="left-top-btn" @click="addCollection(file)"><img src="@/assets/display/collection.svg" /></div>
</div>
<div class="bottom-brush">
<el-tooltip
effect="dark"
content="画笔"
placement="top"
>
<img @click.stop="AIvideo(file, index)" :src="brush" />
</el-tooltip>
</div>
<el-tooltip
effect="dark"
content="画笔"
placement="top"
:hide-after="0"
>
<div @click.stop="AIbrush(file, index)" class="bottom-brush">
<img :src="brush" />
</div>
</el-tooltip>
</div>
</div>
@ -91,6 +92,7 @@ import reEditIcon from '@/assets/display/reEdit.svg'
import againGenerateIcon from '@/assets/display/againGenerate.svg'
import deleteImageIcon from '@/assets/display/deleteImage.svg'
import Img from '@/components/Img/index.vue'
import { cancelOrCollect } from '@/apis/display'
const props = defineProps({
item: {
@ -112,8 +114,12 @@ const generateStatusText = computed(() => {
return ''
})
const AIvideo = (file, index) => {
emit('open-canvas', file)
const AIbrush = (file, index) => {
emit('open-canvas', {
mainImage: { url: file, index: index + 1 },
referenceImages: [],
source: 'set'
})
}
const reEdit = (url, number) => {
@ -146,15 +152,29 @@ const bottomBtnGroup = [
}
]
const addCollection = (url) => {
console.log('添加收藏:', url)
const addCollection = async (url) => {
try {
const res = await cancelOrCollect({
taskId: props.item.id,
userId: useUser.userInfo.id,
url: url
})
if (res.success) {
ElMessage.success(res.message || '操作成功')
} else {
ElMessage.error(res.message || '操作失败')
}
} catch (error) {
console.error('收藏操作失败:', error)
ElMessage.error('收藏操作失败')
}
}
</script>
<style lang="less" scoped>
.primary-box{
width: 100%;
max-width: 1024px;
max-width: 1118px;
display: flex;
flex-direction: column;
flex: 1;

View File

@ -1,350 +0,0 @@
<template>
<div id="display" class="content-area">
<RefreshOverlay :visible="refreshing" />
<div class="back">
<img src="@/assets/display/back.svg" alt="">
<span class="title-text">退出</span>
</div>
<div v-if="props.if" class="btn-container">
<div class="btn">
<!-- <span class="btn-text">全部</span> -->
<img src="@/assets/display/search.svg" alt="">
</div>
<span class="line"></span>
<div class="btn">
<Select v-model="selectedTime" :options="timeOptions" width="auto">
<template #prefix>
<i-ep-Calendar />
</template>
<template #header>
<div class="header">
<el-date-picker
v-model="value1"
type="daterange"
start-placeholder="Start date"
end-placeholder="End date"
:size="size"
/>
</div>
</template>
</Select>
</div>
<span class="line"></span>
<div class="btn">
<Select v-model="selectedFavorite" :options="favoriteOptions" width="auto" >
<template #prefix>
<i-ep-Star />
</template>
</Select>
</div>
</div>
<div v-if="props.if" ref="scrollerRef" class="scroller" @scroll="handleScroll">
<div v-for="(item, index) in list" :key="item.id" class="item-wrapper">
<Set :key="`${item.id}`" :item="item" />
</div>
</div>
</div>
</template>
<script setup>
import { useDisplayStore, useParamStore, useUserStore } from '@/stores'
import { storeToRefs } from 'pinia'
import Set from './components/set.vue'
import RefreshOverlay from './components/RefreshOverlay.vue'
import Select from '@/components/Select/index.vue'
import { getGenerateHistoryList } from '@/apis/display'
import { useRouter } from 'vue-router'
const props = defineProps({
if: {
type: Boolean,
default: false
},
loading: {
type: Boolean,
default: false
}
})
const useDisplay = useDisplayStore()
const useParams = useParamStore()
const userStore = useUserStore()
const router = useRouter()
const refreshing = ref(false)
const scrollerRef = ref(null)
const isLoadingMore = ref(false)
const activeTab = ref('all')
const isInitializing = ref(true)
let total = 0
const timeOptions = [
{ label: '全部', value: 'all' },
{ label: '最近一周', value: 'week' },
{ label: '最近一个月', value: 'month' },
{ label: '最近三个月', value: 'quarter' }
]
const favoriteOptions = [
{ label: '全部', value: 'all' },
{ label: '已收藏', value: 'favorite' }
]
const selectedTime = ref('all')
const selectedFavorite = ref('all')
const { tempList } = storeToRefs(useDisplay)
const activeFilter = ref('all')
const list = computed(() => {
const data = tempList.value || []
if (activeFilter.value === 'all') {
return data
}
return data.filter((item) => item.type === activeFilter.value)
})
const page = ref(1)
const toggleDisplay = (newValue, oldValue) => {
activeFilter.value = newValue
}
const conversion = (newlist) => {
const temp = newlist.data.records.map((item) => {
return {
id: item.taskId,
collection: item.collection,
status: 'success',
prompt: item.prompt,
params: item.params,
time: item.createTime,
files: [item.fileUrl]
}
})
return temp
}
const fetchHistory= async (isScrollTopLoad = false) => {
try {
if (isScrollTopLoad) {
return
}
const result = await getGenerateHistoryList({ userId: userStore.userInfo.id, chargeType: 1 })
total = result.data ? result.data.length : 0
if (total === 0) {
useDisplay.Sender_variant = 'updown'
router.push({ name: 'home' })
}
const wrappedData = {
data: {
records: result.data
}
}
const convertedList = conversion(wrappedData)
const adaptedList = convertedList.map((item, index) => {
const originalItem = result.data[index]
return {
...item,
text: originalItem?.title || item.prompt || '生成图片',
name: originalItem?.title || item.prompt || '生成图片',
type: 'image',
title: originalItem?.title || '生成图片'
}
})
if (!isScrollTopLoad && adaptedList.length > 0) {
useDisplay.initHistoryList(adaptedList)
await nextTick()
const scrollToBottomDirect = (force = false) => {
if (scrollerRef.value) {
console.log('直接滚动 - scrollHeight:', scrollerRef.value.scrollHeight, 'force:', force)
if (force) {
scrollerRef.value.scrollTop = scrollerRef.value.scrollHeight + 1000
} else {
scrollerRef.value.scrollTop = scrollerRef.value.scrollHeight
}
}
}
for (let i = 0; i < 20; i++) {
setTimeout(() => {
scrollToBottomDirect(i >= 15)
}, 60 * i)
}
setTimeout(() => {
scrollToBottomDirect(true)
setTimeout(() => {
refreshing.value = false
isInitializing.value = false
useDisplay.scrollToBottom()
}, 600)
}, 1500)
} else {
useDisplay.initHistoryList(adaptedList)
}
} catch (error) {
console.error('获取历史失败:', error)
ElMessage({
message: '获取历史失败',
type: 'warning'
})
}
}
const getList = async () => {
if (isLoadingMore.value) return
isLoadingMore.value = true
try {
await fetchHistory(true)
} finally {
isLoadingMore.value = false
}
}
const handleScroll = (event) => {
if (isInitializing.value) return
const { scrollTop, scrollHeight, clientHeight } = event.target
const distanceToBottom = scrollHeight - scrollTop - clientHeight
if (distanceToBottom <= 50) {
useDisplay.Sender_variant = 'updown'
} else if (distanceToBottom >= 350) {
useDisplay.Sender_variant = 'default'
}
}
onMounted(() => {
console.log('display 组件已挂载')
if (!props.loading) return
refreshing.value = true
nextTick(() => {
console.log('设置 scrollerRef 到 store')
useDisplay.scrollerRef = scrollerRef.value
fetchHistory()
})
page.value++
})
</script>
<style lang="less" scoped>
.content-area {
width: 100%;
min-width: 750px;
height: 100%;
overflow-y: auto;
transition: all 0.3s ease;
}
.back{
display: flex;
align-items: center;
gap: 6px;
height: 36px;
padding: 10px;
position: absolute;
left: 30px;
top: 22px;
z-index: 3;
cursor: pointer;
border-radius: 10px;
// background-color: #FAFBFC;
}
.back:hover{
background-color: #e4e7ed;
}
.btn-container{
display: flex;
align-items: center;
gap: 6px;
height: auto;
padding: 4px;
right: 30px;
top: 22px;
z-index: 3;
border-radius: 10px;
background-color: #FAFBFC;
position: absolute;
.btn{
display: flex;
padding: 5px;
align-items: center;
gap: 5px;
}
.btn-text{
color: #000;
font-family: "Microsoft YaHei";
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px;
text-align: center;
}
.line{
width: 1px;
height: 8px;
background-color: #ccc;
}
}
.scroller {
height: 100%;
padding: 30px 0px 350px 0px;
overflow-y: auto;
will-change: scroll-position;
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
&::-webkit-scrollbar-track {
background: transparent;
}
:deep(.option-item) {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0 20px;
cursor: pointer;
transition: all 0.3s ease;
}
:deep(.option-item:hover) {
background-color: #f5f7fa;
}
:deep(.option-item.selected) {
color: #000F33;
font-weight: 500;
}
:deep(.option-text) {
flex: 1;
text-align: left;
}
:deep(.option-check) {
margin-left: 10px;
font-weight: bold;
}
}
.item-wrapper {
width: 100%;
}
</style>

View File

@ -1,15 +1,15 @@
<template>
<div id="display" class="content-area">
<RefreshOverlay :visible="refreshing" />
<Canvas v-model:visible="canvasVisible" :image="canvasImage" :reference-images="canvasReferenceImages" :source="canvasSource" @send="handleCanvasSend" />
<div class="back">
<div class="back" @click="handleExit">
<img src="@/assets/display/back.svg" alt="">
<span class="title-text">退出</span>
</div>
<div v-if="props.if" class="btn-container">
<div class="btn">
<!-- <span class="btn-text">全部</span> -->
<img src="@/assets/display/search.svg" alt="">
</div>
<span class="line"></span>
@ -46,15 +46,15 @@
ref="scrollerRef"
v-if="props.if"
:data="list"
:item-height="200"
:estimated-height="350"
:render-mode="'bottom'"
:buffer="15"
:item-key="'id'"
:estimated-height="300"
:render-mode="'top'"
:buffer="2"
class="scroller"
@scroll="handleScroll"
>
<template #default="{ item, index }">
<Set :key="`${item.id}`" :item="item" />
<Set :key="item.id" :item="item" @open-canvas="openCanvas" />
</template>
</VirtualScroller>
</div>
@ -67,8 +67,10 @@ import Set from './components/set.vue'
import RefreshOverlay from './components/RefreshOverlay.vue'
import Select from '@/components/Select/index.vue'
import { VirtualScroller } from '@/components/virtual-scroller'
import Canvas from '@/components/canvas/index.vue'
import { getGenerateHistoryList } from '@/apis/display'
import { useRouter } from 'vue-router'
import { getChargeType } from '@/utils/websocket'
const props = defineProps({
if: {
@ -78,6 +80,10 @@ const props = defineProps({
loading: {
type: Boolean,
default: false
},
type: {
type: String,
default: 'painting'
}
})
@ -87,10 +93,16 @@ const userStore = useUserStore()
const router = useRouter()
const refreshing = ref(false)
const scrollerRef = ref(null)
const isLoadingMore = ref(false)
const isLoadingMoreLocked = ref(false)
const activeTab = ref('all')
const isInitializing = ref(true)
let total = 0
const canvasVisible = ref(false)
const canvasImage = ref('')
const canvasReferenceImages = ref([])
const canvasSource = ref('')
const chargeType = computed(() => getChargeType(props.type))
console.log(chargeType.value)
const timeOptions = [
{ label: '全部', value: 'all' },
@ -106,17 +118,8 @@ const favoriteOptions = [
const selectedTime = ref('all')
const selectedFavorite = ref('all')
const { tempList } = storeToRefs(useDisplay)
const { tempList, currentPage, hasMoreData, isLoading } = storeToRefs(useDisplay)
// const tempList = ref([
// { id: 0, type: 'image', status: 'none', name: '', time: '2025-12-01 18:26', files: [] },
// { id: 1, type: 'image', status: 'success', name: '', time: '2025-12-01 18:26', parentName: '2', parentTime: '2025-12-01 12:26', files: ['https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png'] },
// { id: 2, type: 'image', status: 'success', name: '', time: '2025-12-01 18:26', parentName: '2', parentTime: '2025-12-01 12:26', files: ['https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png'] },
// { id: 3, type: 'image', status: 'success', name: '', time: '2025-12-01 18:26', parentName: '2', parentTime: '2025-12-01 12:26', files: ['https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png'] },
// { id: 4, type: 'image', status: 'success', name: '', time: '2025-12-01 18:26', parentName: '2', parentTime: '2025-12-01 12:26', files: ['https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png'] }
// ])
const activeFilter = ref('all')
const list = computed(() => {
const data = tempList.value || []
@ -126,15 +129,13 @@ const list = computed(() => {
return data.filter((item) => item.type === activeFilter.value)
})
const page = ref(1)
//
const toggleDisplay = (newValue, oldValue) => {
activeFilter.value = newValue
}
//
const conversion = (newlist) => {
const temp = newlist.data.records.map((item) => {
const temp = newlist.list.map((item) => {
const files = item.fileUrl ? item.fileUrl.split(',').filter(url => url.trim()) : []
return {
id: item.taskId,
collection: item.collection,
@ -142,123 +143,160 @@ const conversion = (newlist) => {
prompt: item.prompt,
params: item.params,
time: item.createTime,
files: [item.fileUrl]
files: files
}
})
return temp
}
//
const fetchHistory= async (isScrollTopLoad = false) => {
const adaptDataList = (dataList) => {
const convertedList = conversion({ list: dataList })
return convertedList.map((item, index) => {
const originalItem = dataList[index]
return {
...item,
text: originalItem?.title || item.prompt || '生成图片',
name: originalItem?.title || item.prompt || '生成图片',
type: 'image',
title: originalItem?.title || '生成图片'
}
})
}
const fetchHistory = async (isLoadMore = false) => {
if (isLoading.value || (!isLoadMore && !hasMoreData.value)) return
isLoading.value = true
try {
if (isScrollTopLoad) {
const pageToFetch = isLoadMore ? currentPage.value + 1 : 1
const result = await getGenerateHistoryList({
userId: userStore.userInfo.id,
chargeType: chargeType.value,
page: pageToFetch,
size: 10,
sort: 'createTime,desc'
})
const dataList = result.data?.list || result.data || []
if (dataList.length === 0) {
hasMoreData.value = false
if (!isLoadMore) {
refreshing.value = false
isInitializing.value = false
useDisplay.Sender_variant = 'updown'
router.push({ name: 'generate' })
}
return
}
const result = await getGenerateHistoryList({ userId: userStore.userInfo.id, chargeType: 1 })
total = result.data ? result.data.length : 0
if (total === 0) {
useDisplay.Sender_variant = 'updown'
router.push({ name: 'home' })
}
const wrappedData = {
data: {
records: result.data
}
}
const convertedList = conversion(wrappedData)
const adaptedList = convertedList.map((item, index) => {
const originalItem = result.data[index]
return {
...item,
text: originalItem?.title || item.prompt || '生成图片',
name: originalItem?.title || item.prompt || '生成图片',
type: 'image',
title: originalItem?.title || '生成图片'
}
})
if (!isScrollTopLoad && adaptedList.length > 0) {
const adaptedList = adaptDataList(dataList)
if (isLoadMore) {
useDisplay.appendHistoryList(adaptedList)
currentPage.value = pageToFetch
} else {
useDisplay.initHistoryList(adaptedList)
currentPage.value = 1
await nextTick()
const scrollToBottomDirect = () => {
if (scrollerRef.value && typeof scrollerRef.value.scrollToBottom === 'function') {
scrollerRef.value.scrollToBottom()
}
}
for (let i = 0; i < 5; i++) {
setTimeout(() => {
scrollToBottomDirect()
if (scrollerRef.value && typeof scrollerRef.value.scrollToBottom === 'function') {
scrollerRef.value.scrollToBottom()
}
}, 100 * i)
}
setTimeout(() => {
scrollToBottomDirect()
if (scrollerRef.value && typeof scrollerRef.value.scrollToBottom === 'function') {
scrollerRef.value.scrollToBottom()
}
setTimeout(() => {
refreshing.value = false
isInitializing.value = false
useDisplay.scrollToBottom()
}, 300)
}, 600)
} else {
useDisplay.initHistoryList(adaptedList)
}
hasMoreData.value = dataList.length === 10
} catch (error) {
console.error('获取历史失败:', error)
ElMessage({
message: '获取历史失败',
type: 'warning'
})
}
}
//
const getList = async () => {
if (isLoadingMore.value) return
isLoadingMore.value = true
try {
await fetchHistory(true)
} finally {
isLoadingMore.value = false
isLoading.value = false
}
}
//
const handleScroll = (event) => {
if (isInitializing.value) return
const { scrollTop, scrollHeight, clientHeight } = event.target
const distanceToBottom = scrollHeight - scrollTop - clientHeight
const { distanceToPageTop, distanceToPageBottom, isAtPageTop, isAtPageBottom } = event
//
// if (scrollTop <= 50 && !isLoadingMore.value) {
// getList()
// }
if (isAtPageTop && !isLoading.value && !isLoadingMoreLocked.value && hasMoreData.value) {
isLoadingMoreLocked.value = true
fetchHistory(true)
setTimeout(() => {
isLoadingMoreLocked.value = false
}, 3000)
}
if (distanceToBottom <= 50) {
if (isAtPageBottom) {
useDisplay.Sender_variant = 'updown'
} else if (distanceToBottom >= 350) {
} else if (distanceToPageTop >= 350) {
useDisplay.Sender_variant = 'default'
}
}
const openCanvas = (data) => {
if (typeof data === 'string') {
canvasImage.value = data
canvasReferenceImages.value = []
canvasSource.value = ''
} else {
canvasImage.value = data.mainImage?.url || data.mainImage || ''
canvasReferenceImages.value = data.referenceImages || []
canvasSource.value = data.source || ''
}
canvasVisible.value = true
}
const handleCanvasSend = (data) => {
console.log('Canvas send:', data)
}
const handleExit = () => {
if (window.parent !== window) {
window.parent.postMessage({
action: 'navigateBack',
source: 'display-exit'
}, 'https://sxwz.xueai.art')
} else {
router.go(-1)
}
}
onMounted(() => {
console.log('display 组件已挂载')
if (!props.loading) return
refreshing.value = true
nextTick(() => {
console.log('设置 scrollerRef 到 store')
useDisplay.scrollerRef = scrollerRef.value
useDisplay.resetPagination()
fetchHistory()
})
page.value++
})
onBeforeUnmount(() => {
useDisplay.resetPagination()
})
</script>
@ -282,7 +320,6 @@ onMounted(() => {
z-index: 3;
cursor: pointer;
border-radius: 10px;
// background-color: #FAFBFC;
}
.back:hover{
@ -327,44 +364,4 @@ onMounted(() => {
}
}
.scroller {
height: 100%;
padding: 30px 0px 350px 0px;
will-change: scroll-position;
-webkit-overflow-scrolling: touch; /* iOS Safari */
scroll-behavior: smooth; /* 平滑滚动 */
&::-webkit-scrollbar-track {
background: transparent; /* 轨道透明 */
}
:deep(.option-item) {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0 20px;
cursor: pointer;
transition: all 0.3s ease;
}
:deep(.option-item:hover) {
background-color: #f5f7fa;
}
:deep(.option-item.selected) {
color: #000F33;
font-weight: 500;
}
:deep(.option-text) {
flex: 1;
text-align: left;
}
:deep(.option-check) {
margin-left: 10px;
font-weight: bold;
}
}
</style>
</style>

View File

@ -1,18 +1,43 @@
<script setup>
import { useRoute } from 'vue-router'
import display from './display/index.vue'
import Canvas from '@/components/canvas/index.vue'
const route = useRoute()
const shouldShowDisplay = route.path === '/home'
const loading = route.query.loading ? false : (route.path === '/home')
const Generate = route.query.Generate || false
const type = route.query.type || 'painting'
const shouldShowDisplay = computed(() => route.path === '/home')
const loading = computed(() => route.query.loading ? false : (route.path === '/home'))
const Generate = computed(() => route.query.Generate || false)
const type = computed(() => route.query.type || 'painting')
console.log(type.value)
const canvasVisible = ref(false)
const canvasImage = ref('')
const canvasReferenceImages = ref([])
const canvasSource = ref('')
const handleOpenCanvas = (data) => {
canvasImage.value = data.mainImage?.url || data.mainImage || ''
canvasReferenceImages.value = data.referenceImages || []
canvasSource.value = data.source || 'uploader'
canvasVisible.value = true
}
const handleCanvasSend = (data) => {
console.log('Canvas send:', data)
}
</script>
<template>
<div class="app-container">
<dialogBox :is-generate="shouldShowDisplay" :type="type" :generate="Generate" />
<Canvas
v-model:visible="canvasVisible"
:image="canvasImage"
:reference-images="canvasReferenceImages"
:source="canvasSource"
@send="handleCanvasSend"
/>
<dialogBox :is-generate="shouldShowDisplay" :type="type" :generate="Generate" :loading="loading" @open-canvas="handleOpenCanvas" />
<display :if="shouldShowDisplay" :type="type" :loading="loading" />
</div>

10
test/FlashHead.json Normal file
View File

@ -0,0 +1,10 @@
{
"nodeInfoList": {
"value_1":{ "nodeId":"20", "fieldName":"value", "fieldValue":"512" },
"value_2":{ "nodeId":"21", "fieldName":"value", "fieldValue":"512" },
"model_type":{ "nodeId":"18", "fieldName":"model_type", "fieldValue":"pro" },
"audio":{ "nodeId":"16", "fieldName":"audio", "fieldValue":"dce37fba29e596ddcd927c4660b4fb47bd3ecdbef4ad242fed72bd860e032b1f.flac" },
"image":{ "nodeId":"17", "fieldName":"image", "fieldValue":"d3ee810ce387739e7a99cb3ba87a104e34b0a955149b8525a600867edbab1138.png" }
},
"workflowId": "2036266399357739009"
}

11
test/LTX2.3-T2V.json Normal file
View File

@ -0,0 +1,11 @@
{
"nodeInfoList": {
"text":{ "nodeId":"40", "fieldName":"text", "fieldValue":"深夜,一个美丽的中年中国女人在一边弹吉他一边歌唱,环绕镜头,半身特写,逆光,月光,海风吹拂。场景是海边。" },
"audio":{ "nodeId":"39", "fieldName":"audio", "fieldValue":"62e5c0b15854bcac34e9aa0bf9f449767bda9f66047a60abeddbcd47c712ee8d.mp3" },
"start_index":{ "nodeId":"58", "fieldName":"start_index", "fieldValue":0 },
"duration":{ "nodeId":"58", "fieldName":"duration", "fieldValue":25 },
"value_1":{ "nodeId":"55", "fieldName":"value", "fieldValue":1280 },
"value_2":{ "nodeId":"56", "fieldName":"value", "fieldValue":720 }
},
"workflowId": "2036343285949665282"
}

10
test/Vidu Q3-I2V.json Normal file
View File

@ -0,0 +1,10 @@
{
"nodeInfoList": {
"image":{ "nodeId":"2", "fieldName":"image", "fieldValue":"67bbf03a4ce453557b8c9acf85bd83d3519d3374ef35c54da1084d03f9ac111f.png" },
"prompt":{ "nodeId":"3", "fieldName":"prompt", "fieldValue":"一个小女孩在树下吃苹果" },
"resolution":{ "nodeId":"3", "fieldName":"resolution", "fieldValue":"540p" },
"duration":{ "nodeId":"3", "fieldName":"duration", "fieldValue":5 },
"audio":{ "nodeId":"3", "fieldName":"audio", "fieldValue":true }
},
"workflowId": "2036354451904139265"
}

11
test/Vidu Q3-T2V.json Normal file
View File

@ -0,0 +1,11 @@
{
"nodeInfoList": {
"style":{ "nodeId":"2", "fieldName":"style", "fieldValue":"general" },
"prompt":{ "nodeId":"2", "fieldName":"prompt", "fieldValue":"一个小女孩在树下吃苹果" },
"resolution":{ "nodeId":"2", "fieldName":"resolution", "fieldValue":"540p" },
"aspect_ratio":{ "nodeId":"2", "fieldName":"aspect_ratio", "fieldValue":"4:3" },
"duration":{ "nodeId":"2", "fieldName":"duration", "fieldValue":5 },
"audio":{ "nodeId":"2", "fieldName":"audio", "fieldValue":true }
},
"workflowId": "2036349280088231938"
}