优化逻辑
This commit is contained in:
parent
ddb1c367b5
commit
70529ccd47
|
|
@ -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
371
out.txt
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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。
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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" }
|
||||
]
|
||||
}
|
||||
|
|
@ -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" }
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 || '生成失败' }
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
Loading…
Reference in New Issue