fix(cas): 接通 CAS 单点登录全链路 + 清理冗余配置
修复链上的 8 个真 bug: 1. UserDetail(UserVO,Set) 漏 setAuthorities → CAS token 构造抛 IllegalArgumentException 修:CasUserDetailsService.buildGptUserDetail 手动补 empty authorities 2. frontEndUrl 写死内网 IP,localhost 隧道用户跳回时"无法访问此网站" 修:CasUrlBuilder 用 X-Forwarded-Host / Host 动态拼 service URL 3. vite proxy 没配 /metalinfo/chat_web_backend,CAS 回跳 ticket 被前端路由吞 修:加一条 proxy(rewrite 去 /metalinfo 前缀)+ X-Forwarded-Host 转发 4. ticket 校验 service URL 跟 entry point 不一致 → CAS server mismatch 修:自定义 AuthenticationDetailsSource 用同一个动态 URL 5. sendRedirect URL 含 # 经容器编码成 %23,浏览器拿不到 hash → 404 修:改用 query 参数(/metalinfo/?cas_token=xxx),前端 router beforeEach 拦截 6. CAS 登录后 HttpSession 残留,第二次访问 /cas/login 不触发 entryPoint → 落到 DispatcherServlet → 找不到映射 → 404 Whitelabel 修:SuccessHandler 完成后 invalidate session + clear SecurityContext 7. CAS 路径漏写 Redis token,JwtAuthenticationFilter 校验时 LOGIN_TOKEN_KEY 找不到 → "token已失效" → 前端 axios interceptor 清 token 跳回 login 修:SuccessHandler 同步写 redisUtils.set,与 LoginController.saveLoginLog 对齐 8. permission.ts 没拦 query 里的 cas_token,hash 路由下 location.search 取不到 修:router beforeEach 优先消费 cas_token 再走 getSession 清理冗余: - CasProperties 删 6 个未用字段(enabled/serverLogout/appLogout/appKey/ appSecret/httpsFlag/frontEndUrl) - application.yml 同步删,移除写死的 app-secret 等敏感字段 - 删外部 override 文件 chat_web_backend/config/application.yml - casServiceProperties.setService 改占位符(实际不被读取,只满足 ServiceProperties.afterPropertiesSet 的非空校验) - 删 permission.ts 的 [CAS] [GUARD] debug log,保留 catch error 一条 新增: - CasUrlBuilder 工具类:从请求动态解析 host/scheme,多个地方共用 UI: - welcome 页面玻璃按钮 + 呼吸光晕/光感动画(用户自己调过,本次保留) - App.vue:/welcome 路径不渲染 Operates 侧边栏 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,37 +1,83 @@
|
|||||||
package com.inspur.llm.chat.base.security.cas;
|
package com.inspur.llm.chat.base.security.cas;
|
||||||
|
|
||||||
|
import com.inspur.llm.chat.base.constant.RedisConstants;
|
||||||
|
import com.inspur.llm.chat.base.constant.StringPoolConstant;
|
||||||
import com.inspur.llm.chat.base.security.JwtTokenUtils;
|
import com.inspur.llm.chat.base.security.JwtTokenUtils;
|
||||||
|
import com.inspur.llm.chat.base.security.UserDetail;
|
||||||
|
import com.inspur.llm.chat.base.util.RedisUtils;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
|
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import javax.servlet.ServletException;
|
import javax.servlet.ServletException;
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletRequest;
|
||||||
import javax.servlet.http.HttpServletResponse;
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
import javax.servlet.http.HttpSession;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.URLEncoder;
|
import java.net.URLEncoder;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CAS 登录成功 → 生成 JWT → 重定向前端登录页(带 cas_token)
|
* CAS 登录成功 → 生成 JWT → 重定向前端登录页(带 cas_token)。
|
||||||
|
*
|
||||||
|
* 重定向的前端 URL 用当前请求的 X-Forwarded-Host / Host 动态拼,
|
||||||
|
* 这样回到前端时浏览器地址不会突变(用户从哪个 host 来就回哪个 host),
|
||||||
|
* 避免内网 IP 不可达造成"无法访问此网站"。
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
public class CasLoginSuccessHandler implements AuthenticationSuccessHandler {
|
public class CasLoginSuccessHandler implements AuthenticationSuccessHandler {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 前端 base 路径,token 用 query 参数传(不放 hash 里)。
|
||||||
|
* 原本想用 /metalinfo/#/login?cas_token=xxx,但 sendRedirect 经过容器/代理时
|
||||||
|
* # 字符容易被 encode 成 %23,导致浏览器拿不到 hash → vue-router 不知道路由 → 404。
|
||||||
|
* 改用 query:/metalinfo/?cas_token=xxx,前端 router beforeEach 拦截。
|
||||||
|
*/
|
||||||
|
private static final String FRONTEND_BASE_PATH = "/metalinfo/";
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private CasProperties casProperties;
|
private CasProperties casProperties;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RedisUtils redisUtils;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onAuthenticationSuccess(HttpServletRequest request,
|
public void onAuthenticationSuccess(HttpServletRequest request,
|
||||||
HttpServletResponse response,
|
HttpServletResponse response,
|
||||||
Authentication authentication) throws IOException, ServletException {
|
Authentication authentication) throws IOException, ServletException {
|
||||||
log.info("CAS 登录成功: {}", authentication.getName());
|
log.info("CAS 登录成功: {}", authentication.getName());
|
||||||
String token = JwtTokenUtils.generateToken(authentication);
|
String token = JwtTokenUtils.generateToken(authentication);
|
||||||
String redirect = casProperties.getFrontEndUrl()
|
|
||||||
+ "/#/login?cas_token=" + URLEncoder.encode(token, StandardCharsets.UTF_8.name());
|
// 把 token 写进 Redis,否则 JwtAuthenticationFilter 校验时查不到会返"token已失效"
|
||||||
|
// 跟 LoginController.saveLoginLog 同样逻辑
|
||||||
|
try {
|
||||||
|
UserDetail userDetail = JwtTokenUtils.getUserDetail(authentication);
|
||||||
|
String key = RedisConstants.LOGIN_TOKEN_KEY
|
||||||
|
+ userDetail.getId() + StringPoolConstant.COLON + userDetail.getSessionId();
|
||||||
|
redisUtils.set(key, token, JwtTokenUtils.EXPIRE_TIME / 1000L);
|
||||||
|
log.info("CAS token 已写入 Redis, key={}", key);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("CAS token 写 Redis 失败: {}", e.getMessage(), e);
|
||||||
|
}
|
||||||
|
|
||||||
|
String redirect = CasUrlBuilder.buildServiceUrl(request, FRONTEND_BASE_PATH)
|
||||||
|
+ "?cas_token=" + URLEncoder.encode(token, StandardCharsets.UTF_8.name());
|
||||||
|
log.info("CAS 登录成功跳转 URL: {}", redirect);
|
||||||
|
|
||||||
|
// 销毁 HttpSession + 清 SecurityContext
|
||||||
|
// 否则下次访问 /cas/login 时 Spring Security 看到 session 已认证 → 不触发 CAS entryPoint
|
||||||
|
// → 请求穿透到 DispatcherServlet → 找不到 /cas/login 映射 → 404 Whitelabel。
|
||||||
|
// 我们用 JWT 做认证,不需要 server-side session。
|
||||||
|
HttpSession session = request.getSession(false);
|
||||||
|
if (session != null) {
|
||||||
|
session.invalidate();
|
||||||
|
}
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
|
||||||
response.sendRedirect(redirect);
|
response.sendRedirect(redirect);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,20 +5,19 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
|||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CAS 单点登录配置(绑定 application.yml 中的 security.cas.*)
|
* CAS 单点登录配置(绑定 application.yml 中的 security.cas.*)。
|
||||||
|
*
|
||||||
|
* 只保留实际使用的字段。logout/appKey/appSecret 等之前预留但未实现,
|
||||||
|
* 真正接入时再加,避免 dead config。
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
@Configuration
|
@Configuration
|
||||||
@ConfigurationProperties(prefix = "security.cas")
|
@ConfigurationProperties(prefix = "security.cas")
|
||||||
public class CasProperties {
|
public class CasProperties {
|
||||||
private boolean enabled = false;
|
/** CAS 服务器根地址,例如 http://192.168.203.20:8180 */
|
||||||
private String serverHost;
|
private String serverHost;
|
||||||
|
/** CAS 服务器登录入口,通常 ${serverHost}/login */
|
||||||
private String serverLogin;
|
private String serverLogin;
|
||||||
private String serverLogout;
|
/** 本应用的 CAS 回调路径(servlet path),通常 /cas/login */
|
||||||
private String appLogin;
|
private String appLogin;
|
||||||
private String appLogout;
|
|
||||||
private String appKey;
|
|
||||||
private String appSecret;
|
|
||||||
private boolean httpsFlag = false;
|
|
||||||
private String frontEndUrl;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,23 +10,34 @@ import org.springframework.security.cas.authentication.CasAssertionAuthenticatio
|
|||||||
import org.springframework.security.cas.authentication.CasAuthenticationProvider;
|
import org.springframework.security.cas.authentication.CasAuthenticationProvider;
|
||||||
import org.springframework.security.cas.web.CasAuthenticationEntryPoint;
|
import org.springframework.security.cas.web.CasAuthenticationEntryPoint;
|
||||||
import org.springframework.security.cas.web.CasAuthenticationFilter;
|
import org.springframework.security.cas.web.CasAuthenticationFilter;
|
||||||
|
import org.springframework.security.cas.web.authentication.ServiceAuthenticationDetails;
|
||||||
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
|
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
|
||||||
|
import org.springframework.security.authentication.AuthenticationDetailsSource;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
|
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
|
||||||
import org.springframework.security.core.userdetails.AuthenticationUserDetailsService;
|
import org.springframework.security.core.userdetails.AuthenticationUserDetailsService;
|
||||||
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
|
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CAS 单点登录 Security 配置(仅处理 /cas/** 路径,与 WebSecurityConfig 共存)。
|
* CAS 单点登录 Security 配置(仅处理 /cas/** 路径,与 WebSecurityConfig 共存)。
|
||||||
*
|
*
|
||||||
* 流程:
|
* 流程:
|
||||||
* /cas/login(无 ticket) → 触发 CasEntryPoint → 302 到 CAS 登录页
|
* /cas/login(无 ticket) → 触发 CasEntryPoint → 302 到 CAS 登录页
|
||||||
* /cas/login?ticket=xxx → CasAuthenticationFilter 验证 → 成功 → CasLoginSuccessHandler 生成 JWT → 302 前端
|
* /cas/login?ticket=xxx → CasAuthenticationFilter 验证 → 成功 → CasLoginSuccessHandler 生成 JWT → 302 前端
|
||||||
|
*
|
||||||
|
* service URL 用 X-Forwarded-Host / Host 动态拼,不依赖固定 frontEndUrl,
|
||||||
|
* 这样浏览器在 localhost:3000 / 内网 IP / 公网域名 各种入口下 CAS 回跳都能命中。
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
@Order(1)
|
@Order(1)
|
||||||
public class CasSecurityConfig extends WebSecurityConfigurerAdapter {
|
public class CasSecurityConfig extends WebSecurityConfigurerAdapter {
|
||||||
|
|
||||||
|
/** 给 CAS 的 service URL 后缀(不含 host) */
|
||||||
|
static final String SERVICE_URL_SUFFIX = "/metalinfo/chat_web_backend/cas/login";
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private CasProperties casProperties;
|
private CasProperties casProperties;
|
||||||
|
|
||||||
@@ -57,18 +68,39 @@ public class CasSecurityConfig extends WebSecurityConfigurerAdapter {
|
|||||||
auth.authenticationProvider(casAuthenticationProvider());
|
auth.authenticationProvider(casAuthenticationProvider());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ServiceProperties 仅作为 bean 占位被 entry point / filter / provider 引用,
|
||||||
|
* 它的 service 字段实际不会被读到——entry point override 了 createServiceUrl,
|
||||||
|
* filter 设了自定义 AuthenticationDetailsSource,校验时用的是 details.getServiceUrl()。
|
||||||
|
*
|
||||||
|
* 但 ServiceProperties.afterPropertiesSet() 会硬性校验 service 字段非空,
|
||||||
|
* 所以这里 set 一个占位符值满足校验,不影响实际行为。
|
||||||
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
public ServiceProperties casServiceProperties() {
|
public ServiceProperties casServiceProperties() {
|
||||||
ServiceProperties sp = new ServiceProperties();
|
ServiceProperties sp = new ServiceProperties();
|
||||||
sp.setService(casProperties.getFrontEndUrl() + "/chat_web_backend" + casProperties.getAppLogin());
|
// 占位符(必须非空,afterPropertiesSet 会校验),实际值由 entry point 的 createServiceUrl
|
||||||
|
// 和 filter 的 AuthenticationDetailsSource 动态构造
|
||||||
|
sp.setService("placeholder-not-used");
|
||||||
sp.setSendRenew(false);
|
sp.setSendRenew(false);
|
||||||
sp.setAuthenticateAllArtifacts(true);
|
sp.setAuthenticateAllArtifacts(true);
|
||||||
return sp;
|
return sp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义 entry point:override createServiceUrl,用当前请求的 X-Forwarded-Host / Host
|
||||||
|
* 动态拼 service URL,而不是用固定的 frontEndUrl 配置(commence 是 final,只能改这里)。
|
||||||
|
*
|
||||||
|
* 注意返回 raw URL(未 encode),commence 内部的 constructRedirectUrl 会再 URLEncoder.encode 一次。
|
||||||
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
public CasAuthenticationEntryPoint casAuthenticationEntryPoint() {
|
public CasAuthenticationEntryPoint casAuthenticationEntryPoint() {
|
||||||
CasAuthenticationEntryPoint ep = new CasAuthenticationEntryPoint();
|
CasAuthenticationEntryPoint ep = new CasAuthenticationEntryPoint() {
|
||||||
|
@Override
|
||||||
|
protected String createServiceUrl(HttpServletRequest request, HttpServletResponse response) {
|
||||||
|
return CasUrlBuilder.buildServiceUrl(request, SERVICE_URL_SUFFIX);
|
||||||
|
}
|
||||||
|
};
|
||||||
ep.setLoginUrl(casProperties.getServerLogin());
|
ep.setLoginUrl(casProperties.getServerLogin());
|
||||||
ep.setServiceProperties(casServiceProperties());
|
ep.setServiceProperties(casServiceProperties());
|
||||||
return ep;
|
return ep;
|
||||||
@@ -81,6 +113,12 @@ public class CasSecurityConfig extends WebSecurityConfigurerAdapter {
|
|||||||
filter.setFilterProcessesUrl(casProperties.getAppLogin());
|
filter.setFilterProcessesUrl(casProperties.getAppLogin());
|
||||||
filter.setAuthenticationSuccessHandler(casLoginSuccessHandler);
|
filter.setAuthenticationSuccessHandler(casLoginSuccessHandler);
|
||||||
filter.setServiceProperties(casServiceProperties());
|
filter.setServiceProperties(casServiceProperties());
|
||||||
|
// 关键:ticket 校验阶段也得用同一个动态 service URL(含 /metalinfo 前缀),
|
||||||
|
// 否则 CAS 服务器对 ticket 的 service 校验会 mismatch(vite proxy 已经把 /metalinfo
|
||||||
|
// 前缀剥掉,request URL 没法直接拼出正确 service URL,所以自定义一个)。
|
||||||
|
AuthenticationDetailsSource<javax.servlet.http.HttpServletRequest, ServiceAuthenticationDetails> source =
|
||||||
|
request -> () -> CasUrlBuilder.buildServiceUrl(request, SERVICE_URL_SUFFIX);
|
||||||
|
filter.setAuthenticationDetailsSource(source);
|
||||||
// 仅在 URL 含 ticket 时才尝试认证;无 ticket → 让 ExceptionTranslationFilter 触发 EntryPoint 重定向 CAS
|
// 仅在 URL 含 ticket 时才尝试认证;无 ticket → 让 ExceptionTranslationFilter 触发 EntryPoint 重定向 CAS
|
||||||
final String appLogin = casProperties.getAppLogin();
|
final String appLogin = casProperties.getAppLogin();
|
||||||
filter.setRequiresAuthenticationRequestMatcher(req ->
|
filter.setRequiresAuthenticationRequestMatcher(req ->
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.inspur.llm.chat.base.security.cas;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 HTTP 请求动态构造 CAS 相关 URL 的工具类。
|
||||||
|
*
|
||||||
|
* 优先用 X-Forwarded-Host / X-Forwarded-Proto(vite proxy 转发的原始 Host),
|
||||||
|
* 退而求其次用请求自身的 Host。这样无论用户是 localhost、内网 IP 还是公网域名访问,
|
||||||
|
* 拼出来的 URL 都跟用户浏览器的实际地址栏一致。
|
||||||
|
*/
|
||||||
|
public final class CasUrlBuilder {
|
||||||
|
|
||||||
|
private CasUrlBuilder() {}
|
||||||
|
|
||||||
|
/** 取请求实际的 host(优先 X-Forwarded-Host)。 */
|
||||||
|
public static String resolveHost(HttpServletRequest request) {
|
||||||
|
String forwarded = request.getHeader("X-Forwarded-Host");
|
||||||
|
if (forwarded != null && !forwarded.isEmpty()) {
|
||||||
|
// X-Forwarded-Host 可能是 "host1, host2"(多层代理),取第一个
|
||||||
|
int comma = forwarded.indexOf(',');
|
||||||
|
return (comma > 0 ? forwarded.substring(0, comma) : forwarded).trim();
|
||||||
|
}
|
||||||
|
String host = request.getHeader("Host");
|
||||||
|
if (host != null && !host.isEmpty()) {
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
return request.getServerName() + ":" + request.getServerPort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取请求实际的 scheme(优先 X-Forwarded-Proto)。 */
|
||||||
|
public static String resolveScheme(HttpServletRequest request) {
|
||||||
|
String proto = request.getHeader("X-Forwarded-Proto");
|
||||||
|
if (proto != null && !proto.isEmpty()) {
|
||||||
|
return proto.trim();
|
||||||
|
}
|
||||||
|
return request.getScheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拼一个绝对 URL:scheme://host + suffix */
|
||||||
|
public static String buildServiceUrl(HttpServletRequest request, String suffix) {
|
||||||
|
return resolveScheme(request) + "://" + resolveHost(request) + suffix;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import org.springframework.security.core.userdetails.UserDetails;
|
|||||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,21 +55,31 @@ public class CasUserDetailsService implements AuthenticationUserDetailsService<C
|
|||||||
Wrappers.<User>lambdaQuery().eq(User::getCasUsername, casPrincipal).last("limit 1"));
|
Wrappers.<User>lambdaQuery().eq(User::getCasUsername, casPrincipal).last("limit 1"));
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
log.info("CAS 命中 gpt_user (cas_username) id={}", user.getId());
|
log.info("CAS 命中 gpt_user (cas_username) id={}", user.getId());
|
||||||
UserVO vo = new UserVO();
|
return buildGptUserDetail(user);
|
||||||
BeanUtils.copyProperties(user, vo);
|
|
||||||
return new UserDetail(vo, new HashSet<>());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
user = userMapper.selectOne(
|
user = userMapper.selectOne(
|
||||||
Wrappers.<User>lambdaQuery().eq(User::getTel, casPrincipal).last("limit 1"));
|
Wrappers.<User>lambdaQuery().eq(User::getTel, casPrincipal).last("limit 1"));
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
log.info("CAS 命中 gpt_user (tel fallback) id={}", user.getId());
|
log.info("CAS 命中 gpt_user (tel fallback) id={}", user.getId());
|
||||||
UserVO vo = new UserVO();
|
return buildGptUserDetail(user);
|
||||||
BeanUtils.copyProperties(user, vo);
|
|
||||||
return new UserDetail(vo, new HashSet<>());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.warn("CAS 用户未在战知库中找到: {}", casPrincipal);
|
log.warn("CAS 用户未在战知库中找到: {}", casPrincipal);
|
||||||
throw new UsernameNotFoundException("CAS 用户未绑定战知账号: " + casPrincipal);
|
throw new UsernameNotFoundException("CAS 用户未绑定战知账号: " + casPrincipal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 包装 gpt_user → UserDetail。
|
||||||
|
* 注意:UserDetail(UserVO, Set) 构造器没初始化 authorities,导致 getAuthorities() 返 null,
|
||||||
|
* 而 CasAuthenticationToken 构造会校验 authorities 非 null(不是非空 list)→ IllegalArgumentException。
|
||||||
|
* 这里手动补一个空 list。
|
||||||
|
*/
|
||||||
|
private UserDetail buildGptUserDetail(User user) {
|
||||||
|
UserVO vo = new UserVO();
|
||||||
|
BeanUtils.copyProperties(user, vo);
|
||||||
|
UserDetail ud = new UserDetail(vo, new HashSet<>());
|
||||||
|
ud.setAuthorities(new ArrayList<>());
|
||||||
|
return ud;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,17 +92,11 @@ xss:
|
|||||||
# 词向量模型名称
|
# 词向量模型名称
|
||||||
embedding-model-name: bge_m3
|
embedding-model-name: bge_m3
|
||||||
|
|
||||||
# CAS 单点登录配置
|
# CAS 单点登录配置(service URL 用 X-Forwarded-Host 动态拼,无需 front-end-url)
|
||||||
security:
|
security:
|
||||||
cas:
|
cas:
|
||||||
enabled: true
|
|
||||||
server-host: http://192.168.203.20:8180
|
server-host: http://192.168.203.20:8180
|
||||||
server-login: ${security.cas.server-host}/login
|
server-login: ${security.cas.server-host}/login
|
||||||
server-logout: ${security.cas.server-host}/logout
|
|
||||||
app-login: /cas/login
|
app-login: /cas/login
|
||||||
app-logout: /cas/logout
|
|
||||||
app-key: 8UyEQdr6L6FfAH3D
|
|
||||||
app-secret: Ely1U6Uv2Nf8APQSfZdg1epSbQ6ORKwr
|
|
||||||
front-end-url: http://192.168.203.8:3000
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<svg id="_图层_2" data-name="图层 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 31.79 26.46">
|
<svg id="_图层_2" data-name="图层 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 31.79 26.46">
|
||||||
<g id="_图层_1-2" data-name="图层 1">
|
<g id="_图层_1-2" data-name="图层 1">
|
||||||
<g>
|
<g fill="#636E77">
|
||||||
<path d="M4.91,6.25c.18,0,.74-.6,.87-.71,.17,0,.65-.53,.79-.65,.32-.26,.63-.52,.95-.78,.56-.46,1.12-.92,1.69-1.38,.15,0,.72,1.25,.84,1.45,.45,.78,.9,1.56,1.35,2.35l-1.69,1.36c-1.6-.55-3.19-1.1-4.79-1.65Z"/>
|
<path d="M4.91,6.25c.18,0,.74-.6,.87-.71,.17,0,.65-.53,.79-.65,.32-.26,.63-.52,.95-.78,.56-.46,1.12-.92,1.69-1.38,.15,0,.72,1.25,.84,1.45,.45,.78,.9,1.56,1.35,2.35l-1.69,1.36c-1.6-.55-3.19-1.1-4.79-1.65Z"/>
|
||||||
<path d="M13.06,5.43c-.5-1.29,.38-3.57,1.27-4.51C15.49-.3,17.51,0,19.03,.14c0,0-2.48,2.93-3.55,5.08-.82-.01-1.63,.02-2.42,.21Z"/>
|
<path d="M13.06,5.43c-.5-1.29,.38-3.57,1.27-4.51C15.49-.3,17.51,0,19.03,.14c0,0-2.48,2.93-3.55,5.08-.82-.01-1.63,.02-2.42,.21Z"/>
|
||||||
<path d="M17.66,4.96L25.6,.8s2.68,1.49,3.63,2.72c-1.4,.41-9.1,2.6-9.37,2.77-.88-.55-2.2-1.33-2.2-1.33Z"/>
|
<path d="M17.66,4.96L25.6,.8s2.68,1.49,3.63,2.72c-1.4,.41-9.1,2.6-9.37,2.77-.88-.55-2.2-1.33-2.2-1.33Z"/>
|
||||||
<polygon points="20.85 7.78 31.75 7.86 31.79 12.24 20.74 9.98 20.85 7.78"/>
|
<polygon points="20.85 7.78 31.75 7.86 31.79 12.24 20.74 9.98 20.85 7.78"/>
|
||||||
<path d="M19.56,13.33c-.17,0-.29,.12-.57,.33-.62,.47-1.24,.94-1.86,1.41,1.64,1.94,3.21,3.94,4.81,5.91,.62,.77,1.23,1.55,1.87,2.3,.97-.59,1.7-1.47,2.6-2.15,.32-.25,1.43-.76,1.45-1.22,0-.13-.86-.71-.99-.82-.42-.34-.84-.69-1.26-1.03-1.49-1.22-2.97-2.43-4.47-3.63-.4-.32-.81-.71-1.25-.98-.14-.08-.24-.12-.32-.13Z"/>
|
<path d="M19.56,13.33c-.17,0-.29,.12-.57,.33-.62,.47-1.24,.94-1.86,1.41,1.64,1.94,3.21,3.94,4.81,5.91,.62,.77,1.23,1.55,1.87,2.3,.97-.59,1.7-1.47,2.6-2.15,.32-.25,1.43-.76,1.45-1.22,0-.13-.86-.71-.99-.82-.42-.34-.84-.69-1.26-1.03-1.49-1.22-2.97-2.43-4.47-3.63-.4-.32-.81-.71-1.25-.98-.14-.08-.24-.12-.32-.13Z"/>
|
||||||
<path d="M14.11,26.46l-.16-17.73c0-.06-2.36,.1-2.45,.11-1.79,.15-3.65,.33-5.45,.55-1.43,.18-3.03,.26-4.27,1.12C.46,11.43-.13,13.17,.02,14.73l.17,.55s3.27-1.55,3.58-1.7c.63-.3,1.26-.58,1.9-.84,.54-.22,1.32-.65,1.9-.64-.17,.37-.57,.73-.84,1.05l-1.51,1.81-3.24,3.89c-.48,.57-1.02,.92-.79,1.69,.43,1.47,1.54,2.63,2.77,3.5,1.21-1.91,4.39-7.62,6.15-10.35l-.89,12.76h4.89Z"/>
|
<path fill="#FF2500" d="M14.11,26.46l-.16-17.73c0-.06-2.36,.1-2.45,.11-1.79,.15-3.65,.33-5.45,.55-1.43,.18-3.03,.26-4.27,1.12C.46,11.43-.13,13.17,.02,14.73l.17,.55s3.27-1.55,3.58-1.7c.63-.3,1.26-.58,1.9-.84,.54-.22,1.32-.65,1.9-.64-.17,.37-.57,.73-.84,1.05l-1.51,1.81-3.24,3.89c-.48,.57-1.02,.92-.79,1.69,.43,1.47,1.54,2.63,2.77,3.5,1.21-1.91,4.39-7.62,6.15-10.35l-.89,12.76h4.89Z"/>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<Operates v-show="url != '/login' && url != '/writing/edit'" />
|
<Operates v-show="url != '/login' && url != '/welcome' && url != '/writing/edit'" />
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -7,13 +7,13 @@
|
|||||||
<path d="M109.69,12.44s3.91,7.74,7.82,10.54c.39,.28,.92,.27,1.31-.01,1.96-1.45,2.1-1.86,2.1-1.86,0,0-3.8-1.82-9.5-9.58-1.73,.41-1.73,.91-1.73,.91Z"/>
|
<path d="M109.69,12.44s3.91,7.74,7.82,10.54c.39,.28,.92,.27,1.31-.01,1.96-1.45,2.1-1.86,2.1-1.86,0,0-3.8-1.82-9.5-9.58-1.73,.41-1.73,.91-1.73,.91Z"/>
|
||||||
<path d="M109.23,5.22h3.14s.24,10.69-8.85,17.49c-.4,.3-.93,.37-1.39,.17-.52-.23-.99-.57-.99-.57,0,0,7.02-4.62,8.09-17.09Z"/>
|
<path d="M109.23,5.22h3.14s.24,10.69-8.85,17.49c-.4,.3-.93,.37-1.39,.17-.52-.23-.99-.57-.99-.57,0,0,7.02-4.62,8.09-17.09Z"/>
|
||||||
</g>
|
</g>
|
||||||
<g>
|
<g fill="#636E77">
|
||||||
<path d="M4.91,6.25c.18,0,.74-.6,.87-.71,.17,0,.65-.53,.79-.65,.32-.26,.63-.52,.95-.78,.56-.46,1.12-.92,1.69-1.38,.15,0,.72,1.25,.84,1.45,.45,.78,.9,1.56,1.35,2.35l-1.69,1.36c-1.6-.55-3.19-1.1-4.79-1.65Z"/>
|
<path d="M4.91,6.25c.18,0,.74-.6,.87-.71,.17,0,.65-.53,.79-.65,.32-.26,.63-.52,.95-.78,.56-.46,1.12-.92,1.69-1.38,.15,0,.72,1.25,.84,1.45,.45,.78,.9,1.56,1.35,2.35l-1.69,1.36c-1.6-.55-3.19-1.1-4.79-1.65Z"/>
|
||||||
<path d="M13.06,5.43c-.5-1.29,.38-3.57,1.27-4.51C15.49-.3,17.51,0,19.03,.14c0,0-2.48,2.93-3.55,5.08-.82-.01-1.63,.02-2.42,.21Z"/>
|
<path d="M13.06,5.43c-.5-1.29,.38-3.57,1.27-4.51C15.49-.3,17.51,0,19.03,.14c0,0-2.48,2.93-3.55,5.08-.82-.01-1.63,.02-2.42,.21Z"/>
|
||||||
<path d="M17.66,4.96L25.6,.8s2.68,1.49,3.63,2.72c-1.4,.41-9.1,2.6-9.37,2.77-.88-.55-2.2-1.33-2.2-1.33Z"/>
|
<path d="M17.66,4.96L25.6,.8s2.68,1.49,3.63,2.72c-1.4,.41-9.1,2.6-9.37,2.77-.88-.55-2.2-1.33-2.2-1.33Z"/>
|
||||||
<polygon points="20.85 7.78 31.75 7.86 31.79 12.24 20.74 9.98 20.85 7.78"/>
|
<polygon points="20.85 7.78 31.75 7.86 31.79 12.24 20.74 9.98 20.85 7.78"/>
|
||||||
<path d="M19.56,13.33c-.17,0-.29,.12-.57,.33-.62,.47-1.24,.94-1.86,1.41,1.64,1.94,3.21,3.94,4.81,5.91,.62,.77,1.23,1.55,1.87,2.3,.97-.59,1.7-1.47,2.6-2.15,.32-.25,1.43-.76,1.45-1.22,0-.13-.86-.71-.99-.82-.42-.34-.84-.69-1.26-1.03-1.49-1.22-2.97-2.43-4.47-3.63-.4-.32-.81-.71-1.25-.98-.14-.08-.24-.12-.32-.13Z"/>
|
<path d="M19.56,13.33c-.17,0-.29,.12-.57,.33-.62,.47-1.24,.94-1.86,1.41,1.64,1.94,3.21,3.94,4.81,5.91,.62,.77,1.23,1.55,1.87,2.3,.97-.59,1.7-1.47,2.6-2.15,.32-.25,1.43-.76,1.45-1.22,0-.13-.86-.71-.99-.82-.42-.34-.84-.69-1.26-1.03-1.49-1.22-2.97-2.43-4.47-3.63-.4-.32-.81-.71-1.25-.98-.14-.08-.24-.12-.32-.13Z"/>
|
||||||
<path d="M14.11,26.46l-.16-17.73c0-.06-2.36,.1-2.45,.11-1.79,.15-3.65,.33-5.45,.55-1.43,.18-3.03,.26-4.27,1.12C.46,11.43-.13,13.17,.02,14.73l.17,.55s3.27-1.55,3.58-1.7c.63-.3,1.26-.58,1.9-.84,.54-.22,1.32-.65,1.9-.64-.17,.37-.57,.73-.84,1.05l-1.51,1.81-3.24,3.89c-.48,.57-1.02,.92-.79,1.69,.43,1.47,1.54,2.63,2.77,3.5,1.21-1.91,4.39-7.62,6.15-10.35l-.89,12.76h4.89Z"/>
|
<path fill="#FF2500" d="M14.11,26.46l-.16-17.73c0-.06-2.36,.1-2.45,.11-1.79,.15-3.65,.33-5.45,.55-1.43,.18-3.03,.26-4.27,1.12C.46,11.43-.13,13.17,.02,14.73l.17,.55s3.27-1.55,3.58-1.7c.63-.3,1.26-.58,1.9-.84,.54-.22,1.32-.65,1.9-.64-.17,.37-.57,.73-.84,1.05l-1.51,1.81-3.24,3.89c-.48,.57-1.02,.92-.79,1.69,.43,1.47,1.54,2.63,2.77,3.5,1.21-1.91,4.39-7.62,6.15-10.35l-.89,12.76h4.89Z"/>
|
||||||
</g>
|
</g>
|
||||||
<path d="M46.69,9.69v-1.09h-3.34v-3.7h-2.81V13.08h-2.44v7.1h8.57v-5.93c0-.61-.5-1.11-1.11-1.11h-2.21v-3.45h3.34Zm-3.32,4.42c.26,0,.46,.21,.46,.47v4.22c0,.26-.2,.46-.46,.46h-1.89c-.26,0-.47-.2-.47-.46v-4.22c0-.26,.21-.47,.47-.47h1.89Z"/>
|
<path d="M46.69,9.69v-1.09h-3.34v-3.7h-2.81V13.08h-2.44v7.1h8.57v-5.93c0-.61-.5-1.11-1.11-1.11h-2.21v-3.45h3.34Zm-3.32,4.42c.26,0,.46,.21,.46,.47v4.22c0,.26-.2,.46-.46,.46h-1.89c-.26,0-.47-.2-.47-.46v-4.22c0-.26,.21-.47,.47-.47h1.89Z"/>
|
||||||
<path d="M47.63,4.92h2.83l3.11,16.59h3.2v1.19h-3.69c-.88,0-1.7-.42-2.22-1.13l-.05-.07c-.15-.21-.25-.44-.3-.69l-2.88-15.89Z"/>
|
<path d="M47.63,4.92h2.83l3.11,16.59h3.2v1.19h-3.69c-.88,0-1.7-.42-2.22-1.13l-.05-.07c-.15-.21-.25-.44-.3-.69l-2.88-15.89Z"/>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.6 KiB |
@@ -1,9 +1,31 @@
|
|||||||
import type { Router } from 'vue-router'
|
import type { Router } from 'vue-router'
|
||||||
import { useAuthStore } from '@/store/modules/auth'
|
import { useAuthStore } from '@/store/modules/auth'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查 URL search 部分有没有 cas_token(CAS 回调时后端把 token 拼在 query 里),
|
||||||
|
* 有的话存下来并清掉 URL 上的痕迹。
|
||||||
|
*/
|
||||||
|
function consumeCasTokenFromUrl(authStore: ReturnType<typeof useAuthStore>) {
|
||||||
|
const search = window.location.search || ''
|
||||||
|
if (!search.includes('cas_token=')) return false
|
||||||
|
const params = new URLSearchParams(search.replace(/^\?/, ''))
|
||||||
|
const token = params.get('cas_token')
|
||||||
|
if (!token) return false
|
||||||
|
authStore.setToken(token)
|
||||||
|
// 把 cas_token 从 URL 上清掉,留住 hash 部分
|
||||||
|
const cleanUrl = window.location.pathname + window.location.hash
|
||||||
|
window.history.replaceState({}, document.title, cleanUrl)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
export function setupPageGuard(router: Router) {
|
export function setupPageGuard(router: Router) {
|
||||||
router.beforeEach(async (to, from, next) => {
|
router.beforeEach(async (to, from, next) => {
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
// CAS 回调先拿 token,再走普通鉴权
|
||||||
|
if (consumeCasTokenFromUrl(authStore)) {
|
||||||
|
next({ path: '/welcome', replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
if (to.path == '/login') {
|
if (to.path == '/login') {
|
||||||
next()
|
next()
|
||||||
return
|
return
|
||||||
@@ -25,6 +47,7 @@ export function setupPageGuard(router: Router) {
|
|||||||
}
|
}
|
||||||
next()
|
next()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.warn('[auth-guard] getSession 失败:', error)
|
||||||
if (to.path === '/login') next()
|
if (to.path === '/login') next()
|
||||||
else next({ name: 'Login' })
|
else next({ name: 'Login' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,15 +179,20 @@ const goCasLogin = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// CAS 回调:URL 含 cas_token → 存 token + 跳 welcome
|
// CAS 回调:URL 含 cas_token → 存 token + 跳 welcome
|
||||||
|
// 注意 hash 路由下 token 在 hash 里(#/login?cas_token=xxx),不在 location.search
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const hash = window.location.hash || "";
|
||||||
|
const qIdx = hash.indexOf("?");
|
||||||
|
const queryStr = qIdx >= 0 ? hash.substring(qIdx + 1) : window.location.search.replace(/^\?/, "");
|
||||||
|
const params = new URLSearchParams(queryStr);
|
||||||
const casToken = params.get("cas_token");
|
const casToken = params.get("cas_token");
|
||||||
if (casToken) {
|
if (casToken) {
|
||||||
authStore.setToken(casToken);
|
authStore.setToken(casToken);
|
||||||
|
// 清掉 URL 上的 cas_token 参数
|
||||||
window.history.replaceState(
|
window.history.replaceState(
|
||||||
{},
|
{},
|
||||||
document.title,
|
document.title,
|
||||||
window.location.pathname + window.location.hash.split("?")[0]
|
window.location.pathname + (qIdx >= 0 ? hash.substring(0, qIdx) : window.location.hash)
|
||||||
);
|
);
|
||||||
router.push("/welcome");
|
router.push("/welcome");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="welcomePage">
|
<div class="welcomePage">
|
||||||
<Waves></Waves>
|
<Waves color="#ffd060"></Waves>
|
||||||
<img :src="projectLogo" alt="" class="welcomeLogo" />
|
<img :src="projectLogo" alt="" class="welcomeLogo" />
|
||||||
<div class="welcomeContent">
|
<div class="welcomeContent">
|
||||||
<div class="welcomeTitle">
|
<div class="welcomeTitle">
|
||||||
@@ -37,10 +37,48 @@ const goChat = () => {
|
|||||||
.welcomePage {
|
.welcomePage {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background-image: url("../../assets/images/login/loginBg.png");
|
background:
|
||||||
background-size: cover;
|
/* 中心更亮的径向高光(让画面有"光源" + 立体感) */
|
||||||
|
radial-gradient(
|
||||||
|
ellipse 80% 60% at 50% 38%,
|
||||||
|
rgba(255, 220, 130, 0.18) 0%,
|
||||||
|
rgba(255, 80, 50, 0) 55%
|
||||||
|
),
|
||||||
|
/* 主红渐变(深红 → 中红 → 深红,营造曲面感) */
|
||||||
|
linear-gradient(
|
||||||
|
135deg,
|
||||||
|
#8a0a05 0%,
|
||||||
|
#c10b08 35%,
|
||||||
|
#d6201a 50%,
|
||||||
|
#c10b08 65%,
|
||||||
|
#8a0a05 100%
|
||||||
|
);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
/* 底部金色光带(CASSIC 风格的金色波纹) */
|
||||||
|
.welcomePage::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: -10%;
|
||||||
|
right: -10%;
|
||||||
|
bottom: 0;
|
||||||
|
height: 36%;
|
||||||
|
background:
|
||||||
|
radial-gradient(
|
||||||
|
ellipse 60% 100% at 30% 90%,
|
||||||
|
rgba(255, 200, 90, 0.45) 0%,
|
||||||
|
rgba(255, 200, 90, 0) 60%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
ellipse 70% 80% at 75% 100%,
|
||||||
|
rgba(255, 215, 110, 0.35) 0%,
|
||||||
|
rgba(255, 215, 110, 0) 60%
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
}
|
}
|
||||||
.welcomeLogo {
|
.welcomeLogo {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -70,32 +108,92 @@ const goChat = () => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
/* 平面玻璃片:均匀透明 + 重模糊 + 极细单色边沿,不模拟立体凸起 */
|
||||||
.glassBtn {
|
.glassBtn {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
padding: 18px 44px;
|
padding: 18px 46px;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #ffffff;
|
color: rgba(255, 255, 255, 0.95);
|
||||||
background: rgba(255, 255, 255, 0.08);
|
letter-spacing: 0.5px;
|
||||||
backdrop-filter: blur(14px);
|
|
||||||
-webkit-backdrop-filter: blur(14px);
|
/* 玻璃本体:12% 白 — 透明感主导,本身几乎隐形 */
|
||||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
/* 重磨砂打散后景,不上 saturate 避免蓝被加重 */
|
||||||
|
backdrop-filter: blur(28px);
|
||||||
|
-webkit-backdrop-filter: blur(28px);
|
||||||
|
|
||||||
|
/* 边框:更亮,整圈勾出玻璃片的轮廓 */
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.25s ease;
|
transition:
|
||||||
|
transform 0.45s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
background 0.35s ease,
|
||||||
|
border-color 0.35s ease,
|
||||||
|
box-shadow 0.45s ease;
|
||||||
|
|
||||||
|
/* 关键:靠 inset 高光"勾形",不靠白底覆盖
|
||||||
|
- 顶 inset 较亮(玻璃上沿折射到的光,定义"这是玻璃片的上边")
|
||||||
|
- 底 inset 微暗(玻璃下沿) */
|
||||||
box-shadow:
|
box-shadow:
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.25),
|
inset 0 1.5px 1px rgba(255, 255, 255, 0.65),
|
||||||
0 8px 32px rgba(0, 0, 0, 0.18);
|
inset 0 -1px 1px rgba(0, 0, 0, 0.08),
|
||||||
|
0 2px 4px rgba(0, 0, 0, 0.1),
|
||||||
|
0 10px 26px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 横扫光:玻璃面被光划过的一瞬,慢且稀疏 */
|
||||||
|
.glassBtn::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -120%;
|
||||||
|
width: 35%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(
|
||||||
|
100deg,
|
||||||
|
transparent 0%,
|
||||||
|
rgba(255, 255, 255, 0) 35%,
|
||||||
|
rgba(255, 255, 255, 0.35) 50%,
|
||||||
|
rgba(255, 255, 255, 0) 65%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
filter: blur(3px);
|
||||||
|
transform: skewX(-20deg);
|
||||||
|
animation: glassShimmer 6s ease-in-out infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 内容层级在扫光之上 */
|
||||||
|
.glassBtn > * {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.glassBtn:hover {
|
.glassBtn:hover {
|
||||||
background: rgba(255, 255, 255, 0.18);
|
background: rgba(255, 255, 255, 0.18);
|
||||||
border-color: rgba(255, 255, 255, 0.6);
|
border-color: rgba(255, 255, 255, 0.7);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 2px 1px rgba(255, 255, 255, 0.8),
|
||||||
|
inset 0 -1px 1px rgba(0, 0, 0, 0.1),
|
||||||
|
0 4px 8px rgba(0, 0, 0, 0.14),
|
||||||
|
0 14px 30px rgba(0, 0, 0, 0.26);
|
||||||
|
}
|
||||||
|
.glassBtn:hover .arrow {
|
||||||
|
transform: translateX(5px);
|
||||||
|
border-color: rgba(255, 255, 255, 0.7);
|
||||||
}
|
}
|
||||||
.glassBtn:active {
|
.glassBtn:active {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
|
transition-duration: 0.1s;
|
||||||
}
|
}
|
||||||
.glassBtn .arrow {
|
.glassBtn .arrow {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -104,8 +202,19 @@ const goChat = () => {
|
|||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-size: 18px;
|
font-size: 16px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
transition: all 0.4s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 横扫光感动画(6s 周期,划过 35% → 220%) */
|
||||||
|
@keyframes glassShimmer {
|
||||||
|
0% {
|
||||||
|
left: -120%;
|
||||||
|
}
|
||||||
|
55%, 100% {
|
||||||
|
left: 220%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -35,6 +35,26 @@ export default defineConfig((env) => {
|
|||||||
[viteEnv.VITE_GLOB_API_CTX]: {
|
[viteEnv.VITE_GLOB_API_CTX]: {
|
||||||
target: viteEnv.VITE_GLOB_API_DEV_IP,
|
target: viteEnv.VITE_GLOB_API_DEV_IP,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
// 转发原 Host,让后端能用 X-Forwarded-Host 拼出用户浏览器的实际 URL
|
||||||
|
configure: (proxy) => {
|
||||||
|
proxy.on('proxyReq', (proxyReq, req) => {
|
||||||
|
if (req.headers.host) proxyReq.setHeader('X-Forwarded-Host', req.headers.host)
|
||||||
|
proxyReq.setHeader('X-Forwarded-Proto', 'http')
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// CAS 回调:service URL 形如 /metalinfo/chat_web_backend/cas/login?ticket=xxx
|
||||||
|
// 把 /metalinfo 前缀去掉转给后端,否则 vite 当前端路由吃掉
|
||||||
|
[`${viteEnv.VITE_GLOB_FRONT_CTX}${viteEnv.VITE_GLOB_API_CTX}`]: {
|
||||||
|
target: viteEnv.VITE_GLOB_API_DEV_IP,
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (p) => p.replace(new RegExp(`^${viteEnv.VITE_GLOB_FRONT_CTX}`), ''),
|
||||||
|
configure: (proxy) => {
|
||||||
|
proxy.on('proxyReq', (proxyReq, req) => {
|
||||||
|
if (req.headers.host) proxyReq.setHeader('X-Forwarded-Host', req.headers.host)
|
||||||
|
proxyReq.setHeader('X-Forwarded-Proto', 'http')
|
||||||
|
})
|
||||||
|
},
|
||||||
},
|
},
|
||||||
// 工具服务通过 Nginx(:18000) 反代,sub_filter 处理子资源路径
|
// 工具服务通过 Nginx(:18000) 反代,sub_filter 处理子资源路径
|
||||||
'/pdf/': {
|
'/pdf/': {
|
||||||
|
|||||||
Reference in New Issue
Block a user