不再使用的代码清理
This commit is contained in:
Generated
+1
-1
File diff suppressed because one or more lines are too long
@@ -23,15 +23,17 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
registry.addInterceptor(jwtAuthInterceptor)
|
||||
.addPathPatterns("/api/**") // 拦截所有API请求
|
||||
.excludePathPatterns(
|
||||
"/api/auth/login", // 登录接口
|
||||
"/api/auth/register", // 注册接口
|
||||
"/api/auth/captcha", // 验证码接口
|
||||
"/api/auth/refresh-token", // 刷新token接口
|
||||
"/api/health", // 健康检查接口
|
||||
"/api/ws/**", // WebSocket接口
|
||||
"/swagger-ui/**", // Swagger UI
|
||||
"/v3/api-docs/**", // API文档
|
||||
"/actuator/**" // 监控端点
|
||||
"/api/auth/login", // 登录接口
|
||||
"/api/auth/register", // 注册接口
|
||||
"/api/auth/captcha", // 图形验证码接口
|
||||
"/api/auth/sms-code", // 短信验证码接口(免登录)
|
||||
"/api/auth/refresh-token", // 刷新token接口
|
||||
"/api/auth/resetPassword", // 重置密码接口(免登录)
|
||||
"/api/health", // 健康检查接口
|
||||
"/api/ws/**", // WebSocket接口
|
||||
"/swagger-ui/**", // Swagger UI
|
||||
"/v3/api-docs/**", // API文档
|
||||
"/actuator/**" // 监控端点
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import com.emotion.common.Result;
|
||||
import com.emotion.dto.request.LoginRequest;
|
||||
import com.emotion.dto.request.RegisterRequest;
|
||||
import com.emotion.dto.request.RefreshTokenRequest;
|
||||
import com.emotion.dto.request.ResetPasswordRequest;
|
||||
import com.emotion.dto.response.ResetPasswordResponse;
|
||||
|
||||
import com.emotion.dto.response.AuthResponse;
|
||||
import com.emotion.dto.response.CaptchaResponse;
|
||||
import com.emotion.dto.response.SmsCodeResponse;
|
||||
@@ -50,13 +53,23 @@ public class AuthController {
|
||||
/**
|
||||
* 用户注册(简化版:仅需手机号、密码和短信验证码)
|
||||
*/
|
||||
@PostMapping("/register")
|
||||
@PostMapping(value = "/register")
|
||||
@Operation(summary = "用户注册", description = "使用手机号、密码和短信验证码进行注册")
|
||||
public Result<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
|
||||
AuthResponse response = authService.register(request);
|
||||
return Result.success("注册成功", response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置密码(手机号 + 验证码)
|
||||
*/
|
||||
@PostMapping(value = "/resetPassword")
|
||||
@Operation(summary = "重置密码", description = "通过手机号和验证码重置密码,验证码本期固定为123456")
|
||||
public Result<ResetPasswordResponse> resetPassword(@Valid @RequestBody ResetPasswordRequest request) {
|
||||
ResetPasswordResponse response = authService.resetPassword(request);
|
||||
return Result.success("重置密码成功", response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,9 @@ package com.emotion.service;
|
||||
|
||||
import com.emotion.dto.request.LoginRequest;
|
||||
import com.emotion.dto.request.RegisterRequest;
|
||||
import com.emotion.dto.request.ResetPasswordRequest;
|
||||
import com.emotion.dto.response.ResetPasswordResponse;
|
||||
|
||||
import com.emotion.dto.response.AuthResponse;
|
||||
import com.emotion.dto.response.CaptchaResponse;
|
||||
import com.emotion.dto.response.SmsCodeResponse;
|
||||
@@ -31,6 +34,14 @@ public interface AuthService {
|
||||
* @param request 注册请求
|
||||
* @return 认证响应
|
||||
*/
|
||||
/**
|
||||
* 重置密码(手机号 + 验证码)
|
||||
*
|
||||
* @param request 重置密码请求
|
||||
* @return 重置密码响应
|
||||
*/
|
||||
ResetPasswordResponse resetPassword(ResetPasswordRequest request);
|
||||
|
||||
AuthResponse register(RegisterRequest request);
|
||||
|
||||
/**
|
||||
@@ -52,7 +63,7 @@ public interface AuthService {
|
||||
* 验证验证码
|
||||
*
|
||||
* @param captchaKey 验证码key
|
||||
* @param captcha 验证码
|
||||
* @param captcha 验证码
|
||||
* @return 是否验证成功
|
||||
*/
|
||||
boolean validateCaptcha(String captchaKey, String captcha);
|
||||
|
||||
@@ -2,8 +2,10 @@ package com.emotion.service.impl;
|
||||
|
||||
import com.emotion.dto.request.LoginRequest;
|
||||
import com.emotion.dto.request.RegisterRequest;
|
||||
import com.emotion.dto.request.ResetPasswordRequest;
|
||||
import com.emotion.dto.response.AuthResponse;
|
||||
import com.emotion.dto.response.CaptchaResponse;
|
||||
import com.emotion.dto.response.ResetPasswordResponse;
|
||||
import com.emotion.dto.response.SmsCodeResponse;
|
||||
import com.emotion.dto.response.UserInfoResponse;
|
||||
import com.emotion.entity.User;
|
||||
@@ -155,6 +157,30 @@ public class AuthServiceImpl implements AuthService {
|
||||
return response;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResetPasswordResponse resetPassword(ResetPasswordRequest request) {
|
||||
// 验证码本期固定为123456
|
||||
if (request.getCaptcha() == null || !"123456".equals(request.getCaptcha().trim())) {
|
||||
throw new CaptchaException("验证码错误或已过期");
|
||||
}
|
||||
|
||||
// 根据手机号查询用户
|
||||
User user = userService.getByPhone(request.getPhone());
|
||||
if (user == null) {
|
||||
throw new BusinessException("用户不存在");
|
||||
}
|
||||
|
||||
// 使用统一的 PasswordEncoder 进行加密,保持与登录/注册一致
|
||||
String encoded = passwordEncoder.encode(request.getNewPassword());
|
||||
user.setPassword(encoded);
|
||||
userService.updateById(user);
|
||||
|
||||
ResetPasswordResponse resp = new ResetPasswordResponse();
|
||||
resp.setSuccess(true);
|
||||
resp.setMessage("重置密码成功");
|
||||
return resp;
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserInfoResponse getCurrentUserInfo(String userId) {
|
||||
User user = userService.getById(userId);
|
||||
@@ -175,7 +201,7 @@ public class AuthServiceImpl implements AuthService {
|
||||
// 存储验证码到Redis
|
||||
redisTemplate.opsForValue().set(
|
||||
CAPTCHA_PREFIX + captchaKey,
|
||||
captchaCode.toLowerCase(),
|
||||
captchaCode.toLowerCase(),
|
||||
CAPTCHA_EXPIRE_MINUTES,
|
||||
TimeUnit.MINUTES
|
||||
);
|
||||
|
||||
@@ -109,7 +109,8 @@ public class SecurityConfig {
|
||||
"/auth/check-account",
|
||||
"/auth/check-email",
|
||||
"/auth/check-phone",
|
||||
"/captcha/**",
|
||||
"/auth/resetPassword",
|
||||
"/captcha/**",
|
||||
"/oauth/**")
|
||||
.permitAll()
|
||||
|
||||
|
||||
+17
@@ -3,8 +3,10 @@ package com.emotionmuseum.auth.controller;
|
||||
import com.emotionmuseum.common.result.Result;
|
||||
import com.emotionmuseum.auth.request.LoginRequest;
|
||||
import com.emotionmuseum.auth.request.RegisterRequest;
|
||||
import com.emotionmuseum.auth.request.ResetPasswordRequest;
|
||||
import com.emotionmuseum.auth.service.AuthService;
|
||||
import com.emotionmuseum.auth.response.LoginResponse;
|
||||
import com.emotionmuseum.auth.response.ResetPasswordResponse;
|
||||
import com.emotionmuseum.auth.response.UserInfoResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
@@ -108,4 +110,19 @@ public class AuthController {
|
||||
boolean exists = authService.existsByPhone(phone);
|
||||
return Result.success(exists);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置密码(未登录场景)
|
||||
*
|
||||
* @param request 重置密码请求(手机号 + 新密码 + 验证码=123456)
|
||||
* @return 重置密码响应
|
||||
*/
|
||||
@Operation(summary = "重置密码(手机号+验证码)")
|
||||
@PostMapping("/resetPassword")
|
||||
public Result<ResetPasswordResponse> resetPassword(@Valid @RequestBody ResetPasswordRequest request) {
|
||||
log.info("重置密码请求: phone={}", request.getPhone());
|
||||
ResetPasswordResponse response = authService.resetPassword(request);
|
||||
return Result.success("重置密码成功", response);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,18 @@ package com.emotionmuseum.auth.service;
|
||||
|
||||
import com.emotionmuseum.auth.request.LoginRequest;
|
||||
import com.emotionmuseum.auth.request.RegisterRequest;
|
||||
import com.emotionmuseum.auth.request.ResetPasswordRequest;
|
||||
import com.emotionmuseum.auth.response.LoginResponse;
|
||||
import com.emotionmuseum.auth.response.ResetPasswordResponse;
|
||||
import com.emotionmuseum.auth.response.UserInfoResponse;
|
||||
|
||||
/**
|
||||
* 认证服务接口
|
||||
*
|
||||
* <p>
|
||||
* 注意:所有新增接口需遵循项目接口规范与异常处理规范。
|
||||
* </p>
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-16
|
||||
*/
|
||||
@@ -89,4 +95,16 @@ public interface AuthService {
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
void updateLastActiveTime(String userId);
|
||||
|
||||
/**
|
||||
* 重置密码(未登录场景)
|
||||
*
|
||||
* <p>
|
||||
* 通过手机号与验证码(当前固定为123456)设置新密码。
|
||||
* </p>
|
||||
*
|
||||
* @param request 重置密码请求
|
||||
* @return 重置密码响应
|
||||
*/
|
||||
ResetPasswordResponse resetPassword(ResetPasswordRequest request);
|
||||
}
|
||||
|
||||
+42
@@ -6,11 +6,13 @@ import com.emotionmuseum.common.result.ResultCode;
|
||||
import com.emotionmuseum.common.util.JwtUtil;
|
||||
import com.emotionmuseum.auth.request.LoginRequest;
|
||||
import com.emotionmuseum.auth.request.RegisterRequest;
|
||||
import com.emotionmuseum.auth.request.ResetPasswordRequest;
|
||||
import com.emotionmuseum.auth.entity.User;
|
||||
import com.emotionmuseum.auth.mapper.UserMapper;
|
||||
import com.emotionmuseum.auth.service.AuthService;
|
||||
import com.emotionmuseum.auth.service.CaptchaService;
|
||||
import com.emotionmuseum.auth.response.LoginResponse;
|
||||
import com.emotionmuseum.auth.response.ResetPasswordResponse;
|
||||
import com.emotionmuseum.auth.response.UserInfoResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -239,6 +241,46 @@ public class AuthServiceImpl extends ServiceImpl<UserMapper, User> implements Au
|
||||
return convertToUserInfoResponse(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置密码(未登录场景)
|
||||
*
|
||||
* <p>
|
||||
* 校验验证码(当前固定为123456),按手机号查询用户,使用 PasswordEncoder(BCrypt) 加密新密码并更新。
|
||||
* </p>
|
||||
*
|
||||
* @param request 重置密码请求
|
||||
* @return 重置密码响应
|
||||
*/
|
||||
@Override
|
||||
public ResetPasswordResponse resetPassword(ResetPasswordRequest request) {
|
||||
// 校验验证码(本期约定固定为 123456)
|
||||
if (!"123456".equals(request.getCaptcha())) {
|
||||
throw new RuntimeException(ResultCode.CAPTCHA_ERROR.getMessage());
|
||||
}
|
||||
|
||||
// 按手机号查找用户
|
||||
User user = baseMapper.selectByPhone(request.getPhone());
|
||||
if (user == null) {
|
||||
throw new RuntimeException(ResultCode.USER_NOT_FOUND.getMessage());
|
||||
}
|
||||
|
||||
// 获取加密器并加密新密码
|
||||
PasswordEncoder passwordEncoder = applicationContext.getBean(PasswordEncoder.class);
|
||||
String encoded = passwordEncoder.encode(request.getNewPassword());
|
||||
|
||||
// 仅更新密码与更新时间(遵循仅更新非空字段原则)
|
||||
User toUpdate = new User();
|
||||
toUpdate.setId(user.getId());
|
||||
toUpdate.setPassword(encoded);
|
||||
updateById(toUpdate);
|
||||
|
||||
log.info("用户重置密码成功: phone={}", request.getPhone());
|
||||
ResetPasswordResponse resp = new ResetPasswordResponse();
|
||||
resp.setSuccess(true);
|
||||
resp.setMessage("重置密码成功");
|
||||
return resp;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateLastActiveTime(String userId) {
|
||||
baseMapper.updateLastActiveTime(userId);
|
||||
|
||||
@@ -68,7 +68,7 @@ spring:
|
||||
predicates:
|
||||
- Path=/api/auth/**
|
||||
filters:
|
||||
- StripPrefix=2
|
||||
- StripPrefix=1
|
||||
|
||||
# 认证服务路由 - 直接路径
|
||||
- id: emotion-auth-direct
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
# 开发环境配置
|
||||
VITE_APP_ENV=dev
|
||||
VITE_API_BASE_URL=https://dev-api.emotion-museum.com/api
|
||||
VITE_WS_BASE_URL=wss://dev-api.emotion-museum.com
|
||||
VITE_UPLOAD_URL=https://dev-api.emotion-museum.com/api/upload
|
||||
VITE_DEBUG=true
|
||||
VITE_MOCK=false
|
||||
@@ -1,7 +0,0 @@
|
||||
# 生产环境配置
|
||||
VITE_APP_ENV=prod
|
||||
VITE_API_BASE_URL=https://api.emotion-museum.com/api
|
||||
VITE_WS_BASE_URL=wss://api.emotion-museum.com
|
||||
VITE_UPLOAD_URL=https://api.emotion-museum.com/api/upload
|
||||
VITE_DEBUG=false
|
||||
VITE_MOCK=false
|
||||
@@ -1,314 +0,0 @@
|
||||
{
|
||||
"globals": {
|
||||
"Component": true,
|
||||
"ComponentPublicInstance": true,
|
||||
"ComputedRef": true,
|
||||
"DirectiveBinding": true,
|
||||
"EffectScope": true,
|
||||
"ExtractDefaultPropTypes": true,
|
||||
"ExtractPropTypes": true,
|
||||
"ExtractPublicPropTypes": true,
|
||||
"InjectionKey": true,
|
||||
"MaybeRef": true,
|
||||
"MaybeRefOrGetter": true,
|
||||
"PropType": true,
|
||||
"Ref": true,
|
||||
"VNode": true,
|
||||
"WritableComputedRef": true,
|
||||
"acceptHMRUpdate": true,
|
||||
"asyncComputed": true,
|
||||
"autoResetRef": true,
|
||||
"computed": true,
|
||||
"computedAsync": true,
|
||||
"computedEager": true,
|
||||
"computedInject": true,
|
||||
"computedWithControl": true,
|
||||
"controlledComputed": true,
|
||||
"controlledRef": true,
|
||||
"createApp": true,
|
||||
"createEventHook": true,
|
||||
"createGlobalState": true,
|
||||
"createInjectionState": true,
|
||||
"createPinia": true,
|
||||
"createReactiveFn": true,
|
||||
"createReusableTemplate": true,
|
||||
"createSharedComposable": true,
|
||||
"createTemplatePromise": true,
|
||||
"createUnrefFn": true,
|
||||
"customRef": true,
|
||||
"debouncedRef": true,
|
||||
"debouncedWatch": true,
|
||||
"defineAsyncComponent": true,
|
||||
"defineComponent": true,
|
||||
"defineStore": true,
|
||||
"eagerComputed": true,
|
||||
"effectScope": true,
|
||||
"extendRef": true,
|
||||
"getActivePinia": true,
|
||||
"getCurrentInstance": true,
|
||||
"getCurrentScope": true,
|
||||
"h": true,
|
||||
"ignorableWatch": true,
|
||||
"inject": true,
|
||||
"injectLocal": true,
|
||||
"isDefined": true,
|
||||
"isProxy": true,
|
||||
"isReactive": true,
|
||||
"isReadonly": true,
|
||||
"isRef": true,
|
||||
"makeDestructurable": true,
|
||||
"mapActions": true,
|
||||
"mapGetters": true,
|
||||
"mapState": true,
|
||||
"mapStores": true,
|
||||
"mapWritableState": true,
|
||||
"markRaw": true,
|
||||
"nextTick": true,
|
||||
"onActivated": true,
|
||||
"onBeforeMount": true,
|
||||
"onBeforeRouteLeave": true,
|
||||
"onBeforeRouteUpdate": true,
|
||||
"onBeforeUnmount": true,
|
||||
"onBeforeUpdate": true,
|
||||
"onClickOutside": true,
|
||||
"onDeactivated": true,
|
||||
"onErrorCaptured": true,
|
||||
"onKeyStroke": true,
|
||||
"onLongPress": true,
|
||||
"onMounted": true,
|
||||
"onRenderTracked": true,
|
||||
"onRenderTriggered": true,
|
||||
"onScopeDispose": true,
|
||||
"onServerPrefetch": true,
|
||||
"onStartTyping": true,
|
||||
"onUnmounted": true,
|
||||
"onUpdated": true,
|
||||
"onWatcherCleanup": true,
|
||||
"pausableWatch": true,
|
||||
"provide": true,
|
||||
"provideLocal": true,
|
||||
"reactify": true,
|
||||
"reactifyObject": true,
|
||||
"reactive": true,
|
||||
"reactiveComputed": true,
|
||||
"reactiveOmit": true,
|
||||
"reactivePick": true,
|
||||
"readonly": true,
|
||||
"ref": true,
|
||||
"refAutoReset": true,
|
||||
"refDebounced": true,
|
||||
"refDefault": true,
|
||||
"refThrottled": true,
|
||||
"refWithControl": true,
|
||||
"resolveComponent": true,
|
||||
"resolveRef": true,
|
||||
"resolveUnref": true,
|
||||
"setActivePinia": true,
|
||||
"setMapStoreSuffix": true,
|
||||
"shallowReactive": true,
|
||||
"shallowReadonly": true,
|
||||
"shallowRef": true,
|
||||
"storeToRefs": true,
|
||||
"syncRef": true,
|
||||
"syncRefs": true,
|
||||
"templateRef": true,
|
||||
"throttledRef": true,
|
||||
"throttledWatch": true,
|
||||
"toRaw": true,
|
||||
"toReactive": true,
|
||||
"toRef": true,
|
||||
"toRefs": true,
|
||||
"toValue": true,
|
||||
"triggerRef": true,
|
||||
"tryOnBeforeMount": true,
|
||||
"tryOnBeforeUnmount": true,
|
||||
"tryOnMounted": true,
|
||||
"tryOnScopeDispose": true,
|
||||
"tryOnUnmounted": true,
|
||||
"unref": true,
|
||||
"unrefElement": true,
|
||||
"until": true,
|
||||
"useActiveElement": true,
|
||||
"useAnimate": true,
|
||||
"useArrayDifference": true,
|
||||
"useArrayEvery": true,
|
||||
"useArrayFilter": true,
|
||||
"useArrayFind": true,
|
||||
"useArrayFindIndex": true,
|
||||
"useArrayFindLast": true,
|
||||
"useArrayIncludes": true,
|
||||
"useArrayJoin": true,
|
||||
"useArrayMap": true,
|
||||
"useArrayReduce": true,
|
||||
"useArraySome": true,
|
||||
"useArrayUnique": true,
|
||||
"useAsyncQueue": true,
|
||||
"useAsyncState": true,
|
||||
"useAttrs": true,
|
||||
"useBase64": true,
|
||||
"useBattery": true,
|
||||
"useBluetooth": true,
|
||||
"useBreakpoints": true,
|
||||
"useBroadcastChannel": true,
|
||||
"useBrowserLocation": true,
|
||||
"useCached": true,
|
||||
"useClipboard": true,
|
||||
"useClipboardItems": true,
|
||||
"useCloned": true,
|
||||
"useColorMode": true,
|
||||
"useConfirmDialog": true,
|
||||
"useCounter": true,
|
||||
"useCssModule": true,
|
||||
"useCssVar": true,
|
||||
"useCssVars": true,
|
||||
"useCurrentElement": true,
|
||||
"useCycleList": true,
|
||||
"useDark": true,
|
||||
"useDateFormat": true,
|
||||
"useDebounce": true,
|
||||
"useDebounceFn": true,
|
||||
"useDebouncedRefHistory": true,
|
||||
"useDeviceMotion": true,
|
||||
"useDeviceOrientation": true,
|
||||
"useDevicePixelRatio": true,
|
||||
"useDevicesList": true,
|
||||
"useDisplayMedia": true,
|
||||
"useDocumentVisibility": true,
|
||||
"useDraggable": true,
|
||||
"useDropZone": true,
|
||||
"useElementBounding": true,
|
||||
"useElementByPoint": true,
|
||||
"useElementHover": true,
|
||||
"useElementSize": true,
|
||||
"useElementVisibility": true,
|
||||
"useEventBus": true,
|
||||
"useEventListener": true,
|
||||
"useEventSource": true,
|
||||
"useEyeDropper": true,
|
||||
"useFavicon": true,
|
||||
"useFetch": true,
|
||||
"useFileDialog": true,
|
||||
"useFileSystemAccess": true,
|
||||
"useFocus": true,
|
||||
"useFocusWithin": true,
|
||||
"useFps": true,
|
||||
"useFullscreen": true,
|
||||
"useGamepad": true,
|
||||
"useGeolocation": true,
|
||||
"useI18n": true,
|
||||
"useId": true,
|
||||
"useIdle": true,
|
||||
"useImage": true,
|
||||
"useInfiniteScroll": true,
|
||||
"useIntersectionObserver": true,
|
||||
"useInterval": true,
|
||||
"useIntervalFn": true,
|
||||
"useKeyModifier": true,
|
||||
"useLastChanged": true,
|
||||
"useLink": true,
|
||||
"useLocalStorage": true,
|
||||
"useMagicKeys": true,
|
||||
"useManualRefHistory": true,
|
||||
"useMediaControls": true,
|
||||
"useMediaQuery": true,
|
||||
"useMemoize": true,
|
||||
"useMemory": true,
|
||||
"useModel": true,
|
||||
"useMounted": true,
|
||||
"useMouse": true,
|
||||
"useMouseInElement": true,
|
||||
"useMousePressed": true,
|
||||
"useMutationObserver": true,
|
||||
"useNavigatorLanguage": true,
|
||||
"useNetwork": true,
|
||||
"useNow": true,
|
||||
"useObjectUrl": true,
|
||||
"useOffsetPagination": true,
|
||||
"useOnline": true,
|
||||
"usePageLeave": true,
|
||||
"useParallax": true,
|
||||
"useParentElement": true,
|
||||
"usePerformanceObserver": true,
|
||||
"usePermission": true,
|
||||
"usePointer": true,
|
||||
"usePointerLock": true,
|
||||
"usePointerSwipe": true,
|
||||
"usePreferredColorScheme": true,
|
||||
"usePreferredContrast": true,
|
||||
"usePreferredDark": true,
|
||||
"usePreferredLanguages": true,
|
||||
"usePreferredReducedMotion": true,
|
||||
"usePrevious": true,
|
||||
"useRafFn": true,
|
||||
"useRefHistory": true,
|
||||
"useResizeObserver": true,
|
||||
"useRoute": true,
|
||||
"useRouter": true,
|
||||
"useScreenOrientation": true,
|
||||
"useScreenSafeArea": true,
|
||||
"useScriptTag": true,
|
||||
"useScroll": true,
|
||||
"useScrollLock": true,
|
||||
"useSessionStorage": true,
|
||||
"useShare": true,
|
||||
"useSlots": true,
|
||||
"useSorted": true,
|
||||
"useSpeechRecognition": true,
|
||||
"useSpeechSynthesis": true,
|
||||
"useStepper": true,
|
||||
"useStorage": true,
|
||||
"useStorageAsync": true,
|
||||
"useStyleTag": true,
|
||||
"useSupported": true,
|
||||
"useSwipe": true,
|
||||
"useTemplateRef": true,
|
||||
"useTemplateRefsList": true,
|
||||
"useTextDirection": true,
|
||||
"useTextSelection": true,
|
||||
"useTextareaAutosize": true,
|
||||
"useThrottle": true,
|
||||
"useThrottleFn": true,
|
||||
"useThrottledRefHistory": true,
|
||||
"useTimeAgo": true,
|
||||
"useTimeout": true,
|
||||
"useTimeoutFn": true,
|
||||
"useTimeoutPoll": true,
|
||||
"useTimestamp": true,
|
||||
"useTitle": true,
|
||||
"useToNumber": true,
|
||||
"useToString": true,
|
||||
"useToggle": true,
|
||||
"useTransition": true,
|
||||
"useUrlSearchParams": true,
|
||||
"useUserMedia": true,
|
||||
"useVModel": true,
|
||||
"useVModels": true,
|
||||
"useVibrate": true,
|
||||
"useVirtualList": true,
|
||||
"useWakeLock": true,
|
||||
"useWebNotification": true,
|
||||
"useWebSocket": true,
|
||||
"useWebWorker": true,
|
||||
"useWebWorkerFn": true,
|
||||
"useWindowFocus": true,
|
||||
"useWindowScroll": true,
|
||||
"useWindowSize": true,
|
||||
"watch": true,
|
||||
"watchArray": true,
|
||||
"watchAtMost": true,
|
||||
"watchDebounced": true,
|
||||
"watchDeep": true,
|
||||
"watchEffect": true,
|
||||
"watchIgnorable": true,
|
||||
"watchImmediate": true,
|
||||
"watchOnce": true,
|
||||
"watchPausable": true,
|
||||
"watchPostEffect": true,
|
||||
"watchSyncEffect": true,
|
||||
"watchThrottled": true,
|
||||
"watchTriggerable": true,
|
||||
"watchWithFilter": true,
|
||||
"whenever": true,
|
||||
"ElMessage": true
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
/* eslint-env node */
|
||||
require('@rushstack/eslint-patch/modern-module-resolution')
|
||||
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: [
|
||||
'plugin:vue/vue3-essential',
|
||||
'eslint:recommended',
|
||||
'@vue/eslint-config-typescript',
|
||||
'@vue/eslint-config-prettier/skip-formatting',
|
||||
'./.eslintrc-auto-import.json'
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest'
|
||||
},
|
||||
env: {
|
||||
node: true,
|
||||
browser: true,
|
||||
es2022: true
|
||||
},
|
||||
globals: {
|
||||
defineEmits: 'readonly',
|
||||
defineProps: 'readonly',
|
||||
defineExpose: 'readonly',
|
||||
withDefaults: 'readonly'
|
||||
},
|
||||
rules: {
|
||||
// Vue规则
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/no-v-html': 'off',
|
||||
'vue/require-default-prop': 'off',
|
||||
'vue/require-explicit-emits': 'off',
|
||||
'vue/html-self-closing': [
|
||||
'error',
|
||||
{
|
||||
html: {
|
||||
void: 'always',
|
||||
normal: 'always',
|
||||
component: 'always'
|
||||
},
|
||||
svg: 'always',
|
||||
math: 'always'
|
||||
}
|
||||
],
|
||||
|
||||
// TypeScript规则
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_'
|
||||
}
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
'@typescript-eslint/prefer-ts-expect-error': 'error',
|
||||
|
||||
// 通用规则
|
||||
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||
'no-unused-vars': 'off', // 使用TypeScript版本
|
||||
'prefer-const': 'error',
|
||||
'no-var': 'error',
|
||||
|
||||
// 代码风格
|
||||
'eqeqeq': ['error', 'always'],
|
||||
'curly': ['error', 'all'],
|
||||
'brace-style': ['error', '1tbs'],
|
||||
'comma-dangle': ['error', 'never'],
|
||||
'quotes': ['error', 'single', { avoidEscape: true }],
|
||||
'semi': ['error', 'never']
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['cypress/e2e/**/*.{cy,spec}.{js,ts,jsx,tsx}'],
|
||||
extends: ['plugin:cypress/recommended']
|
||||
},
|
||||
{
|
||||
files: ['src/**/__tests__/**/*', 'src/**/*.{test,spec}.*'],
|
||||
env: {
|
||||
vitest: true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false,
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "lf",
|
||||
"vueIndentScriptAndStyle": false,
|
||||
"htmlWhitespaceSensitivity": "ignore",
|
||||
"embeddedLanguageFormatting": "auto"
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
# 情绪博物馆 Web 端
|
||||
|
||||
基于 Vue 3 + TypeScript + Vite 的现代化前端应用,为用户提供情绪记录和心理健康服务。
|
||||
|
||||
## ✨ 特性
|
||||
|
||||
- 🚀 **现代化技术栈**: Vue 3.4.21 + TypeScript 5.4.2 + Vite 5.1.6
|
||||
- 🎨 **优雅的UI**: Element Plus 2.6.1 + Tailwind CSS 3.4.1
|
||||
- 📱 **响应式设计**: 支持桌面端和移动端
|
||||
- 🔐 **完整的认证**: JWT Token + 自动刷新
|
||||
- 🌐 **实时通信**: 原生WebSocket + STOMP协议
|
||||
- 📊 **数据可视化**: ECharts 5.5.0 图表展示
|
||||
- 🌍 **国际化**: Vue I18n 多语言支持
|
||||
- 📦 **PWA支持**: 离线访问和桌面安装
|
||||
- 🧪 **完整测试**: Vitest + Cypress 测试覆盖
|
||||
|
||||
## 🏗️ 项目结构
|
||||
|
||||
```
|
||||
web-new/
|
||||
├── public/ # 静态资源
|
||||
├── src/
|
||||
│ ├── api/ # API接口定义
|
||||
│ ├── assets/ # 资源文件
|
||||
│ ├── components/ # 通用组件
|
||||
│ ├── composables/ # 组合式API
|
||||
│ ├── config/ # 配置文件
|
||||
│ ├── i18n/ # 国际化
|
||||
│ ├── layouts/ # 布局组件
|
||||
│ ├── plugins/ # 插件
|
||||
│ ├── router/ # 路由配置
|
||||
│ ├── stores/ # 状态管理
|
||||
│ ├── types/ # 类型定义
|
||||
│ ├── utils/ # 工具函数
|
||||
│ ├── views/ # 页面组件
|
||||
│ ├── App.vue # 根组件
|
||||
│ └── main.ts # 入口文件
|
||||
├── tests/ # 测试文件
|
||||
├── .env # 环境变量
|
||||
├── package.json # 依赖配置
|
||||
├── vite.config.ts # Vite配置
|
||||
└── README.md # 项目文档
|
||||
```
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Node.js >= 18.0.0
|
||||
- npm >= 9.0.0
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 开发环境
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 构建生产版本
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 预览生产版本
|
||||
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## 🧪 测试
|
||||
|
||||
### 运行单元测试
|
||||
|
||||
```bash
|
||||
npm run test
|
||||
```
|
||||
|
||||
### 运行E2E测试
|
||||
|
||||
```bash
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
### 测试覆盖率
|
||||
|
||||
```bash
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
## 📦 核心依赖
|
||||
|
||||
### 框架和工具
|
||||
|
||||
- **Vue 3.4.21**: 渐进式JavaScript框架
|
||||
- **TypeScript 5.4.2**: 类型安全的JavaScript
|
||||
- **Vite 5.1.6**: 下一代前端构建工具
|
||||
- **Vue Router 4.3.0**: 官方路由管理器
|
||||
- **Pinia 2.1.7**: 官方状态管理库
|
||||
|
||||
### UI和样式
|
||||
|
||||
- **Element Plus 2.6.1**: Vue 3 UI组件库
|
||||
- **Tailwind CSS 3.4.1**: 实用优先的CSS框架
|
||||
- **@element-plus/icons-vue**: Element Plus图标
|
||||
|
||||
### 网络和通信
|
||||
|
||||
- **Axios 1.6.8**: HTTP客户端
|
||||
- **@stomp/stompjs 7.1.1**: WebSocket STOMP协议
|
||||
|
||||
### 数据可视化
|
||||
|
||||
- **ECharts 5.5.0**: 强大的图表库
|
||||
- **vue-echarts 6.7.3**: Vue ECharts组件
|
||||
|
||||
### 工具库
|
||||
|
||||
- **@vueuse/core 10.9.0**: Vue组合式工具集
|
||||
- **Day.js 1.11.10**: 轻量级日期库
|
||||
- **Lodash-es 4.17.21**: 实用工具库
|
||||
|
||||
## 🔧 开发工具
|
||||
|
||||
### 代码质量
|
||||
|
||||
- **ESLint 8.57.0**: 代码检查
|
||||
- **Prettier 3.2.5**: 代码格式化
|
||||
- **Husky 9.0.11**: Git钩子
|
||||
- **lint-staged 15.2.2**: 暂存文件检查
|
||||
|
||||
### 自动化
|
||||
|
||||
- **unplugin-auto-import**: 自动导入API
|
||||
- **unplugin-vue-components**: 自动导入组件
|
||||
- **vite-plugin-pwa**: PWA支持
|
||||
|
||||
### 测试
|
||||
|
||||
- **Vitest 1.4.0**: 单元测试框架
|
||||
- **@vue/test-utils 2.4.5**: Vue组件测试工具
|
||||
- **Cypress 13.7.1**: E2E测试框架
|
||||
|
||||
## 🌍 环境配置
|
||||
|
||||
项目支持多环境配置:
|
||||
|
||||
- **local**: 本地开发环境
|
||||
- **dev**: 开发服务器环境
|
||||
- **test**: 测试环境
|
||||
- **prod**: 生产环境
|
||||
|
||||
通过 `.env` 文件配置不同环境的参数。
|
||||
|
||||
## 📱 功能特性
|
||||
|
||||
### 核心功能
|
||||
|
||||
- **AI智能对话**: 与AI助手进行情绪交流
|
||||
- **情绪日记**: 记录和分析日常情绪
|
||||
- **数据可视化**: 情绪趋势和统计分析
|
||||
- **个人仪表盘**: 全面的个人数据展示
|
||||
|
||||
### 技术特性
|
||||
|
||||
- **响应式设计**: 适配各种设备尺寸
|
||||
- **暗色主题**: 支持明暗主题切换
|
||||
- **国际化**: 中英文语言切换
|
||||
- **PWA**: 支持离线访问和桌面安装
|
||||
- **实时通信**: WebSocket实时消息推送
|
||||
|
||||
## 🔐 安全特性
|
||||
|
||||
- **JWT认证**: 安全的用户认证机制
|
||||
- **Token刷新**: 自动Token刷新和过期处理
|
||||
- **权限控制**: 基于角色的访问控制
|
||||
- **路由守卫**: 页面访问权限验证
|
||||
- **XSS防护**: 内容安全策略
|
||||
|
||||
## 📈 性能优化
|
||||
|
||||
- **代码分割**: 按需加载减少初始包大小
|
||||
- **Tree Shaking**: 移除未使用的代码
|
||||
- **图片优化**: 支持WebP格式和懒加载
|
||||
- **缓存策略**: 合理的缓存配置
|
||||
- **Gzip压缩**: 减少传输大小
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
1. Fork 项目
|
||||
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 打开 Pull Request
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情。
|
||||
|
||||
## 📞 联系我们
|
||||
|
||||
- 项目地址: [GitHub](https://github.com/emotion-museum/web)
|
||||
- 问题反馈: [Issues](https://github.com/emotion-museum/web/issues)
|
||||
- 邮箱: contact@emotion-museum.com
|
||||
|
||||
---
|
||||
|
||||
**情绪博物馆** - 记录情绪,分享心情的温暖空间 ❤️
|
||||
Vendored
-310
@@ -1,310 +0,0 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
export {}
|
||||
declare global {
|
||||
const EffectScope: typeof import('vue')['EffectScope']
|
||||
const ElMessage: typeof import('element-plus/es')['ElMessage']
|
||||
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
|
||||
const asyncComputed: typeof import('@vueuse/core')['asyncComputed']
|
||||
const autoResetRef: typeof import('@vueuse/core')['autoResetRef']
|
||||
const computed: typeof import('vue')['computed']
|
||||
const computedAsync: typeof import('@vueuse/core')['computedAsync']
|
||||
const computedEager: typeof import('@vueuse/core')['computedEager']
|
||||
const computedInject: typeof import('@vueuse/core')['computedInject']
|
||||
const computedWithControl: typeof import('@vueuse/core')['computedWithControl']
|
||||
const controlledComputed: typeof import('@vueuse/core')['controlledComputed']
|
||||
const controlledRef: typeof import('@vueuse/core')['controlledRef']
|
||||
const createApp: typeof import('vue')['createApp']
|
||||
const createEventHook: typeof import('@vueuse/core')['createEventHook']
|
||||
const createGlobalState: typeof import('@vueuse/core')['createGlobalState']
|
||||
const createInjectionState: typeof import('@vueuse/core')['createInjectionState']
|
||||
const createPinia: typeof import('pinia')['createPinia']
|
||||
const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn']
|
||||
const createReusableTemplate: typeof import('@vueuse/core')['createReusableTemplate']
|
||||
const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']
|
||||
const createTemplatePromise: typeof import('@vueuse/core')['createTemplatePromise']
|
||||
const createUnrefFn: typeof import('@vueuse/core')['createUnrefFn']
|
||||
const customRef: typeof import('vue')['customRef']
|
||||
const debouncedRef: typeof import('@vueuse/core')['debouncedRef']
|
||||
const debouncedWatch: typeof import('@vueuse/core')['debouncedWatch']
|
||||
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
|
||||
const defineComponent: typeof import('vue')['defineComponent']
|
||||
const defineStore: typeof import('pinia')['defineStore']
|
||||
const eagerComputed: typeof import('@vueuse/core')['eagerComputed']
|
||||
const effectScope: typeof import('vue')['effectScope']
|
||||
const extendRef: typeof import('@vueuse/core')['extendRef']
|
||||
const getActivePinia: typeof import('pinia')['getActivePinia']
|
||||
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
|
||||
const getCurrentScope: typeof import('vue')['getCurrentScope']
|
||||
const h: typeof import('vue')['h']
|
||||
const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch']
|
||||
const inject: typeof import('vue')['inject']
|
||||
const injectLocal: typeof import('@vueuse/core')['injectLocal']
|
||||
const isDefined: typeof import('@vueuse/core')['isDefined']
|
||||
const isProxy: typeof import('vue')['isProxy']
|
||||
const isReactive: typeof import('vue')['isReactive']
|
||||
const isReadonly: typeof import('vue')['isReadonly']
|
||||
const isRef: typeof import('vue')['isRef']
|
||||
const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
|
||||
const mapActions: typeof import('pinia')['mapActions']
|
||||
const mapGetters: typeof import('pinia')['mapGetters']
|
||||
const mapState: typeof import('pinia')['mapState']
|
||||
const mapStores: typeof import('pinia')['mapStores']
|
||||
const mapWritableState: typeof import('pinia')['mapWritableState']
|
||||
const markRaw: typeof import('vue')['markRaw']
|
||||
const nextTick: typeof import('vue')['nextTick']
|
||||
const onActivated: typeof import('vue')['onActivated']
|
||||
const onBeforeMount: typeof import('vue')['onBeforeMount']
|
||||
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
|
||||
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
|
||||
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
|
||||
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
|
||||
const onClickOutside: typeof import('@vueuse/core')['onClickOutside']
|
||||
const onDeactivated: typeof import('vue')['onDeactivated']
|
||||
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
|
||||
const onKeyStroke: typeof import('@vueuse/core')['onKeyStroke']
|
||||
const onLongPress: typeof import('@vueuse/core')['onLongPress']
|
||||
const onMounted: typeof import('vue')['onMounted']
|
||||
const onRenderTracked: typeof import('vue')['onRenderTracked']
|
||||
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
|
||||
const onScopeDispose: typeof import('vue')['onScopeDispose']
|
||||
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
|
||||
const onStartTyping: typeof import('@vueuse/core')['onStartTyping']
|
||||
const onUnmounted: typeof import('vue')['onUnmounted']
|
||||
const onUpdated: typeof import('vue')['onUpdated']
|
||||
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
|
||||
const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
|
||||
const provide: typeof import('vue')['provide']
|
||||
const provideLocal: typeof import('@vueuse/core')['provideLocal']
|
||||
const reactify: typeof import('@vueuse/core')['reactify']
|
||||
const reactifyObject: typeof import('@vueuse/core')['reactifyObject']
|
||||
const reactive: typeof import('vue')['reactive']
|
||||
const reactiveComputed: typeof import('@vueuse/core')['reactiveComputed']
|
||||
const reactiveOmit: typeof import('@vueuse/core')['reactiveOmit']
|
||||
const reactivePick: typeof import('@vueuse/core')['reactivePick']
|
||||
const readonly: typeof import('vue')['readonly']
|
||||
const ref: typeof import('vue')['ref']
|
||||
const refAutoReset: typeof import('@vueuse/core')['refAutoReset']
|
||||
const refDebounced: typeof import('@vueuse/core')['refDebounced']
|
||||
const refDefault: typeof import('@vueuse/core')['refDefault']
|
||||
const refThrottled: typeof import('@vueuse/core')['refThrottled']
|
||||
const refWithControl: typeof import('@vueuse/core')['refWithControl']
|
||||
const resolveComponent: typeof import('vue')['resolveComponent']
|
||||
const resolveRef: typeof import('@vueuse/core')['resolveRef']
|
||||
const resolveUnref: typeof import('@vueuse/core')['resolveUnref']
|
||||
const setActivePinia: typeof import('pinia')['setActivePinia']
|
||||
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
|
||||
const shallowReactive: typeof import('vue')['shallowReactive']
|
||||
const shallowReadonly: typeof import('vue')['shallowReadonly']
|
||||
const shallowRef: typeof import('vue')['shallowRef']
|
||||
const storeToRefs: typeof import('pinia')['storeToRefs']
|
||||
const syncRef: typeof import('@vueuse/core')['syncRef']
|
||||
const syncRefs: typeof import('@vueuse/core')['syncRefs']
|
||||
const templateRef: typeof import('@vueuse/core')['templateRef']
|
||||
const throttledRef: typeof import('@vueuse/core')['throttledRef']
|
||||
const throttledWatch: typeof import('@vueuse/core')['throttledWatch']
|
||||
const toRaw: typeof import('vue')['toRaw']
|
||||
const toReactive: typeof import('@vueuse/core')['toReactive']
|
||||
const toRef: typeof import('vue')['toRef']
|
||||
const toRefs: typeof import('vue')['toRefs']
|
||||
const toValue: typeof import('vue')['toValue']
|
||||
const triggerRef: typeof import('vue')['triggerRef']
|
||||
const tryOnBeforeMount: typeof import('@vueuse/core')['tryOnBeforeMount']
|
||||
const tryOnBeforeUnmount: typeof import('@vueuse/core')['tryOnBeforeUnmount']
|
||||
const tryOnMounted: typeof import('@vueuse/core')['tryOnMounted']
|
||||
const tryOnScopeDispose: typeof import('@vueuse/core')['tryOnScopeDispose']
|
||||
const tryOnUnmounted: typeof import('@vueuse/core')['tryOnUnmounted']
|
||||
const unref: typeof import('vue')['unref']
|
||||
const unrefElement: typeof import('@vueuse/core')['unrefElement']
|
||||
const until: typeof import('@vueuse/core')['until']
|
||||
const useActiveElement: typeof import('@vueuse/core')['useActiveElement']
|
||||
const useAnimate: typeof import('@vueuse/core')['useAnimate']
|
||||
const useArrayDifference: typeof import('@vueuse/core')['useArrayDifference']
|
||||
const useArrayEvery: typeof import('@vueuse/core')['useArrayEvery']
|
||||
const useArrayFilter: typeof import('@vueuse/core')['useArrayFilter']
|
||||
const useArrayFind: typeof import('@vueuse/core')['useArrayFind']
|
||||
const useArrayFindIndex: typeof import('@vueuse/core')['useArrayFindIndex']
|
||||
const useArrayFindLast: typeof import('@vueuse/core')['useArrayFindLast']
|
||||
const useArrayIncludes: typeof import('@vueuse/core')['useArrayIncludes']
|
||||
const useArrayJoin: typeof import('@vueuse/core')['useArrayJoin']
|
||||
const useArrayMap: typeof import('@vueuse/core')['useArrayMap']
|
||||
const useArrayReduce: typeof import('@vueuse/core')['useArrayReduce']
|
||||
const useArraySome: typeof import('@vueuse/core')['useArraySome']
|
||||
const useArrayUnique: typeof import('@vueuse/core')['useArrayUnique']
|
||||
const useAsyncQueue: typeof import('@vueuse/core')['useAsyncQueue']
|
||||
const useAsyncState: typeof import('@vueuse/core')['useAsyncState']
|
||||
const useAttrs: typeof import('vue')['useAttrs']
|
||||
const useBase64: typeof import('@vueuse/core')['useBase64']
|
||||
const useBattery: typeof import('@vueuse/core')['useBattery']
|
||||
const useBluetooth: typeof import('@vueuse/core')['useBluetooth']
|
||||
const useBreakpoints: typeof import('@vueuse/core')['useBreakpoints']
|
||||
const useBroadcastChannel: typeof import('@vueuse/core')['useBroadcastChannel']
|
||||
const useBrowserLocation: typeof import('@vueuse/core')['useBrowserLocation']
|
||||
const useCached: typeof import('@vueuse/core')['useCached']
|
||||
const useClipboard: typeof import('@vueuse/core')['useClipboard']
|
||||
const useClipboardItems: typeof import('@vueuse/core')['useClipboardItems']
|
||||
const useCloned: typeof import('@vueuse/core')['useCloned']
|
||||
const useColorMode: typeof import('@vueuse/core')['useColorMode']
|
||||
const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog']
|
||||
const useCounter: typeof import('@vueuse/core')['useCounter']
|
||||
const useCssModule: typeof import('vue')['useCssModule']
|
||||
const useCssVar: typeof import('@vueuse/core')['useCssVar']
|
||||
const useCssVars: typeof import('vue')['useCssVars']
|
||||
const useCurrentElement: typeof import('@vueuse/core')['useCurrentElement']
|
||||
const useCycleList: typeof import('@vueuse/core')['useCycleList']
|
||||
const useDark: typeof import('@vueuse/core')['useDark']
|
||||
const useDateFormat: typeof import('@vueuse/core')['useDateFormat']
|
||||
const useDebounce: typeof import('@vueuse/core')['useDebounce']
|
||||
const useDebounceFn: typeof import('@vueuse/core')['useDebounceFn']
|
||||
const useDebouncedRefHistory: typeof import('@vueuse/core')['useDebouncedRefHistory']
|
||||
const useDeviceMotion: typeof import('@vueuse/core')['useDeviceMotion']
|
||||
const useDeviceOrientation: typeof import('@vueuse/core')['useDeviceOrientation']
|
||||
const useDevicePixelRatio: typeof import('@vueuse/core')['useDevicePixelRatio']
|
||||
const useDevicesList: typeof import('@vueuse/core')['useDevicesList']
|
||||
const useDisplayMedia: typeof import('@vueuse/core')['useDisplayMedia']
|
||||
const useDocumentVisibility: typeof import('@vueuse/core')['useDocumentVisibility']
|
||||
const useDraggable: typeof import('@vueuse/core')['useDraggable']
|
||||
const useDropZone: typeof import('@vueuse/core')['useDropZone']
|
||||
const useElementBounding: typeof import('@vueuse/core')['useElementBounding']
|
||||
const useElementByPoint: typeof import('@vueuse/core')['useElementByPoint']
|
||||
const useElementHover: typeof import('@vueuse/core')['useElementHover']
|
||||
const useElementSize: typeof import('@vueuse/core')['useElementSize']
|
||||
const useElementVisibility: typeof import('@vueuse/core')['useElementVisibility']
|
||||
const useEventBus: typeof import('@vueuse/core')['useEventBus']
|
||||
const useEventListener: typeof import('@vueuse/core')['useEventListener']
|
||||
const useEventSource: typeof import('@vueuse/core')['useEventSource']
|
||||
const useEyeDropper: typeof import('@vueuse/core')['useEyeDropper']
|
||||
const useFavicon: typeof import('@vueuse/core')['useFavicon']
|
||||
const useFetch: typeof import('@vueuse/core')['useFetch']
|
||||
const useFileDialog: typeof import('@vueuse/core')['useFileDialog']
|
||||
const useFileSystemAccess: typeof import('@vueuse/core')['useFileSystemAccess']
|
||||
const useFocus: typeof import('@vueuse/core')['useFocus']
|
||||
const useFocusWithin: typeof import('@vueuse/core')['useFocusWithin']
|
||||
const useFps: typeof import('@vueuse/core')['useFps']
|
||||
const useFullscreen: typeof import('@vueuse/core')['useFullscreen']
|
||||
const useGamepad: typeof import('@vueuse/core')['useGamepad']
|
||||
const useGeolocation: typeof import('@vueuse/core')['useGeolocation']
|
||||
const useI18n: typeof import('vue-i18n')['useI18n']
|
||||
const useId: typeof import('vue')['useId']
|
||||
const useIdle: typeof import('@vueuse/core')['useIdle']
|
||||
const useImage: typeof import('@vueuse/core')['useImage']
|
||||
const useInfiniteScroll: typeof import('@vueuse/core')['useInfiniteScroll']
|
||||
const useIntersectionObserver: typeof import('@vueuse/core')['useIntersectionObserver']
|
||||
const useInterval: typeof import('@vueuse/core')['useInterval']
|
||||
const useIntervalFn: typeof import('@vueuse/core')['useIntervalFn']
|
||||
const useKeyModifier: typeof import('@vueuse/core')['useKeyModifier']
|
||||
const useLastChanged: typeof import('@vueuse/core')['useLastChanged']
|
||||
const useLink: typeof import('vue-router')['useLink']
|
||||
const useLocalStorage: typeof import('@vueuse/core')['useLocalStorage']
|
||||
const useMagicKeys: typeof import('@vueuse/core')['useMagicKeys']
|
||||
const useManualRefHistory: typeof import('@vueuse/core')['useManualRefHistory']
|
||||
const useMediaControls: typeof import('@vueuse/core')['useMediaControls']
|
||||
const useMediaQuery: typeof import('@vueuse/core')['useMediaQuery']
|
||||
const useMemoize: typeof import('@vueuse/core')['useMemoize']
|
||||
const useMemory: typeof import('@vueuse/core')['useMemory']
|
||||
const useModel: typeof import('vue')['useModel']
|
||||
const useMounted: typeof import('@vueuse/core')['useMounted']
|
||||
const useMouse: typeof import('@vueuse/core')['useMouse']
|
||||
const useMouseInElement: typeof import('@vueuse/core')['useMouseInElement']
|
||||
const useMousePressed: typeof import('@vueuse/core')['useMousePressed']
|
||||
const useMutationObserver: typeof import('@vueuse/core')['useMutationObserver']
|
||||
const useNavigatorLanguage: typeof import('@vueuse/core')['useNavigatorLanguage']
|
||||
const useNetwork: typeof import('@vueuse/core')['useNetwork']
|
||||
const useNow: typeof import('@vueuse/core')['useNow']
|
||||
const useObjectUrl: typeof import('@vueuse/core')['useObjectUrl']
|
||||
const useOffsetPagination: typeof import('@vueuse/core')['useOffsetPagination']
|
||||
const useOnline: typeof import('@vueuse/core')['useOnline']
|
||||
const usePageLeave: typeof import('@vueuse/core')['usePageLeave']
|
||||
const useParallax: typeof import('@vueuse/core')['useParallax']
|
||||
const useParentElement: typeof import('@vueuse/core')['useParentElement']
|
||||
const usePerformanceObserver: typeof import('@vueuse/core')['usePerformanceObserver']
|
||||
const usePermission: typeof import('@vueuse/core')['usePermission']
|
||||
const usePointer: typeof import('@vueuse/core')['usePointer']
|
||||
const usePointerLock: typeof import('@vueuse/core')['usePointerLock']
|
||||
const usePointerSwipe: typeof import('@vueuse/core')['usePointerSwipe']
|
||||
const usePreferredColorScheme: typeof import('@vueuse/core')['usePreferredColorScheme']
|
||||
const usePreferredContrast: typeof import('@vueuse/core')['usePreferredContrast']
|
||||
const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark']
|
||||
const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages']
|
||||
const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion']
|
||||
const usePrevious: typeof import('@vueuse/core')['usePrevious']
|
||||
const useRafFn: typeof import('@vueuse/core')['useRafFn']
|
||||
const useRefHistory: typeof import('@vueuse/core')['useRefHistory']
|
||||
const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver']
|
||||
const useRoute: typeof import('vue-router')['useRoute']
|
||||
const useRouter: typeof import('vue-router')['useRouter']
|
||||
const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation']
|
||||
const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea']
|
||||
const useScriptTag: typeof import('@vueuse/core')['useScriptTag']
|
||||
const useScroll: typeof import('@vueuse/core')['useScroll']
|
||||
const useScrollLock: typeof import('@vueuse/core')['useScrollLock']
|
||||
const useSessionStorage: typeof import('@vueuse/core')['useSessionStorage']
|
||||
const useShare: typeof import('@vueuse/core')['useShare']
|
||||
const useSlots: typeof import('vue')['useSlots']
|
||||
const useSorted: typeof import('@vueuse/core')['useSorted']
|
||||
const useSpeechRecognition: typeof import('@vueuse/core')['useSpeechRecognition']
|
||||
const useSpeechSynthesis: typeof import('@vueuse/core')['useSpeechSynthesis']
|
||||
const useStepper: typeof import('@vueuse/core')['useStepper']
|
||||
const useStorage: typeof import('@vueuse/core')['useStorage']
|
||||
const useStorageAsync: typeof import('@vueuse/core')['useStorageAsync']
|
||||
const useStyleTag: typeof import('@vueuse/core')['useStyleTag']
|
||||
const useSupported: typeof import('@vueuse/core')['useSupported']
|
||||
const useSwipe: typeof import('@vueuse/core')['useSwipe']
|
||||
const useTemplateRef: typeof import('vue')['useTemplateRef']
|
||||
const useTemplateRefsList: typeof import('@vueuse/core')['useTemplateRefsList']
|
||||
const useTextDirection: typeof import('@vueuse/core')['useTextDirection']
|
||||
const useTextSelection: typeof import('@vueuse/core')['useTextSelection']
|
||||
const useTextareaAutosize: typeof import('@vueuse/core')['useTextareaAutosize']
|
||||
const useThrottle: typeof import('@vueuse/core')['useThrottle']
|
||||
const useThrottleFn: typeof import('@vueuse/core')['useThrottleFn']
|
||||
const useThrottledRefHistory: typeof import('@vueuse/core')['useThrottledRefHistory']
|
||||
const useTimeAgo: typeof import('@vueuse/core')['useTimeAgo']
|
||||
const useTimeout: typeof import('@vueuse/core')['useTimeout']
|
||||
const useTimeoutFn: typeof import('@vueuse/core')['useTimeoutFn']
|
||||
const useTimeoutPoll: typeof import('@vueuse/core')['useTimeoutPoll']
|
||||
const useTimestamp: typeof import('@vueuse/core')['useTimestamp']
|
||||
const useTitle: typeof import('@vueuse/core')['useTitle']
|
||||
const useToNumber: typeof import('@vueuse/core')['useToNumber']
|
||||
const useToString: typeof import('@vueuse/core')['useToString']
|
||||
const useToggle: typeof import('@vueuse/core')['useToggle']
|
||||
const useTransition: typeof import('@vueuse/core')['useTransition']
|
||||
const useUrlSearchParams: typeof import('@vueuse/core')['useUrlSearchParams']
|
||||
const useUserMedia: typeof import('@vueuse/core')['useUserMedia']
|
||||
const useVModel: typeof import('@vueuse/core')['useVModel']
|
||||
const useVModels: typeof import('@vueuse/core')['useVModels']
|
||||
const useVibrate: typeof import('@vueuse/core')['useVibrate']
|
||||
const useVirtualList: typeof import('@vueuse/core')['useVirtualList']
|
||||
const useWakeLock: typeof import('@vueuse/core')['useWakeLock']
|
||||
const useWebNotification: typeof import('@vueuse/core')['useWebNotification']
|
||||
const useWebSocket: typeof import('@vueuse/core')['useWebSocket']
|
||||
const useWebWorker: typeof import('@vueuse/core')['useWebWorker']
|
||||
const useWebWorkerFn: typeof import('@vueuse/core')['useWebWorkerFn']
|
||||
const useWindowFocus: typeof import('@vueuse/core')['useWindowFocus']
|
||||
const useWindowScroll: typeof import('@vueuse/core')['useWindowScroll']
|
||||
const useWindowSize: typeof import('@vueuse/core')['useWindowSize']
|
||||
const watch: typeof import('vue')['watch']
|
||||
const watchArray: typeof import('@vueuse/core')['watchArray']
|
||||
const watchAtMost: typeof import('@vueuse/core')['watchAtMost']
|
||||
const watchDebounced: typeof import('@vueuse/core')['watchDebounced']
|
||||
const watchDeep: typeof import('@vueuse/core')['watchDeep']
|
||||
const watchEffect: typeof import('vue')['watchEffect']
|
||||
const watchIgnorable: typeof import('@vueuse/core')['watchIgnorable']
|
||||
const watchImmediate: typeof import('@vueuse/core')['watchImmediate']
|
||||
const watchOnce: typeof import('@vueuse/core')['watchOnce']
|
||||
const watchPausable: typeof import('@vueuse/core')['watchPausable']
|
||||
const watchPostEffect: typeof import('vue')['watchPostEffect']
|
||||
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
|
||||
const watchThrottled: typeof import('@vueuse/core')['watchThrottled']
|
||||
const watchTriggerable: typeof import('@vueuse/core')['watchTriggerable']
|
||||
const watchWithFilter: typeof import('@vueuse/core')['watchWithFilter']
|
||||
const whenever: typeof import('@vueuse/core')['whenever']
|
||||
}
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
}
|
||||
Vendored
-19
@@ -1,19 +0,0 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
export {}
|
||||
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
EmojiPicker: typeof import('./src/components/emoji/EmojiPicker.vue')['default']
|
||||
ErrorBoundary: typeof import('./src/components/error/ErrorBoundary.vue')['default']
|
||||
FileUpload: typeof import('./src/components/upload/FileUpload.vue')['default']
|
||||
ImageUpload: typeof import('./src/components/upload/ImageUpload.vue')['default']
|
||||
NotificationCenter: typeof import('./src/components/notification/NotificationCenter.vue')['default']
|
||||
RichTextEditor: typeof import('./src/components/editor/RichTextEditor.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
/**
|
||||
* Cypress E2E 测试配置
|
||||
*/
|
||||
|
||||
import { defineConfig } from 'cypress'
|
||||
|
||||
export default defineConfig({
|
||||
e2e: {
|
||||
// 基础URL
|
||||
baseUrl: 'http://localhost:5173',
|
||||
|
||||
// 测试文件位置
|
||||
specPattern: 'tests/e2e/**/*.cy.{js,jsx,ts,tsx}',
|
||||
|
||||
// 支持文件位置
|
||||
supportFile: 'tests/e2e/support/e2e.ts',
|
||||
|
||||
// 固件文件位置
|
||||
fixturesFolder: 'tests/e2e/fixtures',
|
||||
|
||||
// 截图和视频配置
|
||||
screenshotsFolder: 'tests/e2e/screenshots',
|
||||
videosFolder: 'tests/e2e/videos',
|
||||
|
||||
// 视频录制
|
||||
video: true,
|
||||
videoCompression: 32,
|
||||
|
||||
// 截图配置
|
||||
screenshotOnRunFailure: true,
|
||||
|
||||
// 视口配置
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 720,
|
||||
|
||||
// 等待配置
|
||||
defaultCommandTimeout: 10000,
|
||||
requestTimeout: 10000,
|
||||
responseTimeout: 10000,
|
||||
pageLoadTimeout: 30000,
|
||||
|
||||
// 重试配置
|
||||
retries: {
|
||||
runMode: 2,
|
||||
openMode: 0
|
||||
},
|
||||
|
||||
// 浏览器配置
|
||||
chromeWebSecurity: false,
|
||||
|
||||
// 环境变量
|
||||
env: {
|
||||
// API基础URL
|
||||
apiUrl: 'http://localhost:3000/api',
|
||||
|
||||
// 测试用户凭据
|
||||
testUser: {
|
||||
username: 'testuser',
|
||||
password: 'password123',
|
||||
email: 'test@example.com'
|
||||
},
|
||||
|
||||
// 测试管理员凭据
|
||||
adminUser: {
|
||||
username: 'admin',
|
||||
password: 'admin123',
|
||||
email: 'admin@example.com'
|
||||
}
|
||||
},
|
||||
|
||||
setupNodeEvents(on, config) {
|
||||
// 任务注册
|
||||
on('task', {
|
||||
// 数据库清理任务
|
||||
clearDatabase() {
|
||||
// 这里可以添加数据库清理逻辑
|
||||
return null
|
||||
},
|
||||
|
||||
// 创建测试数据任务
|
||||
seedTestData() {
|
||||
// 这里可以添加测试数据创建逻辑
|
||||
return null
|
||||
},
|
||||
|
||||
// 日志输出任务
|
||||
log(message) {
|
||||
console.log(message)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
// 文件处理
|
||||
on('before:browser:launch', (browser, launchOptions) => {
|
||||
if (browser.name === 'chrome') {
|
||||
// Chrome 特定配置
|
||||
launchOptions.args.push('--disable-dev-shm-usage')
|
||||
launchOptions.args.push('--no-sandbox')
|
||||
}
|
||||
|
||||
return launchOptions
|
||||
})
|
||||
|
||||
// 配置处理
|
||||
on('before:spec', (spec) => {
|
||||
console.log(`Running spec: ${spec.name}`)
|
||||
})
|
||||
|
||||
return config
|
||||
}
|
||||
},
|
||||
|
||||
component: {
|
||||
// 组件测试配置
|
||||
devServer: {
|
||||
framework: 'vue',
|
||||
bundler: 'vite'
|
||||
},
|
||||
|
||||
specPattern: 'src/**/*.cy.{js,jsx,ts,tsx,vue}',
|
||||
supportFile: 'tests/e2e/support/component.ts',
|
||||
|
||||
viewportWidth: 1000,
|
||||
viewportHeight: 660
|
||||
}
|
||||
})
|
||||
@@ -1,39 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="情绪博物馆 - 记录情绪,分享心情的温暖空间" />
|
||||
<meta name="keywords" content="情绪,日记,AI对话,心理健康,情感记录" />
|
||||
<meta name="author" content="情绪博物馆团队" />
|
||||
|
||||
<!-- PWA相关 -->
|
||||
<meta name="theme-color" content="#4A90E2" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<!-- 预加载关键资源 -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
|
||||
<!-- 字体 -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
|
||||
<!-- 内容安全策略 -->
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self';
|
||||
script-src 'self' 'unsafe-inline' 'unsafe-eval';
|
||||
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
|
||||
font-src 'self' https://fonts.gstatic.com;
|
||||
img-src 'self' data: https: blob:;
|
||||
connect-src 'self' ws: wss: https:;
|
||||
media-src 'self' blob:;">
|
||||
|
||||
<title>情绪博物馆</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
-15735
File diff suppressed because it is too large
Load Diff
@@ -1,102 +0,0 @@
|
||||
{
|
||||
"name": "emotion-museum-web",
|
||||
"version": "1.0.0",
|
||||
"description": "情绪博物馆Web端 - Vue3+TypeScript重构版本",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"build:check": "vue-tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
||||
"lint:check": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --ignore-path .gitignore",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"test:unit": "vitest run",
|
||||
"test:unit:watch": "vitest",
|
||||
"test:e2e": "cypress run",
|
||||
"test:e2e:open": "cypress open",
|
||||
"test:e2e:ci": "start-server-and-test dev http://localhost:5173 'cypress run'",
|
||||
"test:all": "npm run test:unit && npm run test:e2e",
|
||||
"build:analyze": "vite build --mode analyze",
|
||||
"build:staging": "vite build --mode staging",
|
||||
"build:production": "vite build --mode production",
|
||||
"prepare": "husky install"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.3.0",
|
||||
"pinia": "^2.1.7",
|
||||
"axios": "^1.6.8",
|
||||
"@stomp/stompjs": "^7.1.1",
|
||||
"element-plus": "^2.6.1",
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"echarts": "^5.5.0",
|
||||
"vue-echarts": "^6.7.3",
|
||||
"@vueuse/core": "^10.9.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"lodash-es": "^4.17.21",
|
||||
"zod": "^3.22.4",
|
||||
"@vuelidate/core": "^2.0.3",
|
||||
"@vuelidate/validators": "^2.0.4",
|
||||
"@vueuse/motion": "^2.0.0",
|
||||
"vue-toastification": "^2.0.0-rc.5",
|
||||
"nprogress": "^0.2.0",
|
||||
"vue-upload-component": "^3.1.4",
|
||||
"cropperjs": "^1.6.1",
|
||||
"file-saver": "^2.0.5",
|
||||
"@tiptap/vue-3": "^2.2.4",
|
||||
"@tiptap/starter-kit": "^2.2.4",
|
||||
"@tiptap/extension-image": "^2.2.4",
|
||||
"vue-i18n": "^9.10.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"@vue/tsconfig": "^0.5.1",
|
||||
"vue-tsc": "^2.0.6",
|
||||
"vite": "^5.1.6",
|
||||
"typescript": "^5.4.2",
|
||||
"unplugin-auto-import": "^0.17.5",
|
||||
"unplugin-vue-components": "^0.26.0",
|
||||
"vite-plugin-pwa": "^0.19.2",
|
||||
"workbox-window": "^7.0.0",
|
||||
"eslint": "^8.57.0",
|
||||
"@vue/eslint-config-typescript": "^12.0.0",
|
||||
"@vue/eslint-config-prettier": "^9.0.0",
|
||||
"prettier": "^3.2.5",
|
||||
"lint-staged": "^15.2.2",
|
||||
"husky": "^9.0.11",
|
||||
"vitest": "^1.4.0",
|
||||
"@vue/test-utils": "^2.4.5",
|
||||
"jsdom": "^24.0.0",
|
||||
"cypress": "^13.7.1",
|
||||
"rollup-plugin-visualizer": "^5.12.0",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vite-plugin-mock": "^3.0.1",
|
||||
"@intlify/unplugin-vue-i18n": "^4.0.0",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"postcss": "^8.4.35",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/cropperjs": "^1.3.5",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"@types/node": "^20.11.25",
|
||||
"start-server-and-test": "^2.0.3"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{vue,js,ts}": [
|
||||
"eslint --fix",
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0",
|
||||
"npm": ">=9.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
<template>
|
||||
<div id="app" class="min-h-screen bg-gray-50">
|
||||
<!-- 全局加载进度条 -->
|
||||
<div v-if="isLoading" class="fixed top-0 left-0 w-full h-1 bg-primary-500 z-50 animate-pulse" />
|
||||
|
||||
<!-- 路由视图 -->
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<transition
|
||||
:name="route.meta.transition || 'fade'"
|
||||
mode="out-in"
|
||||
appear
|
||||
>
|
||||
<component :is="Component" :key="route.path" />
|
||||
</transition>
|
||||
</router-view>
|
||||
|
||||
<!-- 全局通知容器 -->
|
||||
<Teleport to="body">
|
||||
<div id="toast-container" />
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { provide, computed, onMounted, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useOnline, useDocumentVisibility } from '@vueuse/core'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
// 应用状态
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 响应式数据
|
||||
const isLoading = computed(() => appStore.isLoading)
|
||||
|
||||
// 提供全局状态
|
||||
provide('appStore', appStore)
|
||||
provide('authStore', authStore)
|
||||
|
||||
// 应用初始化
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// 初始化应用配置
|
||||
await appStore.initialize()
|
||||
|
||||
// 检查用户登录状态
|
||||
await authStore.checkAuthStatus()
|
||||
|
||||
console.log('✅ 应用初始化完成')
|
||||
} catch (error) {
|
||||
console.error('❌ 应用初始化失败:', error)
|
||||
}
|
||||
})
|
||||
|
||||
// 监听网络状态
|
||||
const { isOnline } = useOnline()
|
||||
watch(isOnline, (online) => {
|
||||
if (online) {
|
||||
ElMessage.success('网络连接已恢复')
|
||||
} else {
|
||||
ElMessage.warning('网络连接已断开')
|
||||
}
|
||||
})
|
||||
|
||||
// 监听页面可见性
|
||||
const { visibility } = useDocumentVisibility()
|
||||
watch(visibility, (current) => {
|
||||
if (current === 'visible') {
|
||||
// 页面重新可见时,检查认证状态
|
||||
authStore.refreshTokenIfNeeded()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 页面过渡动画 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-left-enter-active,
|
||||
.slide-left-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-left-enter-from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-left-leave-to {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-up-enter-from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-up-leave-to {
|
||||
transform: translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* 认证相关API接口
|
||||
*/
|
||||
|
||||
import request from '@/utils/request'
|
||||
import { API_PATHS } from '@/config/constants'
|
||||
import type {
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
RegisterRequest,
|
||||
RegisterResponse,
|
||||
RefreshTokenRequest,
|
||||
RefreshTokenResponse,
|
||||
CaptchaResponse,
|
||||
OAuthLoginRequest,
|
||||
UserInfo
|
||||
} from '@/types/api'
|
||||
|
||||
export const authApi = {
|
||||
/**
|
||||
* 用户登录
|
||||
*/
|
||||
login(data: LoginRequest): Promise<LoginResponse> {
|
||||
return request.post(API_PATHS.AUTH.LOGIN, data, {
|
||||
skipAuth: true,
|
||||
showLoading: true,
|
||||
loadingText: '正在登录...'
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*/
|
||||
register(data: RegisterRequest): Promise<RegisterResponse> {
|
||||
return request.post(API_PATHS.AUTH.REGISTER, data, {
|
||||
skipAuth: true,
|
||||
showLoading: true,
|
||||
loadingText: '正在注册...'
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 用户登出
|
||||
*/
|
||||
logout(): Promise<void> {
|
||||
return request.post(API_PATHS.AUTH.LOGOUT)
|
||||
},
|
||||
|
||||
/**
|
||||
* 刷新Token
|
||||
*/
|
||||
refreshToken(data: RefreshTokenRequest): Promise<RefreshTokenResponse> {
|
||||
return request.post(API_PATHS.AUTH.REFRESH_TOKEN, data, {
|
||||
skipAuth: true,
|
||||
skipErrorHandler: true
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取验证码
|
||||
*/
|
||||
getCaptcha(): Promise<CaptchaResponse> {
|
||||
return request.get(API_PATHS.AUTH.CAPTCHA, undefined, {
|
||||
skipAuth: true
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 第三方登录
|
||||
*/
|
||||
oauthLogin(data: OAuthLoginRequest): Promise<LoginResponse> {
|
||||
return request.post(API_PATHS.AUTH.OAUTH_LOGIN, data, {
|
||||
skipAuth: true,
|
||||
showLoading: true,
|
||||
loadingText: '正在登录...'
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取用户信息
|
||||
*/
|
||||
getUserInfo(): Promise<UserInfo> {
|
||||
return request.get(API_PATHS.AUTH.USER_INFO)
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
/**
|
||||
* 对话相关API接口
|
||||
*/
|
||||
|
||||
import request from '@/utils/request'
|
||||
import { API_PATHS } from '@/config/constants'
|
||||
import type {
|
||||
CreateConversationRequest,
|
||||
ConversationInfo,
|
||||
GetUserConversationsRequest,
|
||||
MessageInfo,
|
||||
GetUserMessagesRequest,
|
||||
SearchUserMessagesRequest,
|
||||
GetRecentMessagesRequest
|
||||
} from '@/types/api'
|
||||
|
||||
export const conversationApi = {
|
||||
/**
|
||||
* 创建新对话
|
||||
*/
|
||||
create(data: CreateConversationRequest): Promise<ConversationInfo> {
|
||||
return request.post(API_PATHS.CONVERSATION.CREATE, data, {
|
||||
showLoading: true,
|
||||
loadingText: '创建中...'
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取用户对话列表
|
||||
*/
|
||||
getUserConversations(params: GetUserConversationsRequest): Promise<PageResponse<ConversationInfo>> {
|
||||
return request.get(API_PATHS.CONVERSATION.USER_LIST, params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除对话
|
||||
*/
|
||||
delete(conversationId: string): Promise<void> {
|
||||
return request.delete(`${API_PATHS.CONVERSATION.DELETE}/${conversationId}`, {
|
||||
showLoading: true,
|
||||
loadingText: '删除中...'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const messageApi = {
|
||||
/**
|
||||
* 获取用户消息列表
|
||||
*/
|
||||
getUserMessages(params: GetUserMessagesRequest): Promise<PageResponse<MessageInfo>> {
|
||||
return request.get(API_PATHS.MESSAGE.USER_PAGE, params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 搜索用户消息
|
||||
*/
|
||||
searchUserMessages(params: SearchUserMessagesRequest): Promise<PageResponse<MessageInfo>> {
|
||||
return request.get(API_PATHS.MESSAGE.USER_SEARCH, params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取最近消息
|
||||
*/
|
||||
getRecentMessages(params: GetRecentMessagesRequest): Promise<MessageInfo[]> {
|
||||
return request.get(API_PATHS.MESSAGE.USER_RECENT, params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取消息详情
|
||||
*/
|
||||
getMessageDetail(messageId: string): Promise<MessageInfo> {
|
||||
return request.get(`${API_PATHS.MESSAGE.DETAIL}/${messageId}`)
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* 日记相关API接口
|
||||
*/
|
||||
|
||||
import request from '@/utils/request'
|
||||
import { API_PATHS } from '@/config/constants'
|
||||
import type {
|
||||
DiaryPost,
|
||||
PublishDiaryRequest,
|
||||
GetUserDiariesRequest
|
||||
} from '@/types/api'
|
||||
|
||||
export const diaryApi = {
|
||||
/**
|
||||
* 发布日记
|
||||
*/
|
||||
publish(data: PublishDiaryRequest): Promise<DiaryPost> {
|
||||
return request.post(API_PATHS.DIARY.PUBLISH, data, {
|
||||
showLoading: true,
|
||||
loadingText: '发布中...'
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取用户日记列表
|
||||
*/
|
||||
getUserDiaries(params: GetUserDiariesRequest): Promise<PageResponse<DiaryPost>> {
|
||||
return request.get(API_PATHS.DIARY.USER_PAGE, params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取日记详情
|
||||
*/
|
||||
getDiaryDetail(diaryId: string): Promise<DiaryPost> {
|
||||
return request.get(`${API_PATHS.DIARY.PUBLISH}/${diaryId}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新日记
|
||||
*/
|
||||
updateDiary(diaryId: string, data: Partial<PublishDiaryRequest>): Promise<DiaryPost> {
|
||||
return request.put(`${API_PATHS.DIARY.PUBLISH}/${diaryId}`, data, {
|
||||
showLoading: true,
|
||||
loadingText: '保存中...'
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除日记
|
||||
*/
|
||||
deleteDiary(diaryId: string): Promise<void> {
|
||||
return request.delete(`${API_PATHS.DIARY.PUBLISH}/${diaryId}`, {
|
||||
showLoading: true,
|
||||
loadingText: '删除中...'
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 保存草稿
|
||||
*/
|
||||
saveDraft(data: Partial<PublishDiaryRequest>): Promise<DiaryPost> {
|
||||
return request.post(`${API_PATHS.DIARY.PUBLISH}/draft`, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取草稿列表
|
||||
*/
|
||||
getDrafts(): Promise<DiaryPost[]> {
|
||||
return request.get(`${API_PATHS.DIARY.PUBLISH}/drafts`)
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* 用户相关API接口
|
||||
*/
|
||||
|
||||
import request from '@/utils/request'
|
||||
import { API_PATHS } from '@/config/constants'
|
||||
import type {
|
||||
UserInfo,
|
||||
UpdateUserProfileRequest,
|
||||
ChangePasswordRequest,
|
||||
UploadAvatarResponse,
|
||||
VerifyEmailRequest,
|
||||
SendEmailCodeRequest,
|
||||
VerifyPhoneRequest,
|
||||
SendPhoneCodeRequest,
|
||||
UserGrowthStats
|
||||
} from '@/types/api'
|
||||
|
||||
export const userApi = {
|
||||
/**
|
||||
* 获取用户资料
|
||||
*/
|
||||
getProfile(): Promise<UserInfo> {
|
||||
return request.get(API_PATHS.USER.PROFILE)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新用户资料
|
||||
*/
|
||||
updateProfile(data: UpdateUserProfileRequest): Promise<UserInfo> {
|
||||
return request.put(API_PATHS.USER.PROFILE, data, {
|
||||
showLoading: true,
|
||||
loadingText: '保存中...'
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 修改密码
|
||||
*/
|
||||
changePassword(data: ChangePasswordRequest): Promise<void> {
|
||||
return request.put(API_PATHS.USER.PASSWORD, data, {
|
||||
showLoading: true,
|
||||
loadingText: '修改中...'
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 上传头像
|
||||
*/
|
||||
uploadAvatar(file: File): Promise<UploadAvatarResponse> {
|
||||
return request.upload(API_PATHS.USER.AVATAR_UPLOAD, file, {
|
||||
showLoading: true,
|
||||
loadingText: '上传中...'
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取用户成长数据
|
||||
*/
|
||||
getGrowthStats(): Promise<UserGrowthStats> {
|
||||
return request.get(API_PATHS.USER.GROWTH_STATS)
|
||||
},
|
||||
|
||||
/**
|
||||
* 发送邮箱验证码
|
||||
*/
|
||||
sendEmailCode(data: SendEmailCodeRequest): Promise<void> {
|
||||
return request.post(API_PATHS.USER.EMAIL_SEND_CODE, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 验证邮箱
|
||||
*/
|
||||
verifyEmail(data: VerifyEmailRequest): Promise<void> {
|
||||
return request.post(API_PATHS.USER.EMAIL_VERIFY, data, {
|
||||
showLoading: true,
|
||||
loadingText: '验证中...'
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 发送手机验证码
|
||||
*/
|
||||
sendPhoneCode(data: SendPhoneCodeRequest): Promise<void> {
|
||||
return request.post(API_PATHS.USER.PHONE_SEND_CODE, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 验证手机号
|
||||
*/
|
||||
verifyPhone(data: VerifyPhoneRequest): Promise<void> {
|
||||
return request.post(API_PATHS.USER.PHONE_VERIFY, data, {
|
||||
showLoading: true,
|
||||
loadingText: '验证中...'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
/**
|
||||
* 主样式文件
|
||||
*/
|
||||
|
||||
@import 'tailwindcss/base';
|
||||
@import 'tailwindcss/components';
|
||||
@import 'tailwindcss/utilities';
|
||||
|
||||
/* 全局样式 */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: 'Noto Sans SC', 'Inter', system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f9fafb;
|
||||
color: #374151;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
/* 暗色主题 */
|
||||
.dark {
|
||||
background-color: #111827;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-track {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background: #9ca3af;
|
||||
}
|
||||
|
||||
/* Element Plus 样式覆盖 */
|
||||
.el-button--primary {
|
||||
background-color: #4a90e2;
|
||||
border-color: #4a90e2;
|
||||
}
|
||||
|
||||
.el-button--primary:hover {
|
||||
background-color: #357abd;
|
||||
border-color: #357abd;
|
||||
}
|
||||
|
||||
/* 自定义工具类 */
|
||||
.text-gradient {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.glass-effect {
|
||||
backdrop-filter: blur(10px);
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.dark .glass-effect {
|
||||
background-color: rgba(17, 24, 39, 0.8);
|
||||
border: 1px solid rgba(75, 85, 99, 0.2);
|
||||
}
|
||||
|
||||
/* 动画类 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-up-enter-from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-up-leave-to {
|
||||
transform: translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-left-enter-active,
|
||||
.slide-left-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-left-enter-from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-left-leave-to {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 响应式工具类 */
|
||||
@media (max-width: 768px) {
|
||||
.mobile-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
.desktop-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 打印样式 */
|
||||
@media print {
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body {
|
||||
background: white !important;
|
||||
color: black !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 无障碍样式 */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* 焦点样式 */
|
||||
.focus-visible:focus {
|
||||
outline: 2px solid #4a90e2;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.loading-spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 脉冲动画 */
|
||||
.pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* 弹跳动画 */
|
||||
.bounce {
|
||||
animation: bounce 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 20%, 53%, 80%, 100% {
|
||||
animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
40%, 43% {
|
||||
animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06);
|
||||
transform: translate3d(0, -30px, 0);
|
||||
}
|
||||
70% {
|
||||
animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06);
|
||||
transform: translate3d(0, -15px, 0);
|
||||
}
|
||||
90% {
|
||||
transform: translate3d(0, -4px, 0);
|
||||
}
|
||||
}
|
||||
@@ -1,472 +0,0 @@
|
||||
<template>
|
||||
<div class="rich-text-editor">
|
||||
<!-- 工具栏 -->
|
||||
<div v-if="showToolbar" class="editor-toolbar">
|
||||
<div class="toolbar-group">
|
||||
<!-- 文本格式 -->
|
||||
<el-button-group size="small">
|
||||
<el-button @click="execCommand('bold')" :class="{ active: isActive('bold') }">
|
||||
<el-icon><Bold /></el-icon>
|
||||
</el-button>
|
||||
<el-button @click="execCommand('italic')" :class="{ active: isActive('italic') }">
|
||||
<el-icon><Italic /></el-icon>
|
||||
</el-button>
|
||||
<el-button @click="execCommand('underline')" :class="{ active: isActive('underline') }">
|
||||
<el-icon><Underline /></el-icon>
|
||||
</el-button>
|
||||
<el-button @click="execCommand('strikeThrough')" :class="{ active: isActive('strikeThrough') }">
|
||||
<el-icon><Strikethrough /></el-icon>
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
|
||||
<!-- 对齐方式 -->
|
||||
<el-button-group size="small">
|
||||
<el-button @click="execCommand('justifyLeft')" :class="{ active: isActive('justifyLeft') }">
|
||||
<el-icon><AlignLeft /></el-icon>
|
||||
</el-button>
|
||||
<el-button @click="execCommand('justifyCenter')" :class="{ active: isActive('justifyCenter') }">
|
||||
<el-icon><AlignCenter /></el-icon>
|
||||
</el-button>
|
||||
<el-button @click="execCommand('justifyRight')" :class="{ active: isActive('justifyRight') }">
|
||||
<el-icon><AlignRight /></el-icon>
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
|
||||
<!-- 列表 -->
|
||||
<el-button-group size="small">
|
||||
<el-button @click="execCommand('insertUnorderedList')" :class="{ active: isActive('insertUnorderedList') }">
|
||||
<el-icon><List /></el-icon>
|
||||
</el-button>
|
||||
<el-button @click="execCommand('insertOrderedList')" :class="{ active: isActive('insertOrderedList') }">
|
||||
<el-icon><Numbered /></el-icon>
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
|
||||
<!-- 其他功能 -->
|
||||
<el-button-group size="small">
|
||||
<el-button @click="insertLink">
|
||||
<el-icon><Link /></el-icon>
|
||||
</el-button>
|
||||
<el-button @click="insertImage">
|
||||
<el-icon><Picture /></el-icon>
|
||||
</el-button>
|
||||
<el-button @click="insertTable">
|
||||
<el-icon><Grid /></el-icon>
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
|
||||
<!-- 颜色 -->
|
||||
<div class="color-picker">
|
||||
<el-color-picker
|
||||
v-model="textColor"
|
||||
size="small"
|
||||
@change="changeTextColor"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 清除格式 -->
|
||||
<el-button size="small" @click="clearFormat">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑器内容区 -->
|
||||
<div
|
||||
ref="editorRef"
|
||||
class="editor-content"
|
||||
:style="{ height: height }"
|
||||
contenteditable
|
||||
@input="handleInput"
|
||||
@paste="handlePaste"
|
||||
@keydown="handleKeydown"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
v-html="content"
|
||||
/>
|
||||
|
||||
<!-- 字数统计 -->
|
||||
<div v-if="showWordCount" class="editor-footer">
|
||||
<span class="word-count">{{ wordCount }} 字</span>
|
||||
<span v-if="maxLength" class="word-limit">/ {{ maxLength }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 插入链接对话框 -->
|
||||
<el-dialog
|
||||
v-model="linkDialogVisible"
|
||||
title="插入链接"
|
||||
width="400px"
|
||||
>
|
||||
<el-form :model="linkForm" label-width="80px">
|
||||
<el-form-item label="链接文本">
|
||||
<el-input v-model="linkForm.text" placeholder="请输入链接文本" />
|
||||
</el-form-item>
|
||||
<el-form-item label="链接地址">
|
||||
<el-input v-model="linkForm.url" placeholder="请输入链接地址" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="linkDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="confirmInsertLink">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 插入图片对话框 -->
|
||||
<el-dialog
|
||||
v-model="imageDialogVisible"
|
||||
title="插入图片"
|
||||
width="500px"
|
||||
>
|
||||
<el-tabs v-model="imageTabActive">
|
||||
<el-tab-pane label="上传图片" name="upload">
|
||||
<ImageUpload
|
||||
:limit="1"
|
||||
:multiple="false"
|
||||
@success="handleImageUpload"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="网络图片" name="url">
|
||||
<el-form :model="imageForm" label-width="80px">
|
||||
<el-form-item label="图片地址">
|
||||
<el-input v-model="imageForm.url" placeholder="请输入图片地址" />
|
||||
</el-form-item>
|
||||
<el-form-item label="替代文本">
|
||||
<el-input v-model="imageForm.alt" placeholder="请输入替代文本" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="imageDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="confirmInsertImage">确定</el-button>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Underline,
|
||||
Strikethrough,
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
List,
|
||||
Link,
|
||||
Picture,
|
||||
Grid,
|
||||
Delete
|
||||
} from '@element-plus/icons-vue'
|
||||
import ImageUpload from '@/components/upload/ImageUpload.vue'
|
||||
|
||||
interface Props {
|
||||
modelValue?: string
|
||||
placeholder?: string
|
||||
height?: string
|
||||
maxLength?: number
|
||||
showToolbar?: boolean
|
||||
showWordCount?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: '',
|
||||
placeholder: '请输入内容...',
|
||||
height: '300px',
|
||||
maxLength: 0,
|
||||
showToolbar: true,
|
||||
showWordCount: true,
|
||||
disabled: false
|
||||
})
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: string): void
|
||||
(e: 'change', value: string): void
|
||||
(e: 'focus', event: FocusEvent): void
|
||||
(e: 'blur', event: FocusEvent): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// 响应式数据
|
||||
const editorRef = ref<HTMLElement>()
|
||||
const content = ref(props.modelValue)
|
||||
const textColor = ref('#000000')
|
||||
const linkDialogVisible = ref(false)
|
||||
const imageDialogVisible = ref(false)
|
||||
const imageTabActive = ref('upload')
|
||||
const focused = ref(false)
|
||||
|
||||
// 表单数据
|
||||
const linkForm = ref({
|
||||
text: '',
|
||||
url: ''
|
||||
})
|
||||
|
||||
const imageForm = ref({
|
||||
url: '',
|
||||
alt: ''
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const wordCount = computed(() => {
|
||||
const text = editorRef.value?.innerText || ''
|
||||
return text.length
|
||||
})
|
||||
|
||||
// 方法
|
||||
const handleInput = () => {
|
||||
if (!editorRef.value) return
|
||||
|
||||
const html = editorRef.value.innerHTML
|
||||
content.value = html
|
||||
|
||||
// 检查字数限制
|
||||
if (props.maxLength && wordCount.value > props.maxLength) {
|
||||
ElMessage.warning(`内容不能超过 ${props.maxLength} 字`)
|
||||
return
|
||||
}
|
||||
|
||||
emit('update:modelValue', html)
|
||||
emit('change', html)
|
||||
}
|
||||
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
event.preventDefault()
|
||||
|
||||
const clipboardData = event.clipboardData
|
||||
if (!clipboardData) return
|
||||
|
||||
// 获取纯文本内容
|
||||
const text = clipboardData.getData('text/plain')
|
||||
|
||||
// 插入文本
|
||||
execCommand('insertText', text)
|
||||
}
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
// Ctrl+B 加粗
|
||||
if (event.ctrlKey && event.key === 'b') {
|
||||
event.preventDefault()
|
||||
execCommand('bold')
|
||||
}
|
||||
|
||||
// Ctrl+I 斜体
|
||||
if (event.ctrlKey && event.key === 'i') {
|
||||
event.preventDefault()
|
||||
execCommand('italic')
|
||||
}
|
||||
|
||||
// Ctrl+U 下划线
|
||||
if (event.ctrlKey && event.key === 'u') {
|
||||
event.preventDefault()
|
||||
execCommand('underline')
|
||||
}
|
||||
}
|
||||
|
||||
const handleFocus = (event: FocusEvent) => {
|
||||
focused.value = true
|
||||
emit('focus', event)
|
||||
}
|
||||
|
||||
const handleBlur = (event: FocusEvent) => {
|
||||
focused.value = false
|
||||
emit('blur', event)
|
||||
}
|
||||
|
||||
const execCommand = (command: string, value?: string) => {
|
||||
document.execCommand(command, false, value)
|
||||
editorRef.value?.focus()
|
||||
handleInput()
|
||||
}
|
||||
|
||||
const isActive = (command: string): boolean => {
|
||||
return document.queryCommandState(command)
|
||||
}
|
||||
|
||||
const changeTextColor = (color: string) => {
|
||||
execCommand('foreColor', color)
|
||||
}
|
||||
|
||||
const insertLink = () => {
|
||||
const selection = window.getSelection()
|
||||
if (selection && selection.toString()) {
|
||||
linkForm.value.text = selection.toString()
|
||||
}
|
||||
linkDialogVisible.value = true
|
||||
}
|
||||
|
||||
const confirmInsertLink = () => {
|
||||
if (!linkForm.value.url) {
|
||||
ElMessage.warning('请输入链接地址')
|
||||
return
|
||||
}
|
||||
|
||||
const linkHtml = `<a href="${linkForm.value.url}" target="_blank">${linkForm.value.text || linkForm.value.url}</a>`
|
||||
execCommand('insertHTML', linkHtml)
|
||||
|
||||
linkDialogVisible.value = false
|
||||
linkForm.value = { text: '', url: '' }
|
||||
}
|
||||
|
||||
const insertImage = () => {
|
||||
imageDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleImageUpload = (response: any) => {
|
||||
const imageHtml = `<img src="${response.url}" alt="上传图片" style="max-width: 100%; height: auto;" />`
|
||||
execCommand('insertHTML', imageHtml)
|
||||
imageDialogVisible.value = false
|
||||
}
|
||||
|
||||
const confirmInsertImage = () => {
|
||||
if (!imageForm.value.url) {
|
||||
ElMessage.warning('请输入图片地址')
|
||||
return
|
||||
}
|
||||
|
||||
const imageHtml = `<img src="${imageForm.value.url}" alt="${imageForm.value.alt}" style="max-width: 100%; height: auto;" />`
|
||||
execCommand('insertHTML', imageHtml)
|
||||
|
||||
imageDialogVisible.value = false
|
||||
imageForm.value = { url: '', alt: '' }
|
||||
}
|
||||
|
||||
const insertTable = () => {
|
||||
const tableHtml = `
|
||||
<table border="1" style="border-collapse: collapse; width: 100%;">
|
||||
<tr>
|
||||
<td style="padding: 8px;">单元格1</td>
|
||||
<td style="padding: 8px;">单元格2</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px;">单元格3</td>
|
||||
<td style="padding: 8px;">单元格4</td>
|
||||
</tr>
|
||||
</table>
|
||||
`
|
||||
execCommand('insertHTML', tableHtml)
|
||||
}
|
||||
|
||||
const clearFormat = () => {
|
||||
execCommand('removeFormat')
|
||||
}
|
||||
|
||||
const focus = () => {
|
||||
editorRef.value?.focus()
|
||||
}
|
||||
|
||||
const blur = () => {
|
||||
editorRef.value?.blur()
|
||||
}
|
||||
|
||||
const getContent = () => {
|
||||
return content.value
|
||||
}
|
||||
|
||||
const setContent = (html: string) => {
|
||||
content.value = html
|
||||
if (editorRef.value) {
|
||||
editorRef.value.innerHTML = html
|
||||
}
|
||||
}
|
||||
|
||||
// 监听外部值变化
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
if (newValue !== content.value) {
|
||||
content.value = newValue
|
||||
nextTick(() => {
|
||||
if (editorRef.value) {
|
||||
editorRef.value.innerHTML = newValue
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
if (editorRef.value && props.modelValue) {
|
||||
editorRef.value.innerHTML = props.modelValue
|
||||
}
|
||||
})
|
||||
|
||||
// 暴露方法
|
||||
defineExpose({
|
||||
focus,
|
||||
blur,
|
||||
getContent,
|
||||
setContent,
|
||||
execCommand
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rich-text-editor {
|
||||
@apply border border-gray-300 rounded-lg overflow-hidden;
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
@apply bg-gray-50 border-b border-gray-300 p-2;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
@apply flex items-center space-x-2 flex-wrap;
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
@apply flex items-center;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
@apply p-4 outline-none overflow-y-auto;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.editor-content:empty:before {
|
||||
content: attr(placeholder);
|
||||
@apply text-gray-400;
|
||||
}
|
||||
|
||||
.editor-footer {
|
||||
@apply bg-gray-50 border-t border-gray-300 px-4 py-2 text-right text-sm text-gray-500;
|
||||
}
|
||||
|
||||
.word-count {
|
||||
@apply mr-1;
|
||||
}
|
||||
|
||||
.word-limit {
|
||||
@apply text-gray-400;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
@apply flex justify-end space-x-2 mt-4;
|
||||
}
|
||||
|
||||
:deep(.el-button.active) {
|
||||
@apply bg-blue-500 text-white;
|
||||
}
|
||||
|
||||
:deep(.editor-content img) {
|
||||
@apply max-w-full h-auto;
|
||||
}
|
||||
|
||||
:deep(.editor-content table) {
|
||||
@apply border-collapse w-full;
|
||||
}
|
||||
|
||||
:deep(.editor-content table td) {
|
||||
@apply border border-gray-300 p-2;
|
||||
}
|
||||
|
||||
:deep(.editor-content a) {
|
||||
@apply text-blue-500 underline;
|
||||
}
|
||||
</style>
|
||||
@@ -1,325 +0,0 @@
|
||||
<template>
|
||||
<div class="emoji-picker">
|
||||
<el-popover
|
||||
:visible="visible"
|
||||
:width="320"
|
||||
trigger="manual"
|
||||
placement="top-start"
|
||||
popper-class="emoji-popover"
|
||||
@hide="handleHide"
|
||||
>
|
||||
<template #reference>
|
||||
<div @click="togglePicker">
|
||||
<slot>
|
||||
<el-button circle>
|
||||
<el-icon><Sunny /></el-icon>
|
||||
</el-button>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="emoji-picker-content">
|
||||
<!-- 搜索框 -->
|
||||
<div class="emoji-search">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索表情..."
|
||||
size="small"
|
||||
clearable
|
||||
@input="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<!-- 分类标签 -->
|
||||
<div class="emoji-categories">
|
||||
<div
|
||||
v-for="category in categories"
|
||||
:key="category.key"
|
||||
class="category-tab"
|
||||
:class="{ active: activeCategory === category.key }"
|
||||
@click="switchCategory(category.key)"
|
||||
>
|
||||
<span class="category-icon">{{ category.icon }}</span>
|
||||
<span class="category-name">{{ category.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表情列表 -->
|
||||
<div class="emoji-list" ref="emojiListRef">
|
||||
<div v-if="filteredEmojis.length === 0" class="empty-state">
|
||||
<p class="text-gray-500 text-sm">没有找到相关表情</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="emoji-grid">
|
||||
<div
|
||||
v-for="emoji in filteredEmojis"
|
||||
:key="emoji.code"
|
||||
class="emoji-item"
|
||||
:title="emoji.name"
|
||||
@click="selectEmoji(emoji)"
|
||||
>
|
||||
<span class="emoji-char">{{ emoji.char }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最近使用 -->
|
||||
<div v-if="recentEmojis.length > 0 && !searchKeyword" class="recent-emojis">
|
||||
<div class="recent-title">最近使用</div>
|
||||
<div class="emoji-grid">
|
||||
<div
|
||||
v-for="emoji in recentEmojis"
|
||||
:key="emoji.code"
|
||||
class="emoji-item"
|
||||
:title="emoji.name"
|
||||
@click="selectEmoji(emoji)"
|
||||
>
|
||||
<span class="emoji-char">{{ emoji.char }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { Sunny, Search } from '@element-plus/icons-vue'
|
||||
import { EMOJI_DATA } from '@/config/emoji'
|
||||
import storage from '@/utils/storage'
|
||||
|
||||
interface EmojiItem {
|
||||
char: string
|
||||
code: string
|
||||
name: string
|
||||
category: string
|
||||
keywords: string[]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
visible?: boolean
|
||||
maxRecent?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
visible: false,
|
||||
maxRecent: 20
|
||||
})
|
||||
|
||||
interface Emits {
|
||||
(e: 'select', emoji: EmojiItem): void
|
||||
(e: 'update:visible', visible: boolean): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// 响应式数据
|
||||
const visible = ref(props.visible)
|
||||
const searchKeyword = ref('')
|
||||
const activeCategory = ref('smileys')
|
||||
const emojiListRef = ref<HTMLElement>()
|
||||
const recentEmojis = ref<EmojiItem[]>([])
|
||||
|
||||
// 表情分类
|
||||
const categories = [
|
||||
{ key: 'smileys', name: '笑脸', icon: '😀' },
|
||||
{ key: 'people', name: '人物', icon: '👋' },
|
||||
{ key: 'nature', name: '自然', icon: '🌱' },
|
||||
{ key: 'food', name: '食物', icon: '🍎' },
|
||||
{ key: 'activity', name: '活动', icon: '⚽' },
|
||||
{ key: 'travel', name: '旅行', icon: '🚗' },
|
||||
{ key: 'objects', name: '物品', icon: '💡' },
|
||||
{ key: 'symbols', name: '符号', icon: '❤️' },
|
||||
{ key: 'flags', name: '旗帜', icon: '🏳️' }
|
||||
]
|
||||
|
||||
// 计算属性
|
||||
const filteredEmojis = computed(() => {
|
||||
let emojis = EMOJI_DATA.filter(emoji => {
|
||||
if (searchKeyword.value) {
|
||||
const keyword = searchKeyword.value.toLowerCase()
|
||||
return emoji.name.toLowerCase().includes(keyword) ||
|
||||
emoji.keywords.some(k => k.toLowerCase().includes(keyword))
|
||||
}
|
||||
return emoji.category === activeCategory.value
|
||||
})
|
||||
|
||||
return emojis.slice(0, 100) // 限制显示数量以提高性能
|
||||
})
|
||||
|
||||
// 方法
|
||||
const togglePicker = () => {
|
||||
visible.value = !visible.value
|
||||
emit('update:visible', visible.value)
|
||||
}
|
||||
|
||||
const handleHide = () => {
|
||||
visible.value = false
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
// 搜索时重置到第一个分类
|
||||
if (searchKeyword.value) {
|
||||
activeCategory.value = 'smileys'
|
||||
}
|
||||
}
|
||||
|
||||
const switchCategory = (category: string) => {
|
||||
activeCategory.value = category
|
||||
searchKeyword.value = ''
|
||||
|
||||
// 滚动到顶部
|
||||
if (emojiListRef.value) {
|
||||
emojiListRef.value.scrollTop = 0
|
||||
}
|
||||
}
|
||||
|
||||
const selectEmoji = (emoji: EmojiItem) => {
|
||||
// 添加到最近使用
|
||||
addToRecent(emoji)
|
||||
|
||||
// 发送选择事件
|
||||
emit('select', emoji)
|
||||
|
||||
// 关闭选择器
|
||||
visible.value = false
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
const addToRecent = (emoji: EmojiItem) => {
|
||||
// 移除已存在的相同表情
|
||||
const index = recentEmojis.value.findIndex(item => item.code === emoji.code)
|
||||
if (index > -1) {
|
||||
recentEmojis.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// 添加到开头
|
||||
recentEmojis.value.unshift(emoji)
|
||||
|
||||
// 限制数量
|
||||
if (recentEmojis.value.length > props.maxRecent) {
|
||||
recentEmojis.value = recentEmojis.value.slice(0, props.maxRecent)
|
||||
}
|
||||
|
||||
// 保存到本地存储
|
||||
saveRecentEmojis()
|
||||
}
|
||||
|
||||
const loadRecentEmojis = () => {
|
||||
const saved = storage.get('recent_emojis')
|
||||
if (saved && Array.isArray(saved)) {
|
||||
recentEmojis.value = saved
|
||||
}
|
||||
}
|
||||
|
||||
const saveRecentEmojis = () => {
|
||||
storage.set('recent_emojis', recentEmojis.value)
|
||||
}
|
||||
|
||||
// 点击外部关闭
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (!target.closest('.emoji-picker') && !target.closest('.emoji-popover')) {
|
||||
visible.value = false
|
||||
emit('update:visible', false)
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
loadRecentEmojis()
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
// 监听外部visible变化
|
||||
watch(() => props.visible, (newValue) => {
|
||||
visible.value = newValue
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.emoji-picker-content {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.emoji-search {
|
||||
@apply mb-3;
|
||||
}
|
||||
|
||||
.emoji-categories {
|
||||
@apply flex flex-wrap gap-1 mb-3 border-b border-gray-200 pb-2;
|
||||
}
|
||||
|
||||
.category-tab {
|
||||
@apply flex items-center space-x-1 px-2 py-1 rounded text-xs cursor-pointer hover:bg-gray-100 transition-colors;
|
||||
}
|
||||
|
||||
.category-tab.active {
|
||||
@apply bg-blue-100 text-blue-600;
|
||||
}
|
||||
|
||||
.category-icon {
|
||||
@apply text-sm;
|
||||
}
|
||||
|
||||
.category-name {
|
||||
@apply hidden sm:inline;
|
||||
}
|
||||
|
||||
.emoji-list {
|
||||
@apply max-h-64 overflow-y-auto;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
@apply text-center py-8;
|
||||
}
|
||||
|
||||
.emoji-grid {
|
||||
@apply grid grid-cols-8 gap-1;
|
||||
}
|
||||
|
||||
.emoji-item {
|
||||
@apply w-8 h-8 flex items-center justify-center rounded hover:bg-gray-100 cursor-pointer transition-colors;
|
||||
}
|
||||
|
||||
.emoji-char {
|
||||
@apply text-lg leading-none;
|
||||
}
|
||||
|
||||
.recent-emojis {
|
||||
@apply mt-3 pt-3 border-t border-gray-200;
|
||||
}
|
||||
|
||||
.recent-title {
|
||||
@apply text-xs text-gray-600 mb-2 font-medium;
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
.emoji-list::-webkit-scrollbar {
|
||||
@apply w-1;
|
||||
}
|
||||
|
||||
.emoji-list::-webkit-scrollbar-track {
|
||||
@apply bg-gray-100 rounded;
|
||||
}
|
||||
|
||||
.emoji-list::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-300 rounded hover:bg-gray-400;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.emoji-popover {
|
||||
padding: 12px !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,467 +0,0 @@
|
||||
<template>
|
||||
<div v-if="hasError" class="error-boundary">
|
||||
<div class="error-container">
|
||||
<!-- 错误图标 -->
|
||||
<div class="error-icon">
|
||||
<el-icon size="64" class="text-red-500">
|
||||
<WarningFilled />
|
||||
</el-icon>
|
||||
</div>
|
||||
|
||||
<!-- 错误信息 -->
|
||||
<div class="error-content">
|
||||
<h2 class="error-title">{{ errorTitle }}</h2>
|
||||
<p class="error-message">{{ errorMessage }}</p>
|
||||
|
||||
<!-- 错误详情(开发环境) -->
|
||||
<div v-if="showDetails && errorDetails" class="error-details">
|
||||
<el-collapse>
|
||||
<el-collapse-item title="错误详情" name="details">
|
||||
<pre class="error-stack">{{ errorDetails }}</pre>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="error-actions">
|
||||
<el-button type="primary" @click="handleRetry">
|
||||
<el-icon class="mr-2"><Refresh /></el-icon>
|
||||
重试
|
||||
</el-button>
|
||||
|
||||
<el-button @click="handleGoHome">
|
||||
<el-icon class="mr-2"><HomeFilled /></el-icon>
|
||||
返回首页
|
||||
</el-button>
|
||||
|
||||
<el-button v-if="showReportButton" @click="handleReport">
|
||||
<el-icon class="mr-2"><Warning /></el-icon>
|
||||
报告问题
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 建议操作 -->
|
||||
<div class="error-suggestions">
|
||||
<h4 class="suggestions-title">您可以尝试:</h4>
|
||||
<ul class="suggestions-list">
|
||||
<li>刷新页面重新加载</li>
|
||||
<li>检查网络连接是否正常</li>
|
||||
<li>清除浏览器缓存和Cookie</li>
|
||||
<li>如果问题持续存在,请联系技术支持</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误报告对话框 -->
|
||||
<el-dialog
|
||||
v-model="showReportDialog"
|
||||
title="报告问题"
|
||||
width="500px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form :model="reportForm" label-width="80px">
|
||||
<el-form-item label="问题描述">
|
||||
<el-input
|
||||
v-model="reportForm.description"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="请描述您遇到的问题..."
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="联系方式">
|
||||
<el-input
|
||||
v-model="reportForm.contact"
|
||||
placeholder="邮箱或电话(可选)"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="包含错误信息">
|
||||
<el-switch v-model="reportForm.includeError" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="showReportDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitReport" :loading="submitting">
|
||||
提交报告
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
|
||||
<!-- 正常内容 -->
|
||||
<slot v-else />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onErrorCaptured, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
WarningFilled,
|
||||
Refresh,
|
||||
HomeFilled,
|
||||
Warning
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
interface Props {
|
||||
// 是否显示错误详情
|
||||
showDetails?: boolean
|
||||
// 是否显示报告按钮
|
||||
showReportButton?: boolean
|
||||
// 自定义错误标题
|
||||
customTitle?: string
|
||||
// 自定义错误消息
|
||||
customMessage?: string
|
||||
// 重试回调
|
||||
onRetry?: () => void
|
||||
// 错误回调
|
||||
onError?: (error: Error, instance: any, info: string) => void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showDetails: process.env.NODE_ENV === 'development',
|
||||
showReportButton: true,
|
||||
customTitle: '',
|
||||
customMessage: '',
|
||||
onRetry: undefined,
|
||||
onError: undefined
|
||||
})
|
||||
|
||||
interface Emits {
|
||||
(e: 'error', error: Error): void
|
||||
(e: 'retry'): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 响应式数据
|
||||
const hasError = ref(false)
|
||||
const errorInfo = ref<Error | null>(null)
|
||||
const errorInstance = ref<any>(null)
|
||||
const errorContext = ref('')
|
||||
const showReportDialog = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
// 报告表单
|
||||
const reportForm = reactive({
|
||||
description: '',
|
||||
contact: '',
|
||||
includeError: true
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const errorTitle = computed(() => {
|
||||
if (props.customTitle) return props.customTitle
|
||||
|
||||
if (errorInfo.value) {
|
||||
if (errorInfo.value.name === 'ChunkLoadError') {
|
||||
return '资源加载失败'
|
||||
}
|
||||
if (errorInfo.value.name === 'NetworkError') {
|
||||
return '网络连接错误'
|
||||
}
|
||||
if (errorInfo.value.message.includes('timeout')) {
|
||||
return '请求超时'
|
||||
}
|
||||
}
|
||||
|
||||
return '页面出现错误'
|
||||
})
|
||||
|
||||
const errorMessage = computed(() => {
|
||||
if (props.customMessage) return props.customMessage
|
||||
|
||||
if (errorInfo.value) {
|
||||
if (errorInfo.value.name === 'ChunkLoadError') {
|
||||
return '页面资源加载失败,可能是网络问题或版本更新导致的。'
|
||||
}
|
||||
if (errorInfo.value.name === 'NetworkError') {
|
||||
return '网络连接出现问题,请检查您的网络设置。'
|
||||
}
|
||||
if (errorInfo.value.message.includes('timeout')) {
|
||||
return '请求处理时间过长,请稍后重试。'
|
||||
}
|
||||
}
|
||||
|
||||
return '页面运行时出现了意外错误,我们正在努力修复。'
|
||||
})
|
||||
|
||||
const errorDetails = computed(() => {
|
||||
if (!errorInfo.value) return ''
|
||||
|
||||
return `${errorInfo.value.name}: ${errorInfo.value.message}\n\n${errorInfo.value.stack || ''}`
|
||||
})
|
||||
|
||||
// 错误捕获
|
||||
onErrorCaptured((error: Error, instance: any, info: string) => {
|
||||
console.error('ErrorBoundary caught error:', error)
|
||||
console.error('Error info:', info)
|
||||
console.error('Component instance:', instance)
|
||||
|
||||
hasError.value = true
|
||||
errorInfo.value = error
|
||||
errorInstance.value = instance
|
||||
errorContext.value = info
|
||||
|
||||
// 调用自定义错误处理
|
||||
if (props.onError) {
|
||||
props.onError(error, instance, info)
|
||||
}
|
||||
|
||||
// 发送错误事件
|
||||
emit('error', error)
|
||||
|
||||
// 错误上报
|
||||
reportError(error, info)
|
||||
|
||||
// 阻止错误继续传播
|
||||
return false
|
||||
})
|
||||
|
||||
// 全局错误处理
|
||||
onMounted(() => {
|
||||
// 捕获未处理的Promise错误
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
console.error('Unhandled promise rejection:', event.reason)
|
||||
|
||||
const error = new Error(event.reason?.message || 'Unhandled promise rejection')
|
||||
error.name = 'UnhandledPromiseRejection'
|
||||
|
||||
hasError.value = true
|
||||
errorInfo.value = error
|
||||
errorContext.value = 'unhandledrejection'
|
||||
|
||||
reportError(error, 'unhandledrejection')
|
||||
})
|
||||
|
||||
// 捕获资源加载错误
|
||||
window.addEventListener('error', (event) => {
|
||||
if (event.target !== window) {
|
||||
console.error('Resource load error:', event)
|
||||
|
||||
const error = new Error(`Failed to load resource: ${event.target}`)
|
||||
error.name = 'ResourceLoadError'
|
||||
|
||||
// 对于资源加载错误,可以选择不显示错误边界
|
||||
// 而是显示一个小的错误提示
|
||||
ElMessage.error('部分资源加载失败,页面可能显示异常')
|
||||
|
||||
reportError(error, 'resource-load')
|
||||
}
|
||||
}, true)
|
||||
})
|
||||
|
||||
// 方法
|
||||
const handleRetry = () => {
|
||||
if (props.onRetry) {
|
||||
props.onRetry()
|
||||
} else {
|
||||
// 默认重试:重新加载页面
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
emit('retry')
|
||||
}
|
||||
|
||||
const handleGoHome = () => {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
const handleReport = () => {
|
||||
showReportDialog.value = true
|
||||
|
||||
// 预填充错误信息
|
||||
if (errorInfo.value && reportForm.includeError) {
|
||||
reportForm.description = `错误类型: ${errorInfo.value.name}\n错误消息: ${errorInfo.value.message}\n发生时间: ${new Date().toLocaleString()}`
|
||||
}
|
||||
}
|
||||
|
||||
const submitReport = async () => {
|
||||
if (!reportForm.description.trim()) {
|
||||
ElMessage.warning('请描述您遇到的问题')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
submitting.value = true
|
||||
|
||||
const reportData = {
|
||||
description: reportForm.description,
|
||||
contact: reportForm.contact,
|
||||
error: reportForm.includeError ? {
|
||||
name: errorInfo.value?.name,
|
||||
message: errorInfo.value?.message,
|
||||
stack: errorInfo.value?.stack,
|
||||
context: errorContext.value
|
||||
} : null,
|
||||
userAgent: navigator.userAgent,
|
||||
url: window.location.href,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
|
||||
// 发送错误报告到服务器
|
||||
await fetch('/api/error-report', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(reportData)
|
||||
})
|
||||
|
||||
ElMessage.success('问题报告已提交,感谢您的反馈')
|
||||
showReportDialog.value = false
|
||||
|
||||
// 重置表单
|
||||
reportForm.description = ''
|
||||
reportForm.contact = ''
|
||||
reportForm.includeError = true
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to submit error report:', error)
|
||||
ElMessage.error('提交失败,请稍后重试')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const reportError = (error: Error, context: string) => {
|
||||
// 错误上报到监控系统
|
||||
try {
|
||||
// 这里可以集成第三方错误监控服务
|
||||
// 如 Sentry, LogRocket, Bugsnag 等
|
||||
|
||||
const errorData = {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
context,
|
||||
url: window.location.href,
|
||||
userAgent: navigator.userAgent,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
|
||||
// 发送到错误监控服务
|
||||
if (window.Sentry) {
|
||||
window.Sentry.captureException(error, {
|
||||
contexts: {
|
||||
errorBoundary: {
|
||||
context,
|
||||
componentStack: errorContext.value
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 或者发送到自己的错误收集接口
|
||||
fetch('/api/error-log', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(errorData)
|
||||
}).catch(err => {
|
||||
console.error('Failed to report error:', err)
|
||||
})
|
||||
|
||||
} catch (reportError) {
|
||||
console.error('Error reporting failed:', reportError)
|
||||
}
|
||||
}
|
||||
|
||||
// 重置错误状态
|
||||
const resetError = () => {
|
||||
hasError.value = false
|
||||
errorInfo.value = null
|
||||
errorInstance.value = null
|
||||
errorContext.value = ''
|
||||
}
|
||||
|
||||
// 暴露方法
|
||||
defineExpose({
|
||||
resetError,
|
||||
hasError: () => hasError.value,
|
||||
getError: () => errorInfo.value
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.error-boundary {
|
||||
@apply min-h-screen flex items-center justify-center bg-gray-50 p-4;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
@apply max-w-2xl w-full bg-white rounded-lg shadow-lg p-8 text-center;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
@apply mb-6;
|
||||
}
|
||||
|
||||
.error-content {
|
||||
@apply space-y-6;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
@apply text-2xl font-bold text-gray-900 mb-4;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
@apply text-gray-600 text-lg leading-relaxed;
|
||||
}
|
||||
|
||||
.error-details {
|
||||
@apply text-left;
|
||||
}
|
||||
|
||||
.error-stack {
|
||||
@apply bg-gray-100 p-4 rounded text-sm font-mono text-gray-800 overflow-auto max-h-64;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
@apply flex flex-wrap justify-center gap-4;
|
||||
}
|
||||
|
||||
.error-suggestions {
|
||||
@apply text-left bg-blue-50 p-4 rounded-lg;
|
||||
}
|
||||
|
||||
.suggestions-title {
|
||||
@apply text-sm font-semibold text-blue-900 mb-2;
|
||||
}
|
||||
|
||||
.suggestions-list {
|
||||
@apply text-sm text-blue-800 space-y-1;
|
||||
}
|
||||
|
||||
.suggestions-list li {
|
||||
@apply flex items-start;
|
||||
}
|
||||
|
||||
.suggestions-list li::before {
|
||||
content: "•";
|
||||
@apply text-blue-500 mr-2 flex-shrink-0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.error-container {
|
||||
@apply p-6;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
@apply text-xl;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
@apply text-base;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
@apply flex-col;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,514 +0,0 @@
|
||||
<template>
|
||||
<div class="notification-center">
|
||||
<!-- 通知按钮 -->
|
||||
<el-popover
|
||||
:visible="visible"
|
||||
:width="360"
|
||||
trigger="manual"
|
||||
placement="bottom-end"
|
||||
popper-class="notification-popover"
|
||||
@hide="handleHide"
|
||||
>
|
||||
<template #reference>
|
||||
<div class="notification-trigger" @click="toggleNotifications">
|
||||
<el-badge :value="unreadCount" :hidden="unreadCount === 0" :max="99">
|
||||
<el-button circle>
|
||||
<el-icon :size="18">
|
||||
<Bell />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</el-badge>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="notification-content">
|
||||
<!-- 头部 -->
|
||||
<div class="notification-header">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900">通知</h3>
|
||||
<div class="flex items-center space-x-2">
|
||||
<el-button
|
||||
v-if="unreadCount > 0"
|
||||
size="small"
|
||||
text
|
||||
@click="markAllAsRead"
|
||||
>
|
||||
全部已读
|
||||
</el-button>
|
||||
<el-button size="small" text @click="clearAll">
|
||||
清空
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 筛选标签 -->
|
||||
<div class="notification-filters">
|
||||
<div class="filter-tabs">
|
||||
<div
|
||||
v-for="filter in filters"
|
||||
:key="filter.key"
|
||||
class="filter-tab"
|
||||
:class="{ active: activeFilter === filter.key }"
|
||||
@click="switchFilter(filter.key)"
|
||||
>
|
||||
{{ filter.label }}
|
||||
<span v-if="filter.count > 0" class="filter-count">{{ filter.count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 通知列表 -->
|
||||
<div class="notification-list">
|
||||
<div v-if="filteredNotifications.length === 0" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<el-icon size="32" class="text-gray-400">
|
||||
<Bell />
|
||||
</el-icon>
|
||||
</div>
|
||||
<p class="empty-text">暂无通知</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="notification-items">
|
||||
<div
|
||||
v-for="notification in filteredNotifications"
|
||||
:key="notification.id"
|
||||
class="notification-item"
|
||||
:class="{ unread: !notification.read }"
|
||||
@click="handleNotificationClick(notification)"
|
||||
>
|
||||
<div class="notification-icon">
|
||||
<div
|
||||
class="icon-wrapper"
|
||||
:class="getNotificationIconClass(notification.type)"
|
||||
>
|
||||
<el-icon>
|
||||
<component :is="getNotificationIcon(notification.type)" />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="notification-content">
|
||||
<div class="notification-title">{{ notification.title }}</div>
|
||||
<div class="notification-message">{{ notification.message }}</div>
|
||||
<div class="notification-time">{{ formatTime(notification.createTime) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="notification-actions">
|
||||
<el-dropdown @command="handleAction">
|
||||
<el-button circle size="small" text>
|
||||
<el-icon><MoreFilled /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-if="!notification.read"
|
||||
:command="`read_${notification.id}`"
|
||||
>
|
||||
标记已读
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
v-else
|
||||
:command="`unread_${notification.id}`"
|
||||
>
|
||||
标记未读
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
:command="`delete_${notification.id}`"
|
||||
divided
|
||||
>
|
||||
删除
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部 -->
|
||||
<div v-if="hasMore" class="notification-footer">
|
||||
<el-button text @click="loadMore" :loading="loading">
|
||||
加载更多
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
Bell,
|
||||
MoreFilled,
|
||||
ChatDotRound,
|
||||
User,
|
||||
Setting,
|
||||
Warning,
|
||||
InfoFilled
|
||||
} from '@element-plus/icons-vue'
|
||||
import { formatRelativeTime } from '@/utils/format'
|
||||
import { useNotificationStore } from '@/stores/notification'
|
||||
|
||||
interface NotificationItem {
|
||||
id: string
|
||||
type: 'message' | 'system' | 'user' | 'warning' | 'info'
|
||||
title: string
|
||||
message: string
|
||||
read: boolean
|
||||
createTime: number
|
||||
data?: any
|
||||
}
|
||||
|
||||
// 状态管理
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
// 响应式数据
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const activeFilter = ref('all')
|
||||
|
||||
// 模拟通知数据
|
||||
const notifications = ref<NotificationItem[]>([
|
||||
{
|
||||
id: '1',
|
||||
type: 'message',
|
||||
title: 'AI助手回复',
|
||||
message: '您的问题已经得到回复,请查看',
|
||||
read: false,
|
||||
createTime: Date.now() - 1000 * 60 * 5
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'system',
|
||||
title: '系统更新',
|
||||
message: '系统已更新到最新版本 v1.2.0',
|
||||
read: false,
|
||||
createTime: Date.now() - 1000 * 60 * 60
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'user',
|
||||
title: '资料完善提醒',
|
||||
message: '完善个人资料可以获得更好的服务体验',
|
||||
read: true,
|
||||
createTime: Date.now() - 1000 * 60 * 60 * 24
|
||||
}
|
||||
])
|
||||
|
||||
const hasMore = ref(true)
|
||||
|
||||
// 筛选选项
|
||||
const filters = computed(() => [
|
||||
{ key: 'all', label: '全部', count: notifications.value.length },
|
||||
{ key: 'unread', label: '未读', count: unreadCount.value },
|
||||
{ key: 'message', label: '消息', count: notifications.value.filter(n => n.type === 'message').length },
|
||||
{ key: 'system', label: '系统', count: notifications.value.filter(n => n.type === 'system').length }
|
||||
])
|
||||
|
||||
// 计算属性
|
||||
const unreadCount = computed(() => {
|
||||
return notifications.value.filter(n => !n.read).length
|
||||
})
|
||||
|
||||
const filteredNotifications = computed(() => {
|
||||
let filtered = notifications.value
|
||||
|
||||
switch (activeFilter.value) {
|
||||
case 'unread':
|
||||
filtered = filtered.filter(n => !n.read)
|
||||
break
|
||||
case 'message':
|
||||
filtered = filtered.filter(n => n.type === 'message')
|
||||
break
|
||||
case 'system':
|
||||
filtered = filtered.filter(n => n.type === 'system')
|
||||
break
|
||||
case 'user':
|
||||
filtered = filtered.filter(n => n.type === 'user')
|
||||
break
|
||||
}
|
||||
|
||||
return filtered.sort((a, b) => b.createTime - a.createTime)
|
||||
})
|
||||
|
||||
// 方法
|
||||
const toggleNotifications = () => {
|
||||
visible.value = !visible.value
|
||||
}
|
||||
|
||||
const handleHide = () => {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
const switchFilter = (filterKey: string) => {
|
||||
activeFilter.value = filterKey
|
||||
}
|
||||
|
||||
const handleNotificationClick = (notification: NotificationItem) => {
|
||||
// 标记为已读
|
||||
if (!notification.read) {
|
||||
markAsRead(notification.id)
|
||||
}
|
||||
|
||||
// 处理点击事件
|
||||
switch (notification.type) {
|
||||
case 'message':
|
||||
// 跳转到聊天页面
|
||||
break
|
||||
case 'system':
|
||||
// 显示系统消息详情
|
||||
break
|
||||
case 'user':
|
||||
// 跳转到个人资料页面
|
||||
break
|
||||
}
|
||||
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
const markAsRead = (notificationId: string) => {
|
||||
const notification = notifications.value.find(n => n.id === notificationId)
|
||||
if (notification) {
|
||||
notification.read = true
|
||||
}
|
||||
}
|
||||
|
||||
const markAsUnread = (notificationId: string) => {
|
||||
const notification = notifications.value.find(n => n.id === notificationId)
|
||||
if (notification) {
|
||||
notification.read = false
|
||||
}
|
||||
}
|
||||
|
||||
const markAllAsRead = () => {
|
||||
notifications.value.forEach(n => {
|
||||
n.read = true
|
||||
})
|
||||
ElMessage.success('已标记全部为已读')
|
||||
}
|
||||
|
||||
const deleteNotification = (notificationId: string) => {
|
||||
const index = notifications.value.findIndex(n => n.id === notificationId)
|
||||
if (index > -1) {
|
||||
notifications.value.splice(index, 1)
|
||||
ElMessage.success('通知已删除')
|
||||
}
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
notifications.value = []
|
||||
ElMessage.success('已清空所有通知')
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
loading.value = true
|
||||
|
||||
// 模拟加载更多
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
hasMore.value = false
|
||||
ElMessage.info('没有更多通知了')
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const handleAction = (command: string) => {
|
||||
const [action, notificationId] = command.split('_')
|
||||
|
||||
switch (action) {
|
||||
case 'read':
|
||||
markAsRead(notificationId)
|
||||
break
|
||||
case 'unread':
|
||||
markAsUnread(notificationId)
|
||||
break
|
||||
case 'delete':
|
||||
deleteNotification(notificationId)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const getNotificationIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'message':
|
||||
return ChatDotRound
|
||||
case 'system':
|
||||
return Setting
|
||||
case 'user':
|
||||
return User
|
||||
case 'warning':
|
||||
return Warning
|
||||
case 'info':
|
||||
return InfoFilled
|
||||
default:
|
||||
return Bell
|
||||
}
|
||||
}
|
||||
|
||||
const getNotificationIconClass = (type: string) => {
|
||||
switch (type) {
|
||||
case 'message':
|
||||
return 'text-blue-500 bg-blue-100'
|
||||
case 'system':
|
||||
return 'text-green-500 bg-green-100'
|
||||
case 'user':
|
||||
return 'text-purple-500 bg-purple-100'
|
||||
case 'warning':
|
||||
return 'text-orange-500 bg-orange-100'
|
||||
case 'info':
|
||||
return 'text-gray-500 bg-gray-100'
|
||||
default:
|
||||
return 'text-gray-500 bg-gray-100'
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
return formatRelativeTime(timestamp)
|
||||
}
|
||||
|
||||
// 点击外部关闭
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (!target.closest('.notification-center') && !target.closest('.notification-popover')) {
|
||||
visible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notification-trigger {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.notification-header {
|
||||
@apply pb-3 border-b border-gray-200 mb-3;
|
||||
}
|
||||
|
||||
.notification-filters {
|
||||
@apply mb-3;
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
@apply flex space-x-1;
|
||||
}
|
||||
|
||||
.filter-tab {
|
||||
@apply px-3 py-1 text-sm rounded-full cursor-pointer transition-colors flex items-center space-x-1;
|
||||
@apply text-gray-600 hover:bg-gray-100;
|
||||
}
|
||||
|
||||
.filter-tab.active {
|
||||
@apply bg-blue-100 text-blue-600;
|
||||
}
|
||||
|
||||
.filter-count {
|
||||
@apply bg-current text-white rounded-full px-1.5 py-0.5 text-xs min-w-[1.25rem] text-center;
|
||||
}
|
||||
|
||||
.notification-list {
|
||||
@apply max-h-96 overflow-y-auto;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
@apply text-center py-8;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
@apply mb-3;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
@apply text-gray-500 text-sm;
|
||||
}
|
||||
|
||||
.notification-items {
|
||||
@apply space-y-1;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
@apply flex items-start space-x-3 p-3 rounded-lg cursor-pointer transition-colors;
|
||||
@apply hover:bg-gray-50;
|
||||
}
|
||||
|
||||
.notification-item.unread {
|
||||
@apply bg-blue-50 border-l-4 border-blue-500;
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
@apply flex-shrink-0;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
@apply w-8 h-8 rounded-full flex items-center justify-center;
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
@apply flex-1 min-w-0;
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
@apply text-sm font-medium text-gray-900 mb-1;
|
||||
}
|
||||
|
||||
.notification-message {
|
||||
@apply text-sm text-gray-600 mb-1 line-clamp-2;
|
||||
}
|
||||
|
||||
.notification-time {
|
||||
@apply text-xs text-gray-500;
|
||||
}
|
||||
|
||||
.notification-actions {
|
||||
@apply flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity;
|
||||
}
|
||||
|
||||
.notification-item:hover .notification-actions {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
.notification-footer {
|
||||
@apply text-center pt-3 border-t border-gray-200 mt-3;
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
.notification-list::-webkit-scrollbar {
|
||||
@apply w-1;
|
||||
}
|
||||
|
||||
.notification-list::-webkit-scrollbar-track {
|
||||
@apply bg-gray-100 rounded;
|
||||
}
|
||||
|
||||
.notification-list::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-300 rounded hover:bg-gray-400;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.notification-popover {
|
||||
padding: 16px !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,373 +0,0 @@
|
||||
<template>
|
||||
<div class="file-upload">
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
:action="uploadUrl"
|
||||
:headers="uploadHeaders"
|
||||
:data="uploadData"
|
||||
:multiple="multiple"
|
||||
:accept="accept"
|
||||
:limit="limit"
|
||||
:file-list="fileList"
|
||||
:before-upload="handleBeforeUpload"
|
||||
:on-progress="handleProgress"
|
||||
:on-success="handleSuccess"
|
||||
:on-error="handleError"
|
||||
:on-remove="handleRemove"
|
||||
:on-exceed="handleExceed"
|
||||
:auto-upload="autoUpload"
|
||||
:show-file-list="showFileList"
|
||||
:drag="drag"
|
||||
:disabled="disabled"
|
||||
class="upload-component"
|
||||
>
|
||||
<!-- 拖拽上传区域 -->
|
||||
<div v-if="drag" class="upload-dragger">
|
||||
<el-icon class="upload-icon">
|
||||
<UploadFilled />
|
||||
</el-icon>
|
||||
<div class="upload-text">
|
||||
<p class="upload-title">将文件拖拽到此处,或<em>点击上传</em></p>
|
||||
<p class="upload-hint">{{ uploadHint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 按钮上传 -->
|
||||
<template v-else>
|
||||
<el-button v-if="!hideButton" :type="buttonType" :disabled="disabled">
|
||||
<el-icon class="mr-2">
|
||||
<component :is="buttonIcon" />
|
||||
</el-icon>
|
||||
{{ buttonText }}
|
||||
</el-button>
|
||||
|
||||
<!-- 自定义触发器 -->
|
||||
<slot v-else name="trigger" />
|
||||
</template>
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<template #tip>
|
||||
<div v-if="showTip" class="upload-tip">
|
||||
<p class="text-sm text-gray-500">{{ uploadHint }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
|
||||
<!-- 上传进度 -->
|
||||
<div v-if="uploading && showProgress" class="upload-progress mt-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm text-gray-600">上传进度</span>
|
||||
<span class="text-sm text-gray-600">{{ uploadPercent }}%</span>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="uploadPercent"
|
||||
:status="uploadStatus"
|
||||
:stroke-width="6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 文件列表 -->
|
||||
<div v-if="!showFileList && fileList.length > 0" class="file-list mt-4">
|
||||
<div
|
||||
v-for="(file, index) in fileList"
|
||||
:key="file.uid"
|
||||
class="file-item flex items-center justify-between p-3 bg-gray-50 rounded-lg mb-2"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<el-icon class="text-gray-400">
|
||||
<Document />
|
||||
</el-icon>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ file.name }}</p>
|
||||
<p class="text-xs text-gray-500">{{ formatFileSize(file.size) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<el-button
|
||||
v-if="file.status === 'ready' && !autoUpload"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="uploadFile(file)"
|
||||
>
|
||||
上传
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="removeFile(index)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { UploadFilled, Upload, Document, Picture } from '@element-plus/icons-vue'
|
||||
import type { UploadInstance, UploadProps, UploadUserFile, UploadFile } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { UPLOAD_CONFIG } from '@/config/constants'
|
||||
import { formatFileSize } from '@/utils/format'
|
||||
|
||||
interface Props {
|
||||
// 上传配置
|
||||
action?: string
|
||||
multiple?: boolean
|
||||
accept?: string
|
||||
limit?: number
|
||||
maxSize?: number
|
||||
autoUpload?: boolean
|
||||
|
||||
// 显示配置
|
||||
drag?: boolean
|
||||
showFileList?: boolean
|
||||
showProgress?: boolean
|
||||
showTip?: boolean
|
||||
hideButton?: boolean
|
||||
|
||||
// 按钮配置
|
||||
buttonText?: string
|
||||
buttonType?: 'primary' | 'success' | 'warning' | 'danger' | 'info'
|
||||
buttonIcon?: any
|
||||
|
||||
// 状态
|
||||
disabled?: boolean
|
||||
|
||||
// 文件类型
|
||||
fileType?: 'image' | 'document' | 'video' | 'audio' | 'any'
|
||||
|
||||
// 自定义数据
|
||||
data?: Record<string, any>
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
action: '/api/upload',
|
||||
multiple: false,
|
||||
accept: '',
|
||||
limit: 5,
|
||||
maxSize: 10 * 1024 * 1024, // 10MB
|
||||
autoUpload: true,
|
||||
drag: false,
|
||||
showFileList: true,
|
||||
showProgress: true,
|
||||
showTip: true,
|
||||
hideButton: false,
|
||||
buttonText: '选择文件',
|
||||
buttonType: 'primary',
|
||||
buttonIcon: Upload,
|
||||
disabled: false,
|
||||
fileType: 'any',
|
||||
data: () => ({})
|
||||
})
|
||||
|
||||
interface Emits {
|
||||
(e: 'success', response: any, file: UploadFile): void
|
||||
(e: 'error', error: any, file: UploadFile): void
|
||||
(e: 'progress', event: any, file: UploadFile): void
|
||||
(e: 'remove', file: UploadFile): void
|
||||
(e: 'change', fileList: UploadUserFile[]): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// 状态管理
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 响应式数据
|
||||
const uploadRef = ref<UploadInstance>()
|
||||
const fileList = ref<UploadUserFile[]>([])
|
||||
const uploading = ref(false)
|
||||
const uploadPercent = ref(0)
|
||||
const uploadStatus = ref<'success' | 'exception' | undefined>()
|
||||
|
||||
// 计算属性
|
||||
const uploadUrl = computed(() => {
|
||||
return props.action || UPLOAD_CONFIG.DEFAULT_UPLOAD_URL
|
||||
})
|
||||
|
||||
const uploadHeaders = computed(() => {
|
||||
return {
|
||||
Authorization: `Bearer ${authStore.token}`,
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
|
||||
const uploadData = computed(() => {
|
||||
return {
|
||||
type: props.fileType,
|
||||
...props.data
|
||||
}
|
||||
})
|
||||
|
||||
const acceptTypes = computed(() => {
|
||||
if (props.accept) return props.accept
|
||||
|
||||
switch (props.fileType) {
|
||||
case 'image':
|
||||
return UPLOAD_CONFIG.IMAGE_TYPES.join(',')
|
||||
case 'document':
|
||||
return UPLOAD_CONFIG.DOCUMENT_TYPES.join(',')
|
||||
case 'video':
|
||||
return UPLOAD_CONFIG.VIDEO_TYPES.join(',')
|
||||
case 'audio':
|
||||
return UPLOAD_CONFIG.AUDIO_TYPES.join(',')
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
const uploadHint = computed(() => {
|
||||
const sizeText = formatFileSize(props.maxSize)
|
||||
const limitText = props.limit > 1 ? `最多${props.limit}个文件,` : ''
|
||||
|
||||
switch (props.fileType) {
|
||||
case 'image':
|
||||
return `${limitText}支持 JPG、PNG、GIF 格式,单个文件不超过 ${sizeText}`
|
||||
case 'document':
|
||||
return `${limitText}支持 PDF、DOC、DOCX、XLS、XLSX 格式,单个文件不超过 ${sizeText}`
|
||||
case 'video':
|
||||
return `${limitText}支持 MP4、AVI、MOV 格式,单个文件不超过 ${sizeText}`
|
||||
case 'audio':
|
||||
return `${limitText}支持 MP3、WAV、AAC 格式,单个文件不超过 ${sizeText}`
|
||||
default:
|
||||
return `${limitText}单个文件不超过 ${sizeText}`
|
||||
}
|
||||
})
|
||||
|
||||
// 方法
|
||||
const handleBeforeUpload = (file: File) => {
|
||||
// 检查文件类型
|
||||
if (acceptTypes.value && !isValidFileType(file)) {
|
||||
ElMessage.error('文件类型不支持')
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查文件大小
|
||||
if (file.size > props.maxSize) {
|
||||
ElMessage.error(`文件大小不能超过 ${formatFileSize(props.maxSize)}`)
|
||||
return false
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
uploadPercent.value = 0
|
||||
uploadStatus.value = undefined
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const handleProgress = (event: any, file: UploadFile) => {
|
||||
uploadPercent.value = Math.round(event.percent)
|
||||
emit('progress', event, file)
|
||||
}
|
||||
|
||||
const handleSuccess = (response: any, file: UploadFile) => {
|
||||
uploading.value = false
|
||||
uploadPercent.value = 100
|
||||
uploadStatus.value = 'success'
|
||||
|
||||
ElMessage.success('文件上传成功')
|
||||
emit('success', response, file)
|
||||
}
|
||||
|
||||
const handleError = (error: any, file: UploadFile) => {
|
||||
uploading.value = false
|
||||
uploadStatus.value = 'exception'
|
||||
|
||||
console.error('文件上传失败:', error)
|
||||
ElMessage.error('文件上传失败,请重试')
|
||||
emit('error', error, file)
|
||||
}
|
||||
|
||||
const handleRemove = (file: UploadFile) => {
|
||||
emit('remove', file)
|
||||
}
|
||||
|
||||
const handleExceed = () => {
|
||||
ElMessage.warning(`最多只能上传 ${props.limit} 个文件`)
|
||||
}
|
||||
|
||||
const isValidFileType = (file: File): boolean => {
|
||||
if (!acceptTypes.value) return true
|
||||
|
||||
const types = acceptTypes.value.split(',').map(type => type.trim())
|
||||
return types.some(type => {
|
||||
if (type.includes('*')) {
|
||||
const [mainType] = type.split('/')
|
||||
return file.type.startsWith(mainType + '/')
|
||||
}
|
||||
return file.type === type
|
||||
})
|
||||
}
|
||||
|
||||
const uploadFile = (file: UploadUserFile) => {
|
||||
uploadRef.value?.submit()
|
||||
}
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
fileList.value.splice(index, 1)
|
||||
emit('change', fileList.value)
|
||||
}
|
||||
|
||||
const clearFiles = () => {
|
||||
uploadRef.value?.clearFiles()
|
||||
fileList.value = []
|
||||
emit('change', fileList.value)
|
||||
}
|
||||
|
||||
// 监听文件列表变化
|
||||
watch(fileList, (newList) => {
|
||||
emit('change', newList)
|
||||
}, { deep: true })
|
||||
|
||||
// 暴露方法
|
||||
defineExpose({
|
||||
clearFiles,
|
||||
uploadFile,
|
||||
removeFile
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.upload-dragger {
|
||||
@apply border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-blue-400 transition-colors;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
@apply text-4xl text-gray-400 mb-4;
|
||||
}
|
||||
|
||||
.upload-title {
|
||||
@apply text-lg text-gray-600 mb-2;
|
||||
}
|
||||
|
||||
.upload-title em {
|
||||
@apply text-blue-500 not-italic;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
@apply text-sm text-gray-500;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.file-item:hover {
|
||||
@apply bg-gray-100;
|
||||
}
|
||||
|
||||
:deep(.el-upload-dragger) {
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
:deep(.el-upload-dragger:hover) {
|
||||
border: none !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,471 +0,0 @@
|
||||
<template>
|
||||
<div class="image-upload">
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
:action="uploadUrl"
|
||||
:headers="uploadHeaders"
|
||||
:data="uploadData"
|
||||
:multiple="multiple"
|
||||
:accept="acceptTypes"
|
||||
:limit="limit"
|
||||
:file-list="fileList"
|
||||
:before-upload="handleBeforeUpload"
|
||||
:on-success="handleSuccess"
|
||||
:on-error="handleError"
|
||||
:on-remove="handleRemove"
|
||||
:on-exceed="handleExceed"
|
||||
:auto-upload="autoUpload"
|
||||
:show-file-list="false"
|
||||
:disabled="disabled"
|
||||
list-type="picture-card"
|
||||
class="image-upload-component"
|
||||
>
|
||||
<!-- 上传按钮 -->
|
||||
<div v-if="fileList.length < limit" class="upload-trigger">
|
||||
<el-icon class="upload-icon">
|
||||
<Plus />
|
||||
</el-icon>
|
||||
<div class="upload-text">{{ buttonText }}</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
|
||||
<!-- 图片预览列表 -->
|
||||
<div v-if="fileList.length > 0" class="image-list">
|
||||
<div
|
||||
v-for="(file, index) in fileList"
|
||||
:key="file.uid || index"
|
||||
class="image-item"
|
||||
>
|
||||
<div class="image-wrapper">
|
||||
<img
|
||||
:src="getImageUrl(file)"
|
||||
:alt="file.name"
|
||||
class="image-preview"
|
||||
@click="previewImage(file, index)"
|
||||
/>
|
||||
|
||||
<!-- 遮罩层 -->
|
||||
<div class="image-overlay">
|
||||
<div class="overlay-actions">
|
||||
<el-button
|
||||
circle
|
||||
size="small"
|
||||
@click="previewImage(file, index)"
|
||||
>
|
||||
<el-icon><ZoomIn /></el-icon>
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
v-if="!disabled"
|
||||
circle
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="removeImage(index)"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上传进度 -->
|
||||
<div v-if="file.status === 'uploading'" class="upload-progress">
|
||||
<el-progress
|
||||
type="circle"
|
||||
:percentage="file.percentage || 0"
|
||||
:width="40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 上传状态 -->
|
||||
<div v-if="file.status === 'success'" class="upload-status success">
|
||||
<el-icon><Check /></el-icon>
|
||||
</div>
|
||||
|
||||
<div v-if="file.status === 'fail'" class="upload-status error">
|
||||
<el-icon><Close /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图片信息 -->
|
||||
<div v-if="showInfo" class="image-info">
|
||||
<p class="image-name">{{ file.name }}</p>
|
||||
<p class="image-size">{{ formatFileSize(file.size) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图片预览对话框 -->
|
||||
<el-dialog
|
||||
v-model="previewVisible"
|
||||
title="图片预览"
|
||||
width="80%"
|
||||
:close-on-click-modal="true"
|
||||
append-to-body
|
||||
>
|
||||
<div class="preview-container">
|
||||
<img
|
||||
v-if="previewUrl"
|
||||
:src="previewUrl"
|
||||
:alt="previewName"
|
||||
class="preview-image"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="preview-footer">
|
||||
<el-button @click="previewVisible = false">关闭</el-button>
|
||||
<el-button type="primary" @click="downloadImage">下载</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus, ZoomIn, Delete, Check, Close } from '@element-plus/icons-vue'
|
||||
import type { UploadInstance, UploadUserFile, UploadFile } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { UPLOAD_CONFIG } from '@/config/constants'
|
||||
import { formatFileSize } from '@/utils/format'
|
||||
|
||||
interface Props {
|
||||
// 上传配置
|
||||
action?: string
|
||||
multiple?: boolean
|
||||
limit?: number
|
||||
maxSize?: number
|
||||
autoUpload?: boolean
|
||||
|
||||
// 显示配置
|
||||
showInfo?: boolean
|
||||
buttonText?: string
|
||||
|
||||
// 状态
|
||||
disabled?: boolean
|
||||
|
||||
// 图片尺寸限制
|
||||
minWidth?: number
|
||||
minHeight?: number
|
||||
maxWidth?: number
|
||||
maxHeight?: number
|
||||
|
||||
// 自定义数据
|
||||
data?: Record<string, any>
|
||||
|
||||
// 默认图片列表
|
||||
defaultFileList?: UploadUserFile[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
action: '/api/upload/image',
|
||||
multiple: true,
|
||||
limit: 9,
|
||||
maxSize: 5 * 1024 * 1024, // 5MB
|
||||
autoUpload: true,
|
||||
showInfo: false,
|
||||
buttonText: '上传图片',
|
||||
disabled: false,
|
||||
data: () => ({}),
|
||||
defaultFileList: () => []
|
||||
})
|
||||
|
||||
interface Emits {
|
||||
(e: 'success', response: any, file: UploadFile): void
|
||||
(e: 'error', error: any, file: UploadFile): void
|
||||
(e: 'remove', file: UploadFile, index: number): void
|
||||
(e: 'change', fileList: UploadUserFile[]): void
|
||||
(e: 'preview', file: UploadUserFile, index: number): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// 状态管理
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 响应式数据
|
||||
const uploadRef = ref<UploadInstance>()
|
||||
const fileList = ref<UploadUserFile[]>([...props.defaultFileList])
|
||||
const previewVisible = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const previewName = ref('')
|
||||
const currentPreviewIndex = ref(0)
|
||||
|
||||
// 计算属性
|
||||
const uploadUrl = computed(() => {
|
||||
return props.action || UPLOAD_CONFIG.IMAGE_UPLOAD_URL
|
||||
})
|
||||
|
||||
const uploadHeaders = computed(() => {
|
||||
return {
|
||||
Authorization: `Bearer ${authStore.token}`,
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
|
||||
const uploadData = computed(() => {
|
||||
return {
|
||||
type: 'image',
|
||||
...props.data
|
||||
}
|
||||
})
|
||||
|
||||
const acceptTypes = computed(() => {
|
||||
return UPLOAD_CONFIG.IMAGE_TYPES.join(',')
|
||||
})
|
||||
|
||||
// 方法
|
||||
const handleBeforeUpload = async (file: File) => {
|
||||
// 检查文件类型
|
||||
if (!isValidImageType(file)) {
|
||||
ElMessage.error('只支持 JPG、PNG、GIF 格式的图片')
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查文件大小
|
||||
if (file.size > props.maxSize) {
|
||||
ElMessage.error(`图片大小不能超过 ${formatFileSize(props.maxSize)}`)
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查图片尺寸
|
||||
if (props.minWidth || props.minHeight || props.maxWidth || props.maxHeight) {
|
||||
const isValidSize = await validateImageSize(file)
|
||||
if (!isValidSize) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const handleSuccess = (response: any, file: UploadFile) => {
|
||||
ElMessage.success('图片上传成功')
|
||||
|
||||
// 更新文件列表中的URL
|
||||
const index = fileList.value.findIndex(item => item.uid === file.uid)
|
||||
if (index > -1) {
|
||||
fileList.value[index].url = response.url
|
||||
fileList.value[index].status = 'success'
|
||||
}
|
||||
|
||||
emit('success', response, file)
|
||||
emit('change', fileList.value)
|
||||
}
|
||||
|
||||
const handleError = (error: any, file: UploadFile) => {
|
||||
console.error('图片上传失败:', error)
|
||||
ElMessage.error('图片上传失败,请重试')
|
||||
|
||||
// 更新文件状态
|
||||
const index = fileList.value.findIndex(item => item.uid === file.uid)
|
||||
if (index > -1) {
|
||||
fileList.value[index].status = 'fail'
|
||||
}
|
||||
|
||||
emit('error', error, file)
|
||||
}
|
||||
|
||||
const handleRemove = (file: UploadFile) => {
|
||||
const index = fileList.value.findIndex(item => item.uid === file.uid)
|
||||
if (index > -1) {
|
||||
fileList.value.splice(index, 1)
|
||||
emit('remove', file, index)
|
||||
emit('change', fileList.value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExceed = () => {
|
||||
ElMessage.warning(`最多只能上传 ${props.limit} 张图片`)
|
||||
}
|
||||
|
||||
const removeImage = (index: number) => {
|
||||
const file = fileList.value[index]
|
||||
fileList.value.splice(index, 1)
|
||||
emit('remove', file as UploadFile, index)
|
||||
emit('change', fileList.value)
|
||||
}
|
||||
|
||||
const previewImage = (file: UploadUserFile, index: number) => {
|
||||
previewUrl.value = getImageUrl(file)
|
||||
previewName.value = file.name || '图片预览'
|
||||
currentPreviewIndex.value = index
|
||||
previewVisible.value = true
|
||||
|
||||
emit('preview', file, index)
|
||||
}
|
||||
|
||||
const getImageUrl = (file: UploadUserFile): string => {
|
||||
if (file.url) {
|
||||
return file.url
|
||||
}
|
||||
|
||||
if (file.raw) {
|
||||
return URL.createObjectURL(file.raw)
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const isValidImageType = (file: File): boolean => {
|
||||
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
||||
return validTypes.includes(file.type)
|
||||
}
|
||||
|
||||
const validateImageSize = (file: File): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image()
|
||||
const url = URL.createObjectURL(file)
|
||||
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
const { width, height } = img
|
||||
let valid = true
|
||||
let message = ''
|
||||
|
||||
if (props.minWidth && width < props.minWidth) {
|
||||
valid = false
|
||||
message = `图片宽度不能小于 ${props.minWidth}px`
|
||||
} else if (props.maxWidth && width > props.maxWidth) {
|
||||
valid = false
|
||||
message = `图片宽度不能大于 ${props.maxWidth}px`
|
||||
} else if (props.minHeight && height < props.minHeight) {
|
||||
valid = false
|
||||
message = `图片高度不能小于 ${props.minHeight}px`
|
||||
} else if (props.maxHeight && height > props.maxHeight) {
|
||||
valid = false
|
||||
message = `图片高度不能大于 ${props.maxHeight}px`
|
||||
}
|
||||
|
||||
if (!valid) {
|
||||
ElMessage.error(message)
|
||||
}
|
||||
|
||||
resolve(valid)
|
||||
}
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
ElMessage.error('图片格式不正确')
|
||||
resolve(false)
|
||||
}
|
||||
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
|
||||
const downloadImage = () => {
|
||||
if (previewUrl.value) {
|
||||
const link = document.createElement('a')
|
||||
link.href = previewUrl.value
|
||||
link.download = previewName.value
|
||||
link.click()
|
||||
}
|
||||
}
|
||||
|
||||
const clearImages = () => {
|
||||
uploadRef.value?.clearFiles()
|
||||
fileList.value = []
|
||||
emit('change', fileList.value)
|
||||
}
|
||||
|
||||
// 暴露方法
|
||||
defineExpose({
|
||||
clearImages,
|
||||
removeImage,
|
||||
previewImage
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-upload-component {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.upload-trigger {
|
||||
@apply w-full h-full flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 transition-colors cursor-pointer;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
@apply text-2xl text-gray-400 mb-2;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
@apply text-sm text-gray-500;
|
||||
}
|
||||
|
||||
.image-list {
|
||||
@apply grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mt-4;
|
||||
}
|
||||
|
||||
.image-item {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
.image-wrapper {
|
||||
@apply relative overflow-hidden rounded-lg border border-gray-200 aspect-square;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
@apply w-full h-full object-cover cursor-pointer;
|
||||
}
|
||||
|
||||
.image-overlay {
|
||||
@apply absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity;
|
||||
}
|
||||
|
||||
.overlay-actions {
|
||||
@apply flex space-x-2;
|
||||
}
|
||||
|
||||
.upload-progress {
|
||||
@apply absolute inset-0 flex items-center justify-center bg-white bg-opacity-80;
|
||||
}
|
||||
|
||||
.upload-status {
|
||||
@apply absolute top-2 right-2 w-6 h-6 rounded-full flex items-center justify-center text-white text-sm;
|
||||
}
|
||||
|
||||
.upload-status.success {
|
||||
@apply bg-green-500;
|
||||
}
|
||||
|
||||
.upload-status.error {
|
||||
@apply bg-red-500;
|
||||
}
|
||||
|
||||
.image-info {
|
||||
@apply mt-2 text-center;
|
||||
}
|
||||
|
||||
.image-name {
|
||||
@apply text-sm text-gray-900 truncate;
|
||||
}
|
||||
|
||||
.image-size {
|
||||
@apply text-xs text-gray-500;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
@apply text-center;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
@apply max-w-full max-h-96 mx-auto;
|
||||
}
|
||||
|
||||
.preview-footer {
|
||||
@apply flex justify-center space-x-4;
|
||||
}
|
||||
|
||||
:deep(.el-upload--picture-card) {
|
||||
@apply w-full h-auto;
|
||||
}
|
||||
|
||||
:deep(.el-upload-list--picture-card) {
|
||||
@apply hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -1,307 +0,0 @@
|
||||
/**
|
||||
* 聊天功能组合式API
|
||||
* 管理WebSocket连接、消息发送接收等
|
||||
*/
|
||||
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getWebSocketInstance } from '@/utils/websocket'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { WS_SUBSCRIBE_PATHS, WS_SEND_PATHS, MESSAGE_TYPES, SENDER_TYPES } from '@/config/constants'
|
||||
import type { MessageInfo, WSChatMessage } from '@/types/api'
|
||||
|
||||
export function useChat() {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 响应式数据
|
||||
const messages = ref<MessageInfo[]>([])
|
||||
const isConnected = ref(false)
|
||||
const isTyping = ref(false)
|
||||
const currentConversationId = ref<string>('')
|
||||
const connectionState = ref('DISCONNECTED')
|
||||
|
||||
// WebSocket实例
|
||||
let wsInstance: any = null
|
||||
let unsubscribeUserMessages: (() => void) | null = null
|
||||
let unsubscribeChatRoom: (() => void) | null = null
|
||||
|
||||
// 计算属性
|
||||
const sortedMessages = computed(() => {
|
||||
return [...messages.value].sort((a, b) => a.timestamp - b.timestamp)
|
||||
})
|
||||
|
||||
const lastMessage = computed(() => {
|
||||
return sortedMessages.value[sortedMessages.value.length - 1]
|
||||
})
|
||||
|
||||
const messageCount = computed(() => messages.value.length)
|
||||
|
||||
/**
|
||||
* 初始化WebSocket连接
|
||||
*/
|
||||
const initializeWebSocket = () => {
|
||||
if (!authStore.token || !authStore.userId) {
|
||||
console.warn('⚠️ 用户未登录,无法建立WebSocket连接')
|
||||
return
|
||||
}
|
||||
|
||||
wsInstance = getWebSocketInstance({
|
||||
onConnect: () => {
|
||||
isConnected.value = true
|
||||
connectionState.value = 'CONNECTED'
|
||||
console.log('✅ 聊天WebSocket连接成功')
|
||||
|
||||
// 订阅个人消息
|
||||
subscribeToUserMessages()
|
||||
|
||||
// 订阅聊天室消息
|
||||
subscribeToChatRoom()
|
||||
},
|
||||
|
||||
onDisconnect: () => {
|
||||
isConnected.value = false
|
||||
connectionState.value = 'DISCONNECTED'
|
||||
console.log('❌ 聊天WebSocket连接断开')
|
||||
},
|
||||
|
||||
onError: (error) => {
|
||||
connectionState.value = 'ERROR'
|
||||
console.error('❌ 聊天WebSocket错误:', error)
|
||||
ElMessage.error('聊天连接出现问题,请刷新页面重试')
|
||||
},
|
||||
|
||||
onTokenExpired: () => {
|
||||
ElMessage.warning('登录已过期,请重新登录')
|
||||
authStore.logout()
|
||||
},
|
||||
|
||||
onReconnect: (attempt) => {
|
||||
connectionState.value = 'CONNECTING'
|
||||
console.log(`🔄 聊天WebSocket重连中... (第${attempt}次)`)
|
||||
}
|
||||
})
|
||||
|
||||
// 连接WebSocket
|
||||
wsInstance.connect(authStore.token)
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅个人消息
|
||||
*/
|
||||
const subscribeToUserMessages = () => {
|
||||
if (!wsInstance || !authStore.userId) return
|
||||
|
||||
const destination = WS_SUBSCRIBE_PATHS.USER_MESSAGES(authStore.userId)
|
||||
|
||||
unsubscribeUserMessages = wsInstance.subscribe(destination, (message: MessageInfo) => {
|
||||
handleIncomingMessage(message)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅聊天室消息
|
||||
*/
|
||||
const subscribeToChatRoom = () => {
|
||||
if (!wsInstance) return
|
||||
|
||||
unsubscribeChatRoom = wsInstance.subscribe(WS_SUBSCRIBE_PATHS.CHAT_ROOM, (message: MessageInfo) => {
|
||||
handleIncomingMessage(message)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理接收到的消息
|
||||
*/
|
||||
const handleIncomingMessage = (message: MessageInfo) => {
|
||||
// 检查是否已存在该消息(避免重复)
|
||||
const existingMessage = messages.value.find(m => m.id === message.id)
|
||||
if (existingMessage) return
|
||||
|
||||
// 添加到消息列表
|
||||
messages.value.push(message)
|
||||
|
||||
// 如果是AI回复,显示通知
|
||||
if (message.senderType === SENDER_TYPES.AI) {
|
||||
ElMessage.success('收到AI回复')
|
||||
}
|
||||
|
||||
console.log('📨 收到新消息:', message)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
*/
|
||||
const sendMessage = (content: string, type: string = MESSAGE_TYPES.TEXT, metadata?: any) => {
|
||||
if (!wsInstance || !isConnected.value) {
|
||||
ElMessage.error('连接已断开,无法发送消息')
|
||||
return
|
||||
}
|
||||
|
||||
if (!content.trim()) {
|
||||
ElMessage.warning('消息内容不能为空')
|
||||
return
|
||||
}
|
||||
|
||||
const messageData: WSChatMessage = {
|
||||
conversationId: currentConversationId.value,
|
||||
content: content.trim(),
|
||||
type: type as any,
|
||||
metadata
|
||||
}
|
||||
|
||||
try {
|
||||
wsInstance.send(WS_SEND_PATHS.CHAT_SEND, messageData)
|
||||
|
||||
// 添加到本地消息列表(乐观更新)
|
||||
const localMessage: MessageInfo = {
|
||||
id: `temp_${Date.now()}`,
|
||||
conversationId: currentConversationId.value,
|
||||
content: content.trim(),
|
||||
type: type as any,
|
||||
senderId: authStore.userId!,
|
||||
senderType: SENDER_TYPES.USER,
|
||||
senderName: authStore.nickname!,
|
||||
senderAvatar: authStore.avatar,
|
||||
status: 'sending',
|
||||
timestamp: Date.now(),
|
||||
metadata
|
||||
}
|
||||
|
||||
messages.value.push(localMessage)
|
||||
|
||||
console.log('📤 发送消息:', messageData)
|
||||
} catch (error) {
|
||||
console.error('❌ 发送消息失败:', error)
|
||||
ElMessage.error('发送消息失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送图片消息
|
||||
*/
|
||||
const sendImageMessage = (imageUrl: string, metadata?: any) => {
|
||||
sendMessage(imageUrl, MESSAGE_TYPES.IMAGE, {
|
||||
...metadata,
|
||||
imageUrl
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送文件消息
|
||||
*/
|
||||
const sendFileMessage = (fileUrl: string, fileName: string, fileSize: number) => {
|
||||
sendMessage(fileUrl, MESSAGE_TYPES.FILE, {
|
||||
fileUrl,
|
||||
fileName,
|
||||
fileSize
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送表情消息
|
||||
*/
|
||||
const sendEmojiMessage = (emoji: string) => {
|
||||
sendMessage(emoji, MESSAGE_TYPES.EMOJI)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送正在输入状态
|
||||
*/
|
||||
const sendTypingStatus = (isTyping: boolean) => {
|
||||
if (!wsInstance || !isConnected.value) return
|
||||
|
||||
wsInstance.send(WS_SEND_PATHS.TYPING, {
|
||||
conversationId: currentConversationId.value,
|
||||
isTyping
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前会话ID
|
||||
*/
|
||||
const setConversationId = (conversationId: string) => {
|
||||
currentConversationId.value = conversationId
|
||||
// 清空之前的消息
|
||||
messages.value = []
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空消息
|
||||
*/
|
||||
const clearMessages = () => {
|
||||
messages.value = []
|
||||
}
|
||||
|
||||
/**
|
||||
* 重连WebSocket
|
||||
*/
|
||||
const reconnect = () => {
|
||||
if (wsInstance) {
|
||||
wsInstance.disconnect()
|
||||
}
|
||||
setTimeout(() => {
|
||||
initializeWebSocket()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开WebSocket连接
|
||||
*/
|
||||
const disconnect = () => {
|
||||
// 取消订阅
|
||||
if (unsubscribeUserMessages) {
|
||||
unsubscribeUserMessages()
|
||||
unsubscribeUserMessages = null
|
||||
}
|
||||
|
||||
if (unsubscribeChatRoom) {
|
||||
unsubscribeChatRoom()
|
||||
unsubscribeChatRoom = null
|
||||
}
|
||||
|
||||
// 断开连接
|
||||
if (wsInstance) {
|
||||
wsInstance.disconnect()
|
||||
wsInstance = null
|
||||
}
|
||||
|
||||
isConnected.value = false
|
||||
connectionState.value = 'DISCONNECTED'
|
||||
}
|
||||
|
||||
// 生命周期钩子
|
||||
onMounted(() => {
|
||||
if (authStore.isLoggedIn) {
|
||||
initializeWebSocket()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
disconnect()
|
||||
})
|
||||
|
||||
return {
|
||||
// 响应式数据
|
||||
messages: sortedMessages,
|
||||
isConnected,
|
||||
isTyping,
|
||||
connectionState,
|
||||
currentConversationId,
|
||||
|
||||
// 计算属性
|
||||
lastMessage,
|
||||
messageCount,
|
||||
|
||||
// 方法
|
||||
sendMessage,
|
||||
sendImageMessage,
|
||||
sendFileMessage,
|
||||
sendEmojiMessage,
|
||||
sendTypingStatus,
|
||||
setConversationId,
|
||||
clearMessages,
|
||||
reconnect,
|
||||
disconnect,
|
||||
initializeWebSocket
|
||||
}
|
||||
}
|
||||
@@ -1,380 +0,0 @@
|
||||
/**
|
||||
* 日记功能组合式API
|
||||
* 管理日记的创建、编辑、删除、草稿等功能
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { diaryApi } from '@/api/diary'
|
||||
import { STORAGE_KEYS } from '@/config/constants'
|
||||
import storage from '@/utils/storage'
|
||||
import type {
|
||||
DiaryPost,
|
||||
PublishDiaryRequest,
|
||||
GetUserDiariesRequest
|
||||
} from '@/types/api'
|
||||
|
||||
export function useDiary() {
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const publishing = ref(false)
|
||||
const diaries = ref<DiaryPost[]>([])
|
||||
const currentDiary = ref<DiaryPost | null>(null)
|
||||
const drafts = ref<DiaryPost[]>([])
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
// 计算属性
|
||||
const hasMore = computed(() => {
|
||||
return diaries.value.length < total.value
|
||||
})
|
||||
|
||||
const draftCount = computed(() => drafts.value.length)
|
||||
|
||||
/**
|
||||
* 获取用户日记列表
|
||||
*/
|
||||
const fetchUserDiaries = async (params: GetUserDiariesRequest = {}) => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
const requestParams = {
|
||||
page: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
...params
|
||||
}
|
||||
|
||||
const response = await diaryApi.getUserDiaries(requestParams)
|
||||
|
||||
if (currentPage.value === 1) {
|
||||
diaries.value = response.list
|
||||
} else {
|
||||
diaries.value.push(...response.list)
|
||||
}
|
||||
|
||||
total.value = response.total
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('获取日记列表失败:', error)
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载更多日记
|
||||
*/
|
||||
const loadMoreDiaries = async (params: GetUserDiariesRequest = {}) => {
|
||||
if (!hasMore.value || loading.value) return
|
||||
|
||||
currentPage.value++
|
||||
await fetchUserDiaries(params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新日记列表
|
||||
*/
|
||||
const refreshDiaries = async (params: GetUserDiariesRequest = {}) => {
|
||||
currentPage.value = 1
|
||||
await fetchUserDiaries(params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取日记详情
|
||||
*/
|
||||
const fetchDiaryDetail = async (diaryId: string) => {
|
||||
try {
|
||||
loading.value = true
|
||||
currentDiary.value = await diaryApi.getDiaryDetail(diaryId)
|
||||
return currentDiary.value
|
||||
} catch (error) {
|
||||
console.error('获取日记详情失败:', error)
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布日记
|
||||
*/
|
||||
const publishDiary = async (data: PublishDiaryRequest) => {
|
||||
try {
|
||||
// 验证必填字段
|
||||
if (!data.title.trim()) {
|
||||
throw new Error('请输入日记标题')
|
||||
}
|
||||
|
||||
if (!data.content.trim()) {
|
||||
throw new Error('请输入日记内容')
|
||||
}
|
||||
|
||||
if (!data.emotion) {
|
||||
throw new Error('请选择情绪类型')
|
||||
}
|
||||
|
||||
if (data.mood < 1 || data.mood > 10) {
|
||||
throw new Error('心情指数必须在1-10之间')
|
||||
}
|
||||
|
||||
publishing.value = true
|
||||
const diary = await diaryApi.publish(data)
|
||||
|
||||
// 添加到列表开头
|
||||
diaries.value.unshift(diary)
|
||||
total.value++
|
||||
|
||||
// 清除草稿
|
||||
clearDraft()
|
||||
|
||||
ElMessage.success('日记发布成功')
|
||||
return diary
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '日记发布失败')
|
||||
throw error
|
||||
} finally {
|
||||
publishing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新日记
|
||||
*/
|
||||
const updateDiary = async (diaryId: string, data: Partial<PublishDiaryRequest>) => {
|
||||
try {
|
||||
const updatedDiary = await diaryApi.updateDiary(diaryId, data)
|
||||
|
||||
// 更新列表中的日记
|
||||
const index = diaries.value.findIndex(d => d.id === diaryId)
|
||||
if (index > -1) {
|
||||
diaries.value[index] = updatedDiary
|
||||
}
|
||||
|
||||
// 更新当前日记
|
||||
if (currentDiary.value?.id === diaryId) {
|
||||
currentDiary.value = updatedDiary
|
||||
}
|
||||
|
||||
ElMessage.success('日记更新成功')
|
||||
return updatedDiary
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '日记更新失败')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除日记
|
||||
*/
|
||||
const deleteDiary = async (diaryId: string) => {
|
||||
try {
|
||||
await diaryApi.deleteDiary(diaryId)
|
||||
|
||||
// 从列表中移除
|
||||
const index = diaries.value.findIndex(d => d.id === diaryId)
|
||||
if (index > -1) {
|
||||
diaries.value.splice(index, 1)
|
||||
total.value--
|
||||
}
|
||||
|
||||
// 清除当前日记
|
||||
if (currentDiary.value?.id === diaryId) {
|
||||
currentDiary.value = null
|
||||
}
|
||||
|
||||
ElMessage.success('日记删除成功')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '日记删除失败')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存草稿
|
||||
*/
|
||||
const saveDraft = async (data: Partial<PublishDiaryRequest>) => {
|
||||
try {
|
||||
// 本地保存草稿
|
||||
storage.set(STORAGE_KEYS.DRAFT_DIARY, data)
|
||||
|
||||
// 如果有标题和内容,保存到服务器
|
||||
if (data.title?.trim() || data.content?.trim()) {
|
||||
const draft = await diaryApi.saveDraft(data)
|
||||
|
||||
// 更新草稿列表
|
||||
const existingIndex = drafts.value.findIndex(d => d.id === draft.id)
|
||||
if (existingIndex > -1) {
|
||||
drafts.value[existingIndex] = draft
|
||||
} else {
|
||||
drafts.value.unshift(draft)
|
||||
}
|
||||
|
||||
ElMessage.success('草稿保存成功')
|
||||
return draft
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('保存草稿失败:', error)
|
||||
// 草稿保存失败不显示错误消息,静默处理
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取草稿列表
|
||||
*/
|
||||
const fetchDrafts = async () => {
|
||||
try {
|
||||
drafts.value = await diaryApi.getDrafts()
|
||||
return drafts.value
|
||||
} catch (error) {
|
||||
console.error('获取草稿列表失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本地草稿
|
||||
*/
|
||||
const getLocalDraft = (): Partial<PublishDiaryRequest> | null => {
|
||||
return storage.get(STORAGE_KEYS.DRAFT_DIARY)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除本地草稿
|
||||
*/
|
||||
const clearDraft = () => {
|
||||
storage.remove(STORAGE_KEYS.DRAFT_DIARY)
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动保存草稿
|
||||
*/
|
||||
const autoSaveDraft = (() => {
|
||||
let timer: NodeJS.Timeout | null = null
|
||||
|
||||
return (data: Partial<PublishDiaryRequest>) => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
timer = setTimeout(() => {
|
||||
saveDraft(data)
|
||||
}, 3000) // 3秒后自动保存
|
||||
}
|
||||
})()
|
||||
|
||||
/**
|
||||
* 搜索日记
|
||||
*/
|
||||
const searchDiaries = async (keyword: string, filters: Partial<GetUserDiariesRequest> = {}) => {
|
||||
const params: GetUserDiariesRequest = {
|
||||
keyword,
|
||||
...filters
|
||||
}
|
||||
|
||||
currentPage.value = 1
|
||||
await fetchUserDiaries(params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 筛选日记
|
||||
*/
|
||||
const filterDiaries = async (filters: Partial<GetUserDiariesRequest>) => {
|
||||
currentPage.value = 1
|
||||
await fetchUserDiaries(filters)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置状态
|
||||
*/
|
||||
const resetState = () => {
|
||||
diaries.value = []
|
||||
currentDiary.value = null
|
||||
drafts.value = []
|
||||
total.value = 0
|
||||
currentPage.value = 1
|
||||
loading.value = false
|
||||
publishing.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取情绪统计
|
||||
*/
|
||||
const getEmotionStats = () => {
|
||||
const stats: Record<string, number> = {}
|
||||
|
||||
diaries.value.forEach(diary => {
|
||||
if (diary.emotion) {
|
||||
stats[diary.emotion] = (stats[diary.emotion] || 0) + 1
|
||||
}
|
||||
})
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取心情趋势
|
||||
*/
|
||||
const getMoodTrend = (days = 7) => {
|
||||
const now = Date.now()
|
||||
const dayMs = 24 * 60 * 60 * 1000
|
||||
|
||||
const trend = []
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const date = new Date(now - i * dayMs)
|
||||
const dayDiaries = diaries.value.filter(diary => {
|
||||
const diaryDate = new Date(diary.createTime)
|
||||
return diaryDate.toDateString() === date.toDateString()
|
||||
})
|
||||
|
||||
const avgMood = dayDiaries.length > 0
|
||||
? dayDiaries.reduce((sum, diary) => sum + diary.mood, 0) / dayDiaries.length
|
||||
: 0
|
||||
|
||||
trend.push({
|
||||
date: date.toISOString().split('T')[0],
|
||||
mood: Math.round(avgMood * 10) / 10,
|
||||
count: dayDiaries.length
|
||||
})
|
||||
}
|
||||
|
||||
return trend
|
||||
}
|
||||
|
||||
return {
|
||||
// 响应式数据
|
||||
loading,
|
||||
publishing,
|
||||
diaries,
|
||||
currentDiary,
|
||||
drafts,
|
||||
total,
|
||||
currentPage,
|
||||
pageSize,
|
||||
|
||||
// 计算属性
|
||||
hasMore,
|
||||
draftCount,
|
||||
|
||||
// 方法
|
||||
fetchUserDiaries,
|
||||
loadMoreDiaries,
|
||||
refreshDiaries,
|
||||
fetchDiaryDetail,
|
||||
publishDiary,
|
||||
updateDiary,
|
||||
deleteDiary,
|
||||
saveDraft,
|
||||
fetchDrafts,
|
||||
getLocalDraft,
|
||||
clearDraft,
|
||||
autoSaveDraft,
|
||||
searchDiaries,
|
||||
filterDiaries,
|
||||
resetState,
|
||||
getEmotionStats,
|
||||
getMoodTrend
|
||||
}
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
/**
|
||||
* 用户功能组合式API
|
||||
* 管理用户资料、头像上传、密码修改等
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { userApi } from '@/api/user'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { UPLOAD_CONFIG } from '@/config/constants'
|
||||
import { validateEmail, validatePhone, validatePassword } from '@/utils/validation'
|
||||
import type {
|
||||
UserInfo,
|
||||
UpdateUserProfileRequest,
|
||||
ChangePasswordRequest,
|
||||
UserGrowthStats
|
||||
} from '@/types/api'
|
||||
|
||||
export function useUser() {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const uploading = ref(false)
|
||||
const userProfile = ref<UserInfo | null>(null)
|
||||
const growthStats = ref<UserGrowthStats | null>(null)
|
||||
|
||||
// 计算属性
|
||||
const currentUser = computed(() => authStore.user)
|
||||
const isProfileComplete = computed(() => {
|
||||
if (!userProfile.value) return false
|
||||
const { email, phone, bio } = userProfile.value
|
||||
return !!(email && phone && bio)
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取用户资料
|
||||
*/
|
||||
const fetchUserProfile = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
userProfile.value = await userApi.getProfile()
|
||||
return userProfile.value
|
||||
} catch (error) {
|
||||
console.error('获取用户资料失败:', error)
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户资料
|
||||
*/
|
||||
const updateUserProfile = async (data: UpdateUserProfileRequest) => {
|
||||
try {
|
||||
// 验证数据
|
||||
if (data.email && !validateEmail(data.email)) {
|
||||
throw new Error('邮箱格式不正确')
|
||||
}
|
||||
|
||||
if (data.phone && !validatePhone(data.phone)) {
|
||||
throw new Error('手机号格式不正确')
|
||||
}
|
||||
|
||||
const updatedUser = await userApi.updateProfile(data)
|
||||
|
||||
// 更新本地状态
|
||||
userProfile.value = updatedUser
|
||||
await authStore.updateUserInfo(updatedUser)
|
||||
|
||||
ElMessage.success('资料更新成功')
|
||||
return updatedUser
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '资料更新失败')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改密码
|
||||
*/
|
||||
const changePassword = async (data: ChangePasswordRequest) => {
|
||||
try {
|
||||
// 验证新密码
|
||||
if (!validatePassword(data.newPassword)) {
|
||||
throw new Error('新密码格式不正确,必须包含字母和数字,长度6-20位')
|
||||
}
|
||||
|
||||
if (data.newPassword !== data.confirmPassword) {
|
||||
throw new Error('两次输入的密码不一致')
|
||||
}
|
||||
|
||||
await userApi.changePassword(data)
|
||||
ElMessage.success('密码修改成功,请重新登录')
|
||||
|
||||
// 修改密码后需要重新登录
|
||||
setTimeout(() => {
|
||||
authStore.logout()
|
||||
}, 2000)
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '密码修改失败')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传头像
|
||||
*/
|
||||
const uploadAvatar = async (file: File) => {
|
||||
try {
|
||||
// 验证文件类型
|
||||
if (!UPLOAD_CONFIG.AVATAR_ALLOWED_TYPES.includes(file.type)) {
|
||||
throw new Error('只支持 JPG、PNG 格式的图片')
|
||||
}
|
||||
|
||||
// 验证文件大小
|
||||
if (file.size > UPLOAD_CONFIG.AVATAR_MAX_SIZE) {
|
||||
throw new Error('图片大小不能超过 2MB')
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
const response = await userApi.uploadAvatar(file)
|
||||
|
||||
// 更新用户头像
|
||||
if (userProfile.value) {
|
||||
userProfile.value.avatar = response.url
|
||||
}
|
||||
|
||||
await authStore.updateUserInfo({ avatar: response.url })
|
||||
|
||||
ElMessage.success('头像上传成功')
|
||||
return response
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '头像上传失败')
|
||||
throw error
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户成长数据
|
||||
*/
|
||||
const fetchGrowthStats = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
growthStats.value = await userApi.getGrowthStats()
|
||||
return growthStats.value
|
||||
} catch (error) {
|
||||
console.error('获取成长数据失败:', error)
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送邮箱验证码
|
||||
*/
|
||||
const sendEmailVerificationCode = async (email: string, type: 'register' | 'reset_password' | 'verify_email' = 'verify_email') => {
|
||||
try {
|
||||
if (!validateEmail(email)) {
|
||||
throw new Error('邮箱格式不正确')
|
||||
}
|
||||
|
||||
await userApi.sendEmailCode({ email, type })
|
||||
ElMessage.success('验证码已发送到您的邮箱')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '发送验证码失败')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证邮箱
|
||||
*/
|
||||
const verifyEmail = async (email: string, code: string) => {
|
||||
try {
|
||||
await userApi.verifyEmail({ email, code })
|
||||
|
||||
// 更新用户信息
|
||||
if (userProfile.value) {
|
||||
userProfile.value.email = email
|
||||
}
|
||||
|
||||
ElMessage.success('邮箱验证成功')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '邮箱验证失败')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送手机验证码
|
||||
*/
|
||||
const sendPhoneVerificationCode = async (phone: string, type: 'register' | 'reset_password' | 'verify_phone' = 'verify_phone') => {
|
||||
try {
|
||||
if (!validatePhone(phone)) {
|
||||
throw new Error('手机号格式不正确')
|
||||
}
|
||||
|
||||
await userApi.sendPhoneCode({ phone, type })
|
||||
ElMessage.success('验证码已发送到您的手机')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '发送验证码失败')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证手机号
|
||||
*/
|
||||
const verifyPhone = async (phone: string, code: string) => {
|
||||
try {
|
||||
await userApi.verifyPhone({ phone, code })
|
||||
|
||||
// 更新用户信息
|
||||
if (userProfile.value) {
|
||||
userProfile.value.phone = phone
|
||||
}
|
||||
|
||||
ElMessage.success('手机号验证成功')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '手机号验证失败')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查头像文件
|
||||
*/
|
||||
const validateAvatarFile = (file: File): boolean => {
|
||||
// 检查文件类型
|
||||
if (!UPLOAD_CONFIG.AVATAR_ALLOWED_TYPES.includes(file.type)) {
|
||||
ElMessage.error('只支持 JPG、PNG 格式的图片')
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查文件大小
|
||||
if (file.size > UPLOAD_CONFIG.AVATAR_MAX_SIZE) {
|
||||
ElMessage.error('图片大小不能超过 2MB')
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置状态
|
||||
*/
|
||||
const resetState = () => {
|
||||
userProfile.value = null
|
||||
growthStats.value = null
|
||||
loading.value = false
|
||||
uploading.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
// 响应式数据
|
||||
loading,
|
||||
uploading,
|
||||
userProfile,
|
||||
growthStats,
|
||||
|
||||
// 计算属性
|
||||
currentUser,
|
||||
isProfileComplete,
|
||||
|
||||
// 方法
|
||||
fetchUserProfile,
|
||||
updateUserProfile,
|
||||
changePassword,
|
||||
uploadAvatar,
|
||||
fetchGrowthStats,
|
||||
sendEmailVerificationCode,
|
||||
verifyEmail,
|
||||
sendPhoneVerificationCode,
|
||||
verifyPhone,
|
||||
validateAvatarFile,
|
||||
resetState
|
||||
}
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
/**
|
||||
* 应用常量定义
|
||||
*/
|
||||
|
||||
// 存储键名
|
||||
export const STORAGE_KEYS = {
|
||||
TOKEN: 'emotion_museum_token',
|
||||
REFRESH_TOKEN: 'emotion_museum_refresh_token',
|
||||
USER_INFO: 'emotion_museum_user_info',
|
||||
LANGUAGE: 'emotion_museum_language',
|
||||
THEME: 'emotion_museum_theme',
|
||||
CHAT_HISTORY: 'emotion_museum_chat_history',
|
||||
DRAFT_DIARY: 'emotion_museum_draft_diary'
|
||||
} as const
|
||||
|
||||
// API 路径
|
||||
export const API_PATHS = {
|
||||
// 认证相关
|
||||
AUTH: {
|
||||
LOGIN: '/auth/login',
|
||||
REGISTER: '/auth/register',
|
||||
LOGOUT: '/auth/logout',
|
||||
REFRESH_TOKEN: '/auth/refresh-token',
|
||||
CAPTCHA: '/auth/captcha',
|
||||
OAUTH_LOGIN: '/auth/oauth/login',
|
||||
USER_INFO: '/auth/user/info'
|
||||
},
|
||||
|
||||
// 用户相关
|
||||
USER: {
|
||||
PROFILE: '/user/profile',
|
||||
GROWTH_STATS: '/user/growth-stats',
|
||||
AVATAR_UPLOAD: '/user/avatar/upload',
|
||||
PASSWORD: '/user/password',
|
||||
EMAIL_VERIFY: '/user/email/verify',
|
||||
EMAIL_SEND_CODE: '/user/email/send-code',
|
||||
PHONE_VERIFY: '/user/phone/verify',
|
||||
PHONE_SEND_CODE: '/user/phone/send-code'
|
||||
},
|
||||
|
||||
// 对话相关
|
||||
CONVERSATION: {
|
||||
CREATE: '/conversation',
|
||||
USER_LIST: '/conversation/user',
|
||||
DELETE: '/conversation'
|
||||
},
|
||||
|
||||
// 消息相关
|
||||
MESSAGE: {
|
||||
USER_PAGE: '/message/user/page',
|
||||
USER_SEARCH: '/message/user/search',
|
||||
USER_RECENT: '/message/user/recent',
|
||||
DETAIL: '/message'
|
||||
},
|
||||
|
||||
// 日记相关
|
||||
DIARY: {
|
||||
PUBLISH: '/diary-post/publish',
|
||||
USER_PAGE: '/diary-post/user'
|
||||
}
|
||||
} as const
|
||||
|
||||
// WebSocket 路径
|
||||
export const WS_PATHS = {
|
||||
CHAT: '/ws/chat',
|
||||
NOTIFICATIONS: '/ws/notifications'
|
||||
} as const
|
||||
|
||||
// WebSocket 订阅路径
|
||||
export const WS_SUBSCRIBE_PATHS = {
|
||||
USER_MESSAGES: (userId: string) => `/user/${userId}/queue/messages`,
|
||||
CHAT_ROOM: '/topic/chat',
|
||||
NOTIFICATIONS: (userId: string) => `/user/${userId}/queue/notifications`
|
||||
} as const
|
||||
|
||||
// WebSocket 发送路径
|
||||
export const WS_SEND_PATHS = {
|
||||
CHAT_SEND: '/app/chat.send',
|
||||
TYPING: '/app/chat.typing'
|
||||
} as const
|
||||
|
||||
// 分页配置
|
||||
export const PAGINATION = {
|
||||
DEFAULT_PAGE_SIZE: 20,
|
||||
MAX_PAGE_SIZE: 100,
|
||||
DEFAULT_PAGE: 1
|
||||
} as const
|
||||
|
||||
// 文件上传配置
|
||||
export const UPLOAD_CONFIG = {
|
||||
MAX_FILE_SIZE: 10 * 1024 * 1024, // 10MB
|
||||
ALLOWED_IMAGE_TYPES: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
|
||||
ALLOWED_FILE_TYPES: ['application/pdf', 'text/plain', 'application/msword'],
|
||||
AVATAR_MAX_SIZE: 2 * 1024 * 1024, // 2MB
|
||||
AVATAR_ALLOWED_TYPES: ['image/jpeg', 'image/png']
|
||||
} as const
|
||||
|
||||
// 表单验证规则
|
||||
export const VALIDATION_RULES = {
|
||||
USERNAME: {
|
||||
MIN_LENGTH: 3,
|
||||
MAX_LENGTH: 20,
|
||||
PATTERN: /^[a-zA-Z0-9_\u4e00-\u9fa5]+$/
|
||||
},
|
||||
PASSWORD: {
|
||||
MIN_LENGTH: 6,
|
||||
MAX_LENGTH: 20,
|
||||
PATTERN: /^(?=.*[a-zA-Z])(?=.*\d)[a-zA-Z\d@$!%*?&]+$/
|
||||
},
|
||||
EMAIL: {
|
||||
PATTERN: /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
},
|
||||
PHONE: {
|
||||
PATTERN: /^1[3-9]\d{9}$/
|
||||
}
|
||||
} as const
|
||||
|
||||
// 情绪类型
|
||||
export const EMOTION_TYPES = {
|
||||
HAPPY: 'happy',
|
||||
SAD: 'sad',
|
||||
ANGRY: 'angry',
|
||||
CALM: 'calm',
|
||||
EXCITED: 'excited',
|
||||
ANXIOUS: 'anxious',
|
||||
NEUTRAL: 'neutral'
|
||||
} as const
|
||||
|
||||
// 情绪颜色映射
|
||||
export const EMOTION_COLORS = {
|
||||
[EMOTION_TYPES.HAPPY]: '#fbbf24',
|
||||
[EMOTION_TYPES.SAD]: '#3b82f6',
|
||||
[EMOTION_TYPES.ANGRY]: '#ef4444',
|
||||
[EMOTION_TYPES.CALM]: '#10b981',
|
||||
[EMOTION_TYPES.EXCITED]: '#f97316',
|
||||
[EMOTION_TYPES.ANXIOUS]: '#8b5cf6',
|
||||
[EMOTION_TYPES.NEUTRAL]: '#6b7280'
|
||||
} as const
|
||||
|
||||
// 消息类型
|
||||
export const MESSAGE_TYPES = {
|
||||
TEXT: 'text',
|
||||
IMAGE: 'image',
|
||||
FILE: 'file',
|
||||
EMOJI: 'emoji',
|
||||
SYSTEM: 'system'
|
||||
} as const
|
||||
|
||||
// 发送者类型
|
||||
export const SENDER_TYPES = {
|
||||
USER: 'USER',
|
||||
AI: 'AI',
|
||||
SYSTEM: 'SYSTEM'
|
||||
} as const
|
||||
|
||||
// 路由名称
|
||||
export const ROUTE_NAMES = {
|
||||
HOME: 'Home',
|
||||
LOGIN: 'Login',
|
||||
REGISTER: 'Register',
|
||||
CHAT: 'Chat',
|
||||
CHAT_HISTORY: 'ChatHistory',
|
||||
DIARY: 'Diary',
|
||||
PERSONAL_DASHBOARD: 'PersonalDashboard',
|
||||
PROFILE: 'Profile',
|
||||
ANALYSIS: 'Analysis',
|
||||
SETTINGS: 'Settings',
|
||||
NOT_FOUND: 'NotFound'
|
||||
} as const
|
||||
|
||||
// 主题配置
|
||||
export const THEMES = {
|
||||
LIGHT: 'light',
|
||||
DARK: 'dark',
|
||||
AUTO: 'auto'
|
||||
} as const
|
||||
|
||||
// 语言配置
|
||||
export const LANGUAGES = {
|
||||
ZH_CN: 'zh-CN',
|
||||
EN_US: 'en-US'
|
||||
} as const
|
||||
|
||||
// 错误码
|
||||
export const ERROR_CODES = {
|
||||
UNAUTHORIZED: 401,
|
||||
FORBIDDEN: 403,
|
||||
NOT_FOUND: 404,
|
||||
INTERNAL_SERVER_ERROR: 500,
|
||||
NETWORK_ERROR: 'NETWORK_ERROR',
|
||||
TIMEOUT: 'TIMEOUT'
|
||||
} as const
|
||||
|
||||
// 成功状态码
|
||||
export const SUCCESS_CODES = {
|
||||
OK: 200,
|
||||
CREATED: 201,
|
||||
NO_CONTENT: 204
|
||||
} as const
|
||||
|
||||
// 缓存时间(毫秒)
|
||||
export const CACHE_TIME = {
|
||||
SHORT: 5 * 60 * 1000, // 5分钟
|
||||
MEDIUM: 30 * 60 * 1000, // 30分钟
|
||||
LONG: 2 * 60 * 60 * 1000, // 2小时
|
||||
VERY_LONG: 24 * 60 * 60 * 1000 // 24小时
|
||||
} as const
|
||||
|
||||
// 动画持续时间
|
||||
export const ANIMATION_DURATION = {
|
||||
FAST: 150,
|
||||
NORMAL: 300,
|
||||
SLOW: 500
|
||||
} as const
|
||||
@@ -1,158 +0,0 @@
|
||||
/**
|
||||
* 表情数据配置
|
||||
*/
|
||||
|
||||
export interface EmojiItem {
|
||||
char: string
|
||||
code: string
|
||||
name: string
|
||||
category: string
|
||||
keywords: string[]
|
||||
}
|
||||
|
||||
export const EMOJI_DATA: EmojiItem[] = [
|
||||
// 笑脸表情
|
||||
{ char: '😀', code: 'grinning', name: '咧嘴笑', category: 'smileys', keywords: ['开心', '高兴', '笑'] },
|
||||
{ char: '😃', code: 'smiley', name: '笑脸', category: 'smileys', keywords: ['开心', '高兴', '笑'] },
|
||||
{ char: '😄', code: 'smile', name: '大笑', category: 'smileys', keywords: ['开心', '高兴', '大笑'] },
|
||||
{ char: '😁', code: 'grin', name: '露齿笑', category: 'smileys', keywords: ['开心', '高兴', '笑'] },
|
||||
{ char: '😆', code: 'laughing', name: '哈哈', category: 'smileys', keywords: ['开心', '高兴', '哈哈'] },
|
||||
{ char: '😅', code: 'sweat_smile', name: '苦笑', category: 'smileys', keywords: ['苦笑', '尴尬'] },
|
||||
{ char: '🤣', code: 'rofl', name: '笑哭', category: 'smileys', keywords: ['笑哭', '大笑'] },
|
||||
{ char: '😂', code: 'joy', name: '喜极而泣', category: 'smileys', keywords: ['喜极而泣', '笑哭'] },
|
||||
{ char: '🙂', code: 'slightly_smiling_face', name: '微笑', category: 'smileys', keywords: ['微笑', '开心'] },
|
||||
{ char: '🙃', code: 'upside_down_face', name: '倒脸', category: 'smileys', keywords: ['倒脸', '搞怪'] },
|
||||
{ char: '😉', code: 'wink', name: '眨眼', category: 'smileys', keywords: ['眨眼', '调皮'] },
|
||||
{ char: '😊', code: 'blush', name: '害羞', category: 'smileys', keywords: ['害羞', '脸红'] },
|
||||
{ char: '😇', code: 'innocent', name: '天使', category: 'smileys', keywords: ['天使', '纯真'] },
|
||||
{ char: '🥰', code: 'smiling_face_with_hearts', name: '爱心眼', category: 'smileys', keywords: ['爱心', '喜欢'] },
|
||||
{ char: '😍', code: 'heart_eyes', name: '花痴', category: 'smileys', keywords: ['花痴', '爱心眼'] },
|
||||
{ char: '🤩', code: 'star_struck', name: '星星眼', category: 'smileys', keywords: ['星星眼', '崇拜'] },
|
||||
{ char: '😘', code: 'kissing_heart', name: '飞吻', category: 'smileys', keywords: ['飞吻', '亲吻'] },
|
||||
{ char: '😗', code: 'kissing', name: '亲吻', category: 'smileys', keywords: ['亲吻', '吻'] },
|
||||
{ char: '☺️', code: 'relaxed', name: '满足', category: 'smileys', keywords: ['满足', '开心'] },
|
||||
{ char: '😚', code: 'kissing_closed_eyes', name: '闭眼亲吻', category: 'smileys', keywords: ['亲吻', '闭眼'] },
|
||||
|
||||
// 难过表情
|
||||
{ char: '😢', code: 'cry', name: '哭泣', category: 'smileys', keywords: ['哭泣', '难过', '伤心'] },
|
||||
{ char: '😭', code: 'sob', name: '大哭', category: 'smileys', keywords: ['大哭', '难过', '伤心'] },
|
||||
{ char: '😤', code: 'triumph', name: '生气', category: 'smileys', keywords: ['生气', '愤怒'] },
|
||||
{ char: '😠', code: 'angry', name: '愤怒', category: 'smileys', keywords: ['愤怒', '生气'] },
|
||||
{ char: '😡', code: 'rage', name: '暴怒', category: 'smileys', keywords: ['暴怒', '愤怒'] },
|
||||
{ char: '🤬', code: 'swearing', name: '骂人', category: 'smileys', keywords: ['骂人', '愤怒'] },
|
||||
{ char: '😱', code: 'scream', name: '尖叫', category: 'smileys', keywords: ['尖叫', '惊恐'] },
|
||||
{ char: '😨', code: 'fearful', name: '恐惧', category: 'smileys', keywords: ['恐惧', '害怕'] },
|
||||
{ char: '😰', code: 'cold_sweat', name: '冷汗', category: 'smileys', keywords: ['冷汗', '紧张'] },
|
||||
{ char: '😥', code: 'disappointed_relieved', name: '失望', category: 'smileys', keywords: ['失望', '难过'] },
|
||||
|
||||
// 其他表情
|
||||
{ char: '😴', code: 'sleeping', name: '睡觉', category: 'smileys', keywords: ['睡觉', '困'] },
|
||||
{ char: '🤤', code: 'drooling', name: '流口水', category: 'smileys', keywords: ['流口水', '馋'] },
|
||||
{ char: '😪', code: 'sleepy', name: '困倦', category: 'smileys', keywords: ['困倦', '累'] },
|
||||
{ char: '🤔', code: 'thinking', name: '思考', category: 'smileys', keywords: ['思考', '想'] },
|
||||
{ char: '🤫', code: 'shushing', name: '嘘', category: 'smileys', keywords: ['嘘', '安静'] },
|
||||
{ char: '🤭', code: 'hand_over_mouth', name: '捂嘴', category: 'smileys', keywords: ['捂嘴', '惊讶'] },
|
||||
{ char: '🙄', code: 'eye_roll', name: '翻白眼', category: 'smileys', keywords: ['翻白眼', '无语'] },
|
||||
{ char: '😏', code: 'smirk', name: '得意', category: 'smileys', keywords: ['得意', '坏笑'] },
|
||||
{ char: '😒', code: 'unamused', name: '无趣', category: 'smileys', keywords: ['无趣', '无聊'] },
|
||||
{ char: '🙁', code: 'frowning', name: '皱眉', category: 'smileys', keywords: ['皱眉', '不开心'] },
|
||||
|
||||
// 人物手势
|
||||
{ char: '👋', code: 'wave', name: '挥手', category: 'people', keywords: ['挥手', '再见', '你好'] },
|
||||
{ char: '🤚', code: 'raised_back_of_hand', name: '举手', category: 'people', keywords: ['举手', '停'] },
|
||||
{ char: '🖐️', code: 'raised_hand_with_fingers_splayed', name: '张开手', category: 'people', keywords: ['张开手', '五'] },
|
||||
{ char: '✋', code: 'raised_hand', name: '举手', category: 'people', keywords: ['举手', '停'] },
|
||||
{ char: '🖖', code: 'vulcan_salute', name: '瓦肯礼', category: 'people', keywords: ['瓦肯礼', '问候'] },
|
||||
{ char: '👌', code: 'ok_hand', name: 'OK', category: 'people', keywords: ['OK', '好的'] },
|
||||
{ char: '🤏', code: 'pinching_hand', name: '捏', category: 'people', keywords: ['捏', '一点点'] },
|
||||
{ char: '✌️', code: 'v', name: '胜利', category: 'people', keywords: ['胜利', 'V', '耶'] },
|
||||
{ char: '🤞', code: 'crossed_fingers', name: '祈祷', category: 'people', keywords: ['祈祷', '希望'] },
|
||||
{ char: '🤟', code: 'love_you_gesture', name: '爱你', category: 'people', keywords: ['爱你', '手势'] },
|
||||
{ char: '🤘', code: 'metal', name: '摇滚', category: 'people', keywords: ['摇滚', '酷'] },
|
||||
{ char: '🤙', code: 'call_me_hand', name: '打电话', category: 'people', keywords: ['打电话', '联系'] },
|
||||
{ char: '👈', code: 'point_left', name: '向左指', category: 'people', keywords: ['向左', '指'] },
|
||||
{ char: '👉', code: 'point_right', name: '向右指', category: 'people', keywords: ['向右', '指'] },
|
||||
{ char: '👆', code: 'point_up_2', name: '向上指', category: 'people', keywords: ['向上', '指'] },
|
||||
{ char: '🖕', code: 'middle_finger', name: '中指', category: 'people', keywords: ['中指', '鄙视'] },
|
||||
{ char: '👇', code: 'point_down', name: '向下指', category: 'people', keywords: ['向下', '指'] },
|
||||
{ char: '☝️', code: 'point_up', name: '食指向上', category: 'people', keywords: ['食指', '向上'] },
|
||||
{ char: '👍', code: 'thumbsup', name: '赞', category: 'people', keywords: ['赞', '好', '棒'] },
|
||||
{ char: '👎', code: 'thumbsdown', name: '踩', category: 'people', keywords: ['踩', '不好', '差'] },
|
||||
|
||||
// 自然
|
||||
{ char: '🌱', code: 'seedling', name: '幼苗', category: 'nature', keywords: ['幼苗', '植物', '成长'] },
|
||||
{ char: '🌿', code: 'herb', name: '草本', category: 'nature', keywords: ['草本', '植物'] },
|
||||
{ char: '🍀', code: 'four_leaf_clover', name: '四叶草', category: 'nature', keywords: ['四叶草', '幸运'] },
|
||||
{ char: '🌸', code: 'cherry_blossom', name: '樱花', category: 'nature', keywords: ['樱花', '花'] },
|
||||
{ char: '🌺', code: 'hibiscus', name: '芙蓉花', category: 'nature', keywords: ['芙蓉花', '花'] },
|
||||
{ char: '🌻', code: 'sunflower', name: '向日葵', category: 'nature', keywords: ['向日葵', '花'] },
|
||||
{ char: '🌹', code: 'rose', name: '玫瑰', category: 'nature', keywords: ['玫瑰', '花', '爱情'] },
|
||||
{ char: '🌷', code: 'tulip', name: '郁金香', category: 'nature', keywords: ['郁金香', '花'] },
|
||||
{ char: '🌲', code: 'evergreen_tree', name: '常青树', category: 'nature', keywords: ['常青树', '树'] },
|
||||
{ char: '🌳', code: 'deciduous_tree', name: '落叶树', category: 'nature', keywords: ['落叶树', '树'] },
|
||||
|
||||
// 食物
|
||||
{ char: '🍎', code: 'apple', name: '苹果', category: 'food', keywords: ['苹果', '水果'] },
|
||||
{ char: '🍊', code: 'tangerine', name: '橘子', category: 'food', keywords: ['橘子', '水果'] },
|
||||
{ char: '🍋', code: 'lemon', name: '柠檬', category: 'food', keywords: ['柠檬', '水果'] },
|
||||
{ char: '🍌', code: 'banana', name: '香蕉', category: 'food', keywords: ['香蕉', '水果'] },
|
||||
{ char: '🍉', code: 'watermelon', name: '西瓜', category: 'food', keywords: ['西瓜', '水果'] },
|
||||
{ char: '🍇', code: 'grapes', name: '葡萄', category: 'food', keywords: ['葡萄', '水果'] },
|
||||
{ char: '🍓', code: 'strawberry', name: '草莓', category: 'food', keywords: ['草莓', '水果'] },
|
||||
{ char: '🍑', code: 'cherries', name: '樱桃', category: 'food', keywords: ['樱桃', '水果'] },
|
||||
{ char: '🍒', code: 'cherry', name: '樱桃', category: 'food', keywords: ['樱桃', '水果'] },
|
||||
{ char: '🥝', code: 'kiwi_fruit', name: '猕猴桃', category: 'food', keywords: ['猕猴桃', '水果'] },
|
||||
|
||||
// 活动
|
||||
{ char: '⚽', code: 'soccer', name: '足球', category: 'activity', keywords: ['足球', '运动'] },
|
||||
{ char: '🏀', code: 'basketball', name: '篮球', category: 'activity', keywords: ['篮球', '运动'] },
|
||||
{ char: '🏈', code: 'football', name: '橄榄球', category: 'activity', keywords: ['橄榄球', '运动'] },
|
||||
{ char: '⚾', code: 'baseball', name: '棒球', category: 'activity', keywords: ['棒球', '运动'] },
|
||||
{ char: '🎾', code: 'tennis', name: '网球', category: 'activity', keywords: ['网球', '运动'] },
|
||||
{ char: '🏐', code: 'volleyball', name: '排球', category: 'activity', keywords: ['排球', '运动'] },
|
||||
{ char: '🏓', code: 'ping_pong', name: '乒乓球', category: 'activity', keywords: ['乒乓球', '运动'] },
|
||||
{ char: '🏸', code: 'badminton', name: '羽毛球', category: 'activity', keywords: ['羽毛球', '运动'] },
|
||||
{ char: '🥅', code: 'goal_net', name: '球门', category: 'activity', keywords: ['球门', '运动'] },
|
||||
{ char: '🎯', code: 'dart', name: '飞镖', category: 'activity', keywords: ['飞镖', '游戏'] },
|
||||
|
||||
// 符号
|
||||
{ char: '❤️', code: 'heart', name: '红心', category: 'symbols', keywords: ['红心', '爱', '喜欢'] },
|
||||
{ char: '🧡', code: 'orange_heart', name: '橙心', category: 'symbols', keywords: ['橙心', '爱'] },
|
||||
{ char: '💛', code: 'yellow_heart', name: '黄心', category: 'symbols', keywords: ['黄心', '爱'] },
|
||||
{ char: '💚', code: 'green_heart', name: '绿心', category: 'symbols', keywords: ['绿心', '爱'] },
|
||||
{ char: '💙', code: 'blue_heart', name: '蓝心', category: 'symbols', keywords: ['蓝心', '爱'] },
|
||||
{ char: '💜', code: 'purple_heart', name: '紫心', category: 'symbols', keywords: ['紫心', '爱'] },
|
||||
{ char: '🖤', code: 'black_heart', name: '黑心', category: 'symbols', keywords: ['黑心', '爱'] },
|
||||
{ char: '🤍', code: 'white_heart', name: '白心', category: 'symbols', keywords: ['白心', '爱'] },
|
||||
{ char: '🤎', code: 'brown_heart', name: '棕心', category: 'symbols', keywords: ['棕心', '爱'] },
|
||||
{ char: '💔', code: 'broken_heart', name: '心碎', category: 'symbols', keywords: ['心碎', '伤心'] },
|
||||
{ char: '❣️', code: 'heavy_heart_exclamation', name: '心叹号', category: 'symbols', keywords: ['心叹号', '爱'] },
|
||||
{ char: '💕', code: 'two_hearts', name: '双心', category: 'symbols', keywords: ['双心', '爱'] },
|
||||
{ char: '💞', code: 'revolving_hearts', name: '旋转心', category: 'symbols', keywords: ['旋转心', '爱'] },
|
||||
{ char: '💓', code: 'heartbeat', name: '心跳', category: 'symbols', keywords: ['心跳', '爱'] },
|
||||
{ char: '💗', code: 'heartpulse', name: '心脉', category: 'symbols', keywords: ['心脉', '爱'] },
|
||||
{ char: '💖', code: 'sparkling_heart', name: '闪亮心', category: 'symbols', keywords: ['闪亮心', '爱'] },
|
||||
{ char: '💘', code: 'cupid', name: '丘比特', category: 'symbols', keywords: ['丘比特', '爱'] },
|
||||
{ char: '💝', code: 'gift_heart', name: '礼物心', category: 'symbols', keywords: ['礼物心', '爱'] },
|
||||
{ char: '💟', code: 'heart_decoration', name: '心装饰', category: 'symbols', keywords: ['心装饰', '爱'] }
|
||||
]
|
||||
|
||||
// 根据分类获取表情
|
||||
export const getEmojisByCategory = (category: string): EmojiItem[] => {
|
||||
return EMOJI_DATA.filter(emoji => emoji.category === category)
|
||||
}
|
||||
|
||||
// 搜索表情
|
||||
export const searchEmojis = (keyword: string): EmojiItem[] => {
|
||||
const lowerKeyword = keyword.toLowerCase()
|
||||
return EMOJI_DATA.filter(emoji =>
|
||||
emoji.name.toLowerCase().includes(lowerKeyword) ||
|
||||
emoji.keywords.some(k => k.toLowerCase().includes(lowerKeyword))
|
||||
)
|
||||
}
|
||||
|
||||
// 获取随机表情
|
||||
export const getRandomEmojis = (count: number = 10): EmojiItem[] => {
|
||||
const shuffled = [...EMOJI_DATA].sort(() => 0.5 - Math.random())
|
||||
return shuffled.slice(0, count)
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
/**
|
||||
* 环境配置管理
|
||||
* 支持 local/dev/test/prod 四种环境
|
||||
*/
|
||||
|
||||
export interface EnvConfig {
|
||||
name: string
|
||||
apiBaseUrl: string
|
||||
wsBaseUrl: string
|
||||
uploadUrl: string
|
||||
debug: boolean
|
||||
mock: boolean
|
||||
appTitle: string
|
||||
appVersion: string
|
||||
}
|
||||
|
||||
// 环境配置映射
|
||||
const envConfigs: Record<string, EnvConfig> = {
|
||||
local: {
|
||||
name: '本地环境',
|
||||
apiBaseUrl: 'http://localhost:19089/api',
|
||||
wsBaseUrl: 'ws://localhost:19089',
|
||||
uploadUrl: 'http://localhost:19089/api/upload',
|
||||
debug: true,
|
||||
mock: false,
|
||||
appTitle: '情绪博物馆 - 本地',
|
||||
appVersion: '1.0.0'
|
||||
},
|
||||
|
||||
dev: {
|
||||
name: '开发环境',
|
||||
apiBaseUrl: 'https://dev-api.emotion-museum.com/api',
|
||||
wsBaseUrl: 'wss://dev-api.emotion-museum.com',
|
||||
uploadUrl: 'https://dev-api.emotion-museum.com/api/upload',
|
||||
debug: true,
|
||||
mock: false,
|
||||
appTitle: '情绪博物馆 - 开发',
|
||||
appVersion: '1.0.0'
|
||||
},
|
||||
|
||||
test: {
|
||||
name: '测试环境',
|
||||
apiBaseUrl: 'https://test-api.emotion-museum.com/api',
|
||||
wsBaseUrl: 'wss://test-api.emotion-museum.com',
|
||||
uploadUrl: 'https://test-api.emotion-museum.com/api/upload',
|
||||
debug: false,
|
||||
mock: false,
|
||||
appTitle: '情绪博物馆 - 测试',
|
||||
appVersion: '1.0.0'
|
||||
},
|
||||
|
||||
prod: {
|
||||
name: '生产环境',
|
||||
apiBaseUrl: 'https://api.emotion-museum.com/api',
|
||||
wsBaseUrl: 'wss://api.emotion-museum.com',
|
||||
uploadUrl: 'https://api.emotion-museum.com/api/upload',
|
||||
debug: false,
|
||||
mock: false,
|
||||
appTitle: '情绪博物馆',
|
||||
appVersion: '1.0.0'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前环境
|
||||
function getCurrentEnv(): string {
|
||||
// 优先使用环境变量
|
||||
const viteEnv = import.meta.env.VITE_APP_ENV
|
||||
if (viteEnv && envConfigs[viteEnv]) {
|
||||
return viteEnv
|
||||
}
|
||||
|
||||
// 根据域名判断环境
|
||||
const hostname = window.location.hostname
|
||||
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
||||
return 'local'
|
||||
} else if (hostname.includes('dev')) {
|
||||
return 'dev'
|
||||
} else if (hostname.includes('test')) {
|
||||
return 'test'
|
||||
} else {
|
||||
return 'prod'
|
||||
}
|
||||
}
|
||||
|
||||
// 当前环境配置
|
||||
export const currentEnv = getCurrentEnv()
|
||||
export const envConfig = envConfigs[currentEnv]
|
||||
|
||||
// 环境变量覆盖
|
||||
if (import.meta.env.VITE_API_BASE_URL) {
|
||||
envConfig.apiBaseUrl = import.meta.env.VITE_API_BASE_URL
|
||||
}
|
||||
|
||||
if (import.meta.env.VITE_WS_BASE_URL) {
|
||||
envConfig.wsBaseUrl = import.meta.env.VITE_WS_BASE_URL
|
||||
}
|
||||
|
||||
if (import.meta.env.VITE_UPLOAD_URL) {
|
||||
envConfig.uploadUrl = import.meta.env.VITE_UPLOAD_URL
|
||||
}
|
||||
|
||||
if (import.meta.env.VITE_DEBUG) {
|
||||
envConfig.debug = import.meta.env.VITE_DEBUG === 'true'
|
||||
}
|
||||
|
||||
if (import.meta.env.VITE_MOCK) {
|
||||
envConfig.mock = import.meta.env.VITE_MOCK === 'true'
|
||||
}
|
||||
|
||||
if (import.meta.env.VITE_APP_TITLE) {
|
||||
envConfig.appTitle = import.meta.env.VITE_APP_TITLE
|
||||
}
|
||||
|
||||
if (import.meta.env.VITE_APP_VERSION) {
|
||||
envConfig.appVersion = import.meta.env.VITE_APP_VERSION
|
||||
}
|
||||
|
||||
// 开发环境下打印配置信息
|
||||
if (envConfig.debug) {
|
||||
console.log('🔧 当前环境配置:', {
|
||||
环境: envConfig.name,
|
||||
API地址: envConfig.apiBaseUrl,
|
||||
WebSocket地址: envConfig.wsBaseUrl,
|
||||
上传地址: envConfig.uploadUrl,
|
||||
调试模式: envConfig.debug,
|
||||
Mock模式: envConfig.mock
|
||||
})
|
||||
}
|
||||
|
||||
// 导出配置验证函数
|
||||
export function validateConfig(): boolean {
|
||||
const required = ['apiBaseUrl', 'wsBaseUrl', 'uploadUrl']
|
||||
|
||||
for (const key of required) {
|
||||
if (!envConfig[key as keyof EnvConfig]) {
|
||||
console.error(`❌ 环境配置缺失: ${key}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 导出所有环境配置(用于调试)
|
||||
export { envConfigs }
|
||||
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* 国际化配置
|
||||
*/
|
||||
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { getLanguage } from '@/utils/storage'
|
||||
import { LANGUAGES } from '@/config/constants'
|
||||
|
||||
// 导入语言文件
|
||||
import zhCN from './locales/zh-CN.json'
|
||||
import enUS from './locales/en-US.json'
|
||||
|
||||
const messages = {
|
||||
[LANGUAGES.ZH_CN]: zhCN,
|
||||
[LANGUAGES.EN_US]: enUS
|
||||
}
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: getLanguage() || LANGUAGES.ZH_CN,
|
||||
fallbackLocale: LANGUAGES.ZH_CN,
|
||||
messages,
|
||||
globalInjection: true
|
||||
})
|
||||
|
||||
export default i18n
|
||||
@@ -1,73 +0,0 @@
|
||||
{
|
||||
"common": {
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"add": "Add",
|
||||
"search": "Search",
|
||||
"reset": "Reset",
|
||||
"submit": "Submit",
|
||||
"loading": "Loading...",
|
||||
"success": "Success",
|
||||
"error": "Error",
|
||||
"warning": "Warning",
|
||||
"info": "Info"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Login",
|
||||
"register": "Register",
|
||||
"logout": "Logout",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"email": "Email",
|
||||
"phone": "Phone",
|
||||
"captcha": "Captcha",
|
||||
"rememberMe": "Remember Me",
|
||||
"forgotPassword": "Forgot Password?",
|
||||
"loginSuccess": "Login successful",
|
||||
"registerSuccess": "Registration successful",
|
||||
"logoutSuccess": "Logout successful"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"chat": "AI Chat",
|
||||
"diary": "Emotion Diary",
|
||||
"dashboard": "Dashboard",
|
||||
"analysis": "Analysis",
|
||||
"profile": "Profile",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"chat": {
|
||||
"sendMessage": "Send Message",
|
||||
"typing": "Typing...",
|
||||
"offline": "Offline",
|
||||
"connected": "Connected",
|
||||
"connecting": "Connecting...",
|
||||
"disconnected": "Disconnected",
|
||||
"reconnecting": "Reconnecting..."
|
||||
},
|
||||
"diary": {
|
||||
"title": "Title",
|
||||
"content": "Content",
|
||||
"emotion": "Emotion",
|
||||
"mood": "Mood",
|
||||
"weather": "Weather",
|
||||
"location": "Location",
|
||||
"tags": "Tags",
|
||||
"publish": "Publish",
|
||||
"draft": "Draft",
|
||||
"public": "Public",
|
||||
"private": "Private"
|
||||
},
|
||||
"error": {
|
||||
"404": "Page Not Found",
|
||||
"403": "Access Denied",
|
||||
"500": "Server Error",
|
||||
"network": "Network Error",
|
||||
"timeout": "Request Timeout",
|
||||
"unknown": "Unknown Error"
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
{
|
||||
"common": {
|
||||
"confirm": "确认",
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
"delete": "删除",
|
||||
"edit": "编辑",
|
||||
"add": "添加",
|
||||
"search": "搜索",
|
||||
"reset": "重置",
|
||||
"submit": "提交",
|
||||
"loading": "加载中...",
|
||||
"success": "成功",
|
||||
"error": "错误",
|
||||
"warning": "警告",
|
||||
"info": "信息"
|
||||
},
|
||||
"auth": {
|
||||
"login": "登录",
|
||||
"register": "注册",
|
||||
"logout": "退出登录",
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
"confirmPassword": "确认密码",
|
||||
"email": "邮箱",
|
||||
"phone": "手机号",
|
||||
"captcha": "验证码",
|
||||
"rememberMe": "记住我",
|
||||
"forgotPassword": "忘记密码?",
|
||||
"loginSuccess": "登录成功",
|
||||
"registerSuccess": "注册成功",
|
||||
"logoutSuccess": "退出登录成功"
|
||||
},
|
||||
"nav": {
|
||||
"home": "首页",
|
||||
"chat": "AI对话",
|
||||
"diary": "情绪日记",
|
||||
"dashboard": "个人仪表盘",
|
||||
"analysis": "情绪分析",
|
||||
"profile": "个人资料",
|
||||
"settings": "设置"
|
||||
},
|
||||
"chat": {
|
||||
"sendMessage": "发送消息",
|
||||
"typing": "正在输入...",
|
||||
"offline": "离线",
|
||||
"connected": "已连接",
|
||||
"connecting": "连接中...",
|
||||
"disconnected": "已断开",
|
||||
"reconnecting": "重连中..."
|
||||
},
|
||||
"diary": {
|
||||
"title": "标题",
|
||||
"content": "内容",
|
||||
"emotion": "情绪",
|
||||
"mood": "心情指数",
|
||||
"weather": "天气",
|
||||
"location": "位置",
|
||||
"tags": "标签",
|
||||
"publish": "发布",
|
||||
"draft": "草稿",
|
||||
"public": "公开",
|
||||
"private": "私密"
|
||||
},
|
||||
"error": {
|
||||
"404": "页面不存在",
|
||||
"403": "权限不足",
|
||||
"500": "服务器错误",
|
||||
"network": "网络错误",
|
||||
"timeout": "请求超时",
|
||||
"unknown": "未知错误"
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
<template>
|
||||
<div class="auth-layout min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
|
||||
<!-- 背景装饰 -->
|
||||
<div class="absolute inset-0 overflow-hidden">
|
||||
<div class="absolute -top-40 -right-40 w-80 h-80 bg-gradient-to-br from-blue-400 to-purple-500 rounded-full opacity-20 animate-pulse-slow"></div>
|
||||
<div class="absolute -bottom-40 -left-40 w-96 h-96 bg-gradient-to-tr from-pink-400 to-yellow-500 rounded-full opacity-20 animate-bounce-gentle"></div>
|
||||
</div>
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<div class="relative w-full max-w-md">
|
||||
<!-- Logo和标题 -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl flex items-center justify-center shadow-lg">
|
||||
<el-icon size="32" class="text-white">
|
||||
<Sunny />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">情绪博物馆</h1>
|
||||
<p class="text-gray-600">记录情绪,分享心情的温暖空间</p>
|
||||
</div>
|
||||
|
||||
<!-- 认证表单容器 -->
|
||||
<div class="bg-white rounded-2xl shadow-xl p-8 backdrop-blur-sm bg-opacity-95">
|
||||
<router-view />
|
||||
</div>
|
||||
|
||||
<!-- 底部链接 -->
|
||||
<div class="text-center mt-6 space-y-2">
|
||||
<div class="flex justify-center space-x-4 text-sm text-gray-600">
|
||||
<a href="#" class="hover:text-blue-600 transition-colors">帮助中心</a>
|
||||
<span>·</span>
|
||||
<a href="#" class="hover:text-blue-600 transition-colors">隐私政策</a>
|
||||
<span>·</span>
|
||||
<a href="#" class="hover:text-blue-600 transition-colors">服务条款</a>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">
|
||||
© 2024 情绪博物馆. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主题切换按钮 -->
|
||||
<div class="absolute top-4 right-4">
|
||||
<el-button circle @click="toggleTheme" class="bg-white bg-opacity-80 backdrop-blur-sm">
|
||||
<el-icon>
|
||||
<Sunny v-if="!isDarkTheme" />
|
||||
<Moon v-else />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 语言切换按钮 -->
|
||||
<div class="absolute top-4 left-4">
|
||||
<el-dropdown @command="handleLanguageChange">
|
||||
<el-button circle class="bg-white bg-opacity-80 backdrop-blur-sm">
|
||||
<el-icon><Globe /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="zh-CN">中文</el-dropdown-item>
|
||||
<el-dropdown-item command="en-US">English</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Sunny, Moon, Globe } from '@element-plus/icons-vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
// 计算属性
|
||||
const isDarkTheme = computed(() => appStore.isDarkTheme)
|
||||
|
||||
// 方法
|
||||
const toggleTheme = () => {
|
||||
appStore.toggleTheme()
|
||||
}
|
||||
|
||||
const handleLanguageChange = (language: string) => {
|
||||
appStore.setLanguage(language)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-layout {
|
||||
background-image:
|
||||
radial-gradient(circle at 20% 80%, rgba(120, 119, 198, 0.3) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, rgba(255, 119, 198, 0.3) 0%, transparent 50%),
|
||||
radial-gradient(circle at 40% 40%, rgba(120, 219, 255, 0.3) 0%, transparent 50%);
|
||||
}
|
||||
|
||||
@keyframes pulse-slow {
|
||||
0%, 100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce-gentle {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse-slow {
|
||||
animation: pulse-slow 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-bounce-gentle {
|
||||
animation: bounce-gentle 6s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -1,376 +0,0 @@
|
||||
<template>
|
||||
<div class="chat-layout h-screen flex bg-gray-50">
|
||||
<!-- 侧边栏 -->
|
||||
<aside
|
||||
class="sidebar bg-white border-r border-gray-200 flex flex-col transition-all duration-300"
|
||||
:class="sidebarCollapsed ? 'w-16' : 'w-80'"
|
||||
>
|
||||
<!-- 侧边栏头部 -->
|
||||
<header class="sidebar-header p-4 border-b border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div v-if="!sidebarCollapsed" class="flex items-center space-x-3">
|
||||
<div class="w-8 h-8 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center">
|
||||
<el-icon size="16" class="text-white">
|
||||
<ChatDotRound />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold text-gray-900">聊天</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
circle
|
||||
size="small"
|
||||
@click="toggleSidebar"
|
||||
class="flex-shrink-0"
|
||||
>
|
||||
<el-icon>
|
||||
<Expand v-if="sidebarCollapsed" />
|
||||
<Fold v-else />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 会话列表 -->
|
||||
<div class="conversation-list flex-1 overflow-hidden">
|
||||
<div v-if="!sidebarCollapsed" class="p-4">
|
||||
<!-- 新建对话按钮 -->
|
||||
<el-button
|
||||
type="primary"
|
||||
class="w-full mb-4"
|
||||
@click="createNewConversation"
|
||||
>
|
||||
<el-icon class="mr-2"><Plus /></el-icon>
|
||||
新建对话
|
||||
</el-button>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索对话..."
|
||||
clearable
|
||||
class="mb-4"
|
||||
@input="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<!-- 对话列表 -->
|
||||
<div class="conversation-items overflow-y-auto flex-1">
|
||||
<div v-if="filteredConversations.length === 0" class="text-center py-8">
|
||||
<div class="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<el-icon size="24" class="text-gray-400">
|
||||
<ChatDotRound />
|
||||
</el-icon>
|
||||
</div>
|
||||
<p class="text-gray-500 text-sm">暂无对话记录</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="conversation in filteredConversations"
|
||||
:key="conversation.id"
|
||||
class="conversation-item"
|
||||
:class="{ 'active': currentConversationId === conversation.id }"
|
||||
@click="selectConversation(conversation.id)"
|
||||
>
|
||||
<div v-if="sidebarCollapsed" class="p-3 flex justify-center">
|
||||
<div class="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<el-icon size="16" class="text-blue-600">
|
||||
<ChatDotRound />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="p-4">
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<el-icon size="16" class="text-white">
|
||||
<ChatDotRound />
|
||||
</el-icon>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<h3 class="text-sm font-medium text-gray-900 truncate">
|
||||
{{ conversation.title || 'AI对话' }}
|
||||
</h3>
|
||||
<span class="text-xs text-gray-500">
|
||||
{{ formatTime(conversation.updateTime) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-600 truncate">
|
||||
{{ conversation.lastMessage || '开始新的对话...' }}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center justify-between mt-2">
|
||||
<span class="text-xs text-gray-500">
|
||||
{{ conversation.messageCount }} 条消息
|
||||
</span>
|
||||
|
||||
<el-dropdown @command="handleConversationAction">
|
||||
<el-button circle size="small" text>
|
||||
<el-icon><MoreFilled /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item :command="`rename_${conversation.id}`">
|
||||
重命名
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item :command="`archive_${conversation.id}`">
|
||||
归档
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
:command="`delete_${conversation.id}`"
|
||||
divided
|
||||
>
|
||||
删除
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 侧边栏底部 -->
|
||||
<footer v-if="!sidebarCollapsed" class="sidebar-footer p-4 border-t border-gray-200">
|
||||
<div class="flex items-center space-x-3">
|
||||
<el-avatar :src="userAvatar" :size="32">
|
||||
<el-icon><User /></el-icon>
|
||||
</el-avatar>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">
|
||||
{{ userNickname }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
<span :class="connectionStatusClass">{{ connectionStatusText }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<el-dropdown @command="handleUserAction">
|
||||
<el-button circle size="small" text>
|
||||
<el-icon><Setting /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="profile">个人资料</el-dropdown-item>
|
||||
<el-dropdown-item command="settings">设置</el-dropdown-item>
|
||||
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</footer>
|
||||
</aside>
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<main class="main-content flex-1 flex flex-col">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
ChatDotRound,
|
||||
Expand,
|
||||
Fold,
|
||||
Plus,
|
||||
Search,
|
||||
MoreFilled,
|
||||
User,
|
||||
Setting
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useChat } from '@/composables/useChat'
|
||||
import { formatRelativeTime } from '@/utils/format'
|
||||
|
||||
// 状态管理
|
||||
const authStore = useAuthStore()
|
||||
const appStore = useAppStore()
|
||||
const router = useRouter()
|
||||
|
||||
// 聊天功能
|
||||
const { connectionState } = useChat()
|
||||
|
||||
// 响应式数据
|
||||
const searchKeyword = ref('')
|
||||
const currentConversationId = ref('')
|
||||
const conversations = ref([
|
||||
{
|
||||
id: '1',
|
||||
title: 'AI助手对话',
|
||||
lastMessage: '你好,有什么可以帮助你的吗?',
|
||||
updateTime: Date.now() - 1000 * 60 * 5,
|
||||
messageCount: 12
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '情绪咨询',
|
||||
lastMessage: '今天感觉怎么样?',
|
||||
updateTime: Date.now() - 1000 * 60 * 60,
|
||||
messageCount: 8
|
||||
}
|
||||
])
|
||||
|
||||
// 计算属性
|
||||
const sidebarCollapsed = computed(() => appStore.sidebarCollapsed)
|
||||
const userAvatar = computed(() => authStore.avatar)
|
||||
const userNickname = computed(() => authStore.nickname)
|
||||
|
||||
const connectionStatusText = computed(() => {
|
||||
switch (connectionState.value) {
|
||||
case 'CONNECTED':
|
||||
return '在线'
|
||||
case 'CONNECTING':
|
||||
return '连接中...'
|
||||
case 'DISCONNECTED':
|
||||
return '离线'
|
||||
case 'ERROR':
|
||||
return '连接错误'
|
||||
default:
|
||||
return '未知状态'
|
||||
}
|
||||
})
|
||||
|
||||
const connectionStatusClass = computed(() => {
|
||||
switch (connectionState.value) {
|
||||
case 'CONNECTED':
|
||||
return 'text-green-500'
|
||||
case 'CONNECTING':
|
||||
return 'text-yellow-500'
|
||||
case 'DISCONNECTED':
|
||||
return 'text-gray-500'
|
||||
case 'ERROR':
|
||||
return 'text-red-500'
|
||||
default:
|
||||
return 'text-gray-500'
|
||||
}
|
||||
})
|
||||
|
||||
const filteredConversations = computed(() => {
|
||||
if (!searchKeyword.value) return conversations.value
|
||||
|
||||
return conversations.value.filter(conv =>
|
||||
conv.title.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
|
||||
conv.lastMessage?.toLowerCase().includes(searchKeyword.value.toLowerCase())
|
||||
)
|
||||
})
|
||||
|
||||
// 方法
|
||||
const toggleSidebar = () => {
|
||||
appStore.toggleSidebar()
|
||||
}
|
||||
|
||||
const createNewConversation = () => {
|
||||
ElMessage.info('新建对话功能开发中...')
|
||||
}
|
||||
|
||||
const selectConversation = (conversationId: string) => {
|
||||
currentConversationId.value = conversationId
|
||||
// 这里可以加载对话历史
|
||||
ElMessage.success(`切换到对话: ${conversationId}`)
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
// 搜索逻辑已在计算属性中实现
|
||||
}
|
||||
|
||||
const handleConversationAction = (command: string) => {
|
||||
const [action, conversationId] = command.split('_')
|
||||
|
||||
switch (action) {
|
||||
case 'rename':
|
||||
ElMessage.info('重命名功能开发中...')
|
||||
break
|
||||
case 'archive':
|
||||
ElMessage.info('归档功能开发中...')
|
||||
break
|
||||
case 'delete':
|
||||
ElMessageBox.confirm(
|
||||
'确定要删除这个对话吗?',
|
||||
'提示',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
).then(() => {
|
||||
ElMessage.success('对话已删除')
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleUserAction = async (command: string) => {
|
||||
switch (command) {
|
||||
case 'profile':
|
||||
await router.push('/app/profile')
|
||||
break
|
||||
case 'settings':
|
||||
await router.push('/app/settings')
|
||||
break
|
||||
case 'logout':
|
||||
ElMessageBox.confirm(
|
||||
'确定要退出登录吗?',
|
||||
'提示',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
).then(() => {
|
||||
authStore.logout()
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
return formatRelativeTime(timestamp)
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
// 选择第一个对话
|
||||
if (conversations.value.length > 0) {
|
||||
currentConversationId.value = conversations.value[0].id
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.conversation-item {
|
||||
@apply cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-100;
|
||||
}
|
||||
|
||||
.conversation-item.active {
|
||||
@apply bg-blue-50 border-blue-200;
|
||||
}
|
||||
|
||||
.conversation-item:last-child {
|
||||
@apply border-b-0;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
min-width: 4rem;
|
||||
max-width: 20rem;
|
||||
}
|
||||
|
||||
.conversation-items {
|
||||
height: calc(100vh - 200px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,307 +0,0 @@
|
||||
<template>
|
||||
<div class="default-layout min-h-screen bg-gray-50">
|
||||
<!-- 顶部导航栏 -->
|
||||
<header class="header bg-white shadow-sm border-b border-gray-200 sticky top-0 z-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<!-- 左侧:Logo和导航 -->
|
||||
<div class="flex items-center space-x-8">
|
||||
<!-- Logo -->
|
||||
<router-link to="/home" class="flex items-center space-x-2">
|
||||
<img src="/logo.png" alt="情绪博物馆" class="h-8 w-8" />
|
||||
<span class="text-xl font-bold text-gray-900">情绪博物馆</span>
|
||||
</router-link>
|
||||
|
||||
<!-- 主导航 -->
|
||||
<nav class="hidden md:flex space-x-6">
|
||||
<router-link
|
||||
v-for="item in mainNavItems"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
class="nav-link"
|
||||
:class="{ 'active': isActiveRoute(item.path) }"
|
||||
>
|
||||
<el-icon class="mr-1">
|
||||
<component :is="item.icon" />
|
||||
</el-icon>
|
||||
{{ item.title }}
|
||||
</router-link>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:用户操作 -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- 搜索 -->
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索..."
|
||||
class="w-64 hidden lg:block"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
|
||||
<!-- 通知 -->
|
||||
<el-badge :value="unreadCount" :hidden="unreadCount === 0">
|
||||
<el-button circle @click="showNotifications">
|
||||
<el-icon><Bell /></el-icon>
|
||||
</el-button>
|
||||
</el-badge>
|
||||
|
||||
<!-- 主题切换 -->
|
||||
<el-button circle @click="toggleTheme">
|
||||
<el-icon>
|
||||
<Sunny v-if="!isDarkTheme" />
|
||||
<Moon v-else />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
|
||||
<!-- 用户菜单 -->
|
||||
<el-dropdown @command="handleUserCommand">
|
||||
<div class="flex items-center space-x-2 cursor-pointer hover:bg-gray-50 rounded-lg p-2">
|
||||
<el-avatar :src="userAvatar" :size="32">
|
||||
<el-icon><User /></el-icon>
|
||||
</el-avatar>
|
||||
<span class="hidden md:block text-sm font-medium text-gray-700">
|
||||
{{ userNickname }}
|
||||
</span>
|
||||
<el-icon class="text-gray-400"><ArrowDown /></el-icon>
|
||||
</div>
|
||||
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="profile">
|
||||
<el-icon><User /></el-icon>
|
||||
个人资料
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="settings">
|
||||
<el-icon><Setting /></el-icon>
|
||||
设置
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided command="logout">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
退出登录
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<main class="main-content">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- 面包屑导航 -->
|
||||
<el-breadcrumb v-if="showBreadcrumb" class="mb-6" separator="/">
|
||||
<el-breadcrumb-item
|
||||
v-for="item in breadcrumbItems"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
>
|
||||
<el-icon v-if="item.icon" class="mr-1">
|
||||
<component :is="item.icon" />
|
||||
</el-icon>
|
||||
{{ item.title }}
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
|
||||
<!-- 页面内容 -->
|
||||
<div class="content-wrapper">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 底部 -->
|
||||
<footer v-if="showFooter" class="footer bg-white border-t border-gray-200 mt-auto">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-4">关于我们</h3>
|
||||
<p class="text-sm text-gray-600">
|
||||
情绪博物馆致力于为用户提供情绪记录和心理健康服务。
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-4">功能</h3>
|
||||
<ul class="space-y-2 text-sm text-gray-600">
|
||||
<li><router-link to="/chat" class="hover:text-primary-600">AI对话</router-link></li>
|
||||
<li><router-link to="/app/diary" class="hover:text-primary-600">情绪日记</router-link></li>
|
||||
<li><router-link to="/app/analysis" class="hover:text-primary-600">情绪分析</router-link></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-4">支持</h3>
|
||||
<ul class="space-y-2 text-sm text-gray-600">
|
||||
<li><a href="#" class="hover:text-primary-600">帮助中心</a></li>
|
||||
<li><a href="#" class="hover:text-primary-600">联系我们</a></li>
|
||||
<li><a href="#" class="hover:text-primary-600">隐私政策</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-4">关注我们</h3>
|
||||
<div class="flex space-x-4">
|
||||
<a href="#" class="text-gray-400 hover:text-gray-500">
|
||||
<span class="sr-only">微信</span>
|
||||
<el-icon size="20"><ChatDotRound /></el-icon>
|
||||
</a>
|
||||
<a href="#" class="text-gray-400 hover:text-gray-500">
|
||||
<span class="sr-only">微博</span>
|
||||
<el-icon size="20"><Share /></el-icon>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-8 pt-8 border-t border-gray-200">
|
||||
<p class="text-center text-sm text-gray-500">
|
||||
© 2024 情绪博物馆. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Search,
|
||||
Bell,
|
||||
Sunny,
|
||||
Moon,
|
||||
User,
|
||||
ArrowDown,
|
||||
Setting,
|
||||
SwitchButton,
|
||||
House,
|
||||
ChatDotRound,
|
||||
EditPen,
|
||||
DataBoard,
|
||||
TrendCharts,
|
||||
Share
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { menuConfig } from '@/router'
|
||||
|
||||
// 状态管理
|
||||
const authStore = useAuthStore()
|
||||
const appStore = useAppStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 响应式数据
|
||||
const searchKeyword = ref('')
|
||||
const unreadCount = ref(0)
|
||||
|
||||
// 计算属性
|
||||
const userAvatar = computed(() => authStore.avatar)
|
||||
const userNickname = computed(() => authStore.nickname)
|
||||
const isDarkTheme = computed(() => appStore.isDarkTheme)
|
||||
const showBreadcrumb = computed(() => appStore.pageSettings.showBreadcrumb)
|
||||
const showFooter = computed(() => appStore.pageSettings.showFooter)
|
||||
|
||||
// 主导航项目
|
||||
const mainNavItems = computed(() => {
|
||||
return menuConfig.filter(item =>
|
||||
!item.requireAuth || authStore.isLoggedIn
|
||||
).filter(item => !item.children)
|
||||
})
|
||||
|
||||
// 面包屑导航
|
||||
const breadcrumbItems = computed(() => {
|
||||
const items = []
|
||||
const matched = route.matched.filter(item => item.meta && item.meta.title)
|
||||
|
||||
// 添加首页
|
||||
items.push({ title: '首页', path: '/home', icon: 'House' })
|
||||
|
||||
// 添加匹配的路由
|
||||
matched.forEach(match => {
|
||||
if (match.path !== '/home') {
|
||||
items.push({
|
||||
title: match.meta.title,
|
||||
path: match.path,
|
||||
icon: match.meta.icon
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
// 方法
|
||||
const isActiveRoute = (path: string) => {
|
||||
return route.path.startsWith(path)
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
if (searchKeyword.value.trim()) {
|
||||
router.push({
|
||||
path: '/search',
|
||||
query: { q: searchKeyword.value.trim() }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const showNotifications = () => {
|
||||
ElMessage.info('通知功能开发中...')
|
||||
}
|
||||
|
||||
const toggleTheme = () => {
|
||||
appStore.toggleTheme()
|
||||
}
|
||||
|
||||
const handleUserCommand = async (command: string) => {
|
||||
switch (command) {
|
||||
case 'profile':
|
||||
await router.push('/app/profile')
|
||||
break
|
||||
case 'settings':
|
||||
await router.push('/app/settings')
|
||||
break
|
||||
case 'logout':
|
||||
ElMessageBox.confirm(
|
||||
'确定要退出登录吗?',
|
||||
'提示',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
).then(() => {
|
||||
authStore.logout()
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 监听路由变化,更新页面标题
|
||||
watch(() => route.meta.title, (title) => {
|
||||
if (title) {
|
||||
document.title = `${title} - ${appStore.title}`
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nav-link {
|
||||
@apply flex items-center px-3 py-2 text-sm font-medium text-gray-700 rounded-md hover:text-primary-600 hover:bg-gray-50 transition-colors;
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
@apply text-primary-600 bg-primary-50;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
min-height: calc(100vh - 200px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,50 +0,0 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
// 样式导入
|
||||
import 'element-plus/dist/index.css'
|
||||
import './assets/styles/main.css'
|
||||
|
||||
// 组件导入
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
// import { i18n } from './i18n'
|
||||
|
||||
// 插件导入
|
||||
// import { registerGlobalComponents } from './plugins/global-components'
|
||||
// import { setupErrorHandler } from './plugins/error-handler'
|
||||
// import { setupProgressBar } from './plugins/progress-bar'
|
||||
|
||||
// 创建应用实例
|
||||
const app = createApp(App)
|
||||
|
||||
// 创建状态管理实例
|
||||
const pinia = createPinia()
|
||||
|
||||
// 注册插件
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
// app.use(i18n)
|
||||
|
||||
// 设置路由守卫
|
||||
// import { setupRouterGuards } from './router/guards'
|
||||
// setupRouterGuards(router, pinia)
|
||||
|
||||
// 注册全局组件
|
||||
// registerGlobalComponents(app)
|
||||
|
||||
// 设置错误处理
|
||||
// setupErrorHandler(app)
|
||||
|
||||
// 设置进度条
|
||||
// setupProgressBar(router)
|
||||
|
||||
// 挂载应用
|
||||
app.mount('#app')
|
||||
|
||||
// 开发环境下的调试信息
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('🚀 情绪博物馆 Web 应用启动成功')
|
||||
console.log('📦 Vue版本:', app.version)
|
||||
console.log('🔧 环境:', import.meta.env.MODE)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* 全局错误处理
|
||||
*/
|
||||
|
||||
import type { App } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { envConfig } from '@/config/env'
|
||||
|
||||
export function setupErrorHandler(app: App) {
|
||||
// Vue错误处理
|
||||
app.config.errorHandler = (error: any, instance, info) => {
|
||||
console.error('Vue Error:', error)
|
||||
console.error('Error Info:', info)
|
||||
|
||||
if (envConfig.debug) {
|
||||
ElMessage.error(`Vue错误: ${error.message}`)
|
||||
} else {
|
||||
ElMessage.error('应用出现错误,请刷新页面重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 全局未捕获的Promise错误
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
console.error('Unhandled Promise Rejection:', event.reason)
|
||||
|
||||
if (envConfig.debug) {
|
||||
ElMessage.error(`Promise错误: ${event.reason}`)
|
||||
}
|
||||
|
||||
// 阻止默认的错误处理
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
// 全局JavaScript错误
|
||||
window.addEventListener('error', (event) => {
|
||||
console.error('Global Error:', event.error)
|
||||
|
||||
if (envConfig.debug) {
|
||||
ElMessage.error(`JavaScript错误: ${event.error?.message}`)
|
||||
}
|
||||
})
|
||||
|
||||
console.log('✅ 错误处理器设置完成')
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* 全局组件注册
|
||||
*/
|
||||
|
||||
import type { App } from 'vue'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
|
||||
export function registerGlobalComponents(app: App) {
|
||||
// 注册Element Plus图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
console.log('✅ 全局组件注册完成')
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* 页面加载进度条
|
||||
*/
|
||||
|
||||
import type { Router } from 'vue-router'
|
||||
import NProgress from 'nprogress'
|
||||
import 'nprogress/nprogress.css'
|
||||
|
||||
// 配置NProgress
|
||||
NProgress.configure({
|
||||
showSpinner: false,
|
||||
trickleSpeed: 200,
|
||||
minimum: 0.3
|
||||
})
|
||||
|
||||
export function setupProgressBar(router: Router) {
|
||||
router.beforeEach((to, from, next) => {
|
||||
// 开始进度条
|
||||
NProgress.start()
|
||||
next()
|
||||
})
|
||||
|
||||
router.afterEach(() => {
|
||||
// 完成进度条
|
||||
NProgress.done()
|
||||
})
|
||||
|
||||
console.log('✅ 进度条设置完成')
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
/**
|
||||
* 路由守卫
|
||||
* 处理认证、权限、页面标题等
|
||||
*/
|
||||
|
||||
import type { Router } from 'vue-router'
|
||||
import type { Pinia } from 'pinia'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { envConfig } from '@/config/env'
|
||||
|
||||
export function setupRouterGuards(router: Router, pinia: Pinia) {
|
||||
// 全局前置守卫
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const authStore = useAuthStore(pinia)
|
||||
const appStore = useAppStore(pinia)
|
||||
|
||||
// 开始加载
|
||||
appStore.setLoading(true, '页面加载中...')
|
||||
|
||||
try {
|
||||
// 检查是否需要认证
|
||||
const requireAuth = to.meta.requireAuth !== false
|
||||
|
||||
// 如果需要认证但用户未登录
|
||||
if (requireAuth && !authStore.isLoggedIn) {
|
||||
// 检查是否有Token
|
||||
if (authStore.token) {
|
||||
try {
|
||||
// 尝试获取用户信息
|
||||
await authStore.checkAuthStatus()
|
||||
|
||||
// 认证成功,继续导航
|
||||
next()
|
||||
} catch (error) {
|
||||
console.warn('认证检查失败:', error)
|
||||
|
||||
// 认证失败,跳转到登录页
|
||||
ElMessage.warning('登录状态已过期,请重新登录')
|
||||
next({
|
||||
path: '/auth/login',
|
||||
query: { redirect: to.fullPath }
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// 没有Token,跳转到登录页
|
||||
next({
|
||||
path: '/auth/login',
|
||||
query: { redirect: to.fullPath }
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 如果已登录但访问认证页面,跳转到首页
|
||||
if (authStore.isLoggedIn && to.meta.hideForAuth) {
|
||||
next('/home')
|
||||
return
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if (to.meta.roles && to.meta.roles.length > 0) {
|
||||
const hasRole = to.meta.roles.some((role: string) =>
|
||||
authStore.hasRole(role)
|
||||
)
|
||||
|
||||
if (!hasRole) {
|
||||
ElMessage.error('权限不足,无法访问该页面')
|
||||
next('/403')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if (to.meta.permissions && to.meta.permissions.length > 0) {
|
||||
const hasPermission = to.meta.permissions.some((permission: string) =>
|
||||
authStore.hasPermission(permission)
|
||||
)
|
||||
|
||||
if (!hasPermission) {
|
||||
ElMessage.error('权限不足,无法访问该页面')
|
||||
next('/403')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 生产环境隐藏调试页面
|
||||
if (to.meta.hideInProduction && !envConfig.debug) {
|
||||
next('/404')
|
||||
return
|
||||
}
|
||||
|
||||
// 检查Token是否需要刷新
|
||||
if (authStore.isLoggedIn) {
|
||||
await authStore.refreshTokenIfNeeded()
|
||||
}
|
||||
|
||||
next()
|
||||
} catch (error) {
|
||||
console.error('路由守卫错误:', error)
|
||||
appStore.addError('页面加载失败', 'error')
|
||||
next('/404')
|
||||
}
|
||||
})
|
||||
|
||||
// 全局后置守卫
|
||||
router.afterEach((to, from) => {
|
||||
const appStore = useAppStore(pinia)
|
||||
|
||||
// 结束加载
|
||||
appStore.setLoading(false)
|
||||
|
||||
// 设置页面标题
|
||||
const title = to.meta.title as string
|
||||
if (title) {
|
||||
document.title = `${title} - ${appStore.title}`
|
||||
} else {
|
||||
document.title = appStore.title
|
||||
}
|
||||
|
||||
// 记录页面访问
|
||||
if (envConfig.debug) {
|
||||
console.log(`📄 页面访问: ${from.path} -> ${to.path}`)
|
||||
}
|
||||
|
||||
// 埋点统计(如果需要)
|
||||
// analytics.track('page_view', {
|
||||
// page: to.path,
|
||||
// title: to.meta.title
|
||||
// })
|
||||
})
|
||||
|
||||
// 路由错误处理
|
||||
router.onError((error) => {
|
||||
const appStore = useAppStore(pinia)
|
||||
|
||||
console.error('路由错误:', error)
|
||||
appStore.setLoading(false)
|
||||
appStore.addError('页面加载失败', 'error')
|
||||
})
|
||||
|
||||
console.log('✅ 路由守卫设置完成')
|
||||
}
|
||||
|
||||
// 扩展路由元信息类型
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
title?: string
|
||||
requireAuth?: boolean
|
||||
hideForAuth?: boolean
|
||||
roles?: string[]
|
||||
permissions?: string[]
|
||||
hideInProduction?: boolean
|
||||
layout?: string
|
||||
transition?: string
|
||||
icon?: string
|
||||
}
|
||||
}
|
||||
@@ -1,394 +0,0 @@
|
||||
/**
|
||||
* 路由配置
|
||||
* Vue Router 4.x 配置
|
||||
*/
|
||||
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { ROUTE_NAMES } from '@/config/constants'
|
||||
|
||||
// 路由懒加载
|
||||
const Home = () => import('@/views/Home.vue')
|
||||
const Login = () => import('@/views/auth/Login.vue')
|
||||
const Register = () => import('@/views/auth/Register.vue')
|
||||
const Chat = () => import('@/views/chat/Chat.vue')
|
||||
const ChatHistory = () => import('@/views/chat/ChatHistory.vue')
|
||||
const Diary = () => import('@/views/diary/Diary.vue')
|
||||
const DiaryEditor = () => import('@/views/diary/DiaryEditor.vue')
|
||||
const DiaryDetail = () => import('@/views/diary/DiaryDetail.vue')
|
||||
const PersonalDashboard = () => import('@/views/dashboard/PersonalDashboard.vue')
|
||||
const Profile = () => import('@/views/profile/Profile.vue')
|
||||
const Analysis = () => import('@/views/analysis/Analysis.vue')
|
||||
const LifeMilestones = () => import('@/views/milestones/LifeMilestones.vue')
|
||||
const LifeTrajectory = () => import('@/views/trajectory/LifeTrajectory.vue')
|
||||
const Messages = () => import('@/views/messages/Messages.vue')
|
||||
const Settings = () => import('@/views/settings/Settings.vue')
|
||||
const TopicTracker = () => import('@/views/topic/TopicTracker.vue')
|
||||
const EmotionManagement = () => import('@/views/emotion/EmotionManagement.vue')
|
||||
const EmotionMap = () => import('@/views/map/EmotionMap.vue')
|
||||
const SocialShare = () => import('@/views/social/SocialShare.vue')
|
||||
const Debug = () => import('@/views/debug/Debug.vue')
|
||||
const NotFound = () => import('@/views/error/NotFound.vue')
|
||||
const Forbidden = () => import('@/views/error/Forbidden.vue')
|
||||
|
||||
// 布局组件
|
||||
const DefaultLayout = () => import('@/layouts/DefaultLayout.vue')
|
||||
const AuthLayout = () => import('@/layouts/AuthLayout.vue')
|
||||
const ChatLayout = () => import('@/layouts/ChatLayout.vue')
|
||||
|
||||
export const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/home'
|
||||
},
|
||||
|
||||
// 首页
|
||||
{
|
||||
path: '/home',
|
||||
name: ROUTE_NAMES.HOME,
|
||||
component: Home,
|
||||
meta: {
|
||||
title: '首页',
|
||||
requireAuth: false,
|
||||
layout: 'default',
|
||||
transition: 'fade'
|
||||
}
|
||||
},
|
||||
|
||||
// 认证相关路由
|
||||
{
|
||||
path: '/auth',
|
||||
component: AuthLayout,
|
||||
children: [
|
||||
{
|
||||
path: 'login',
|
||||
name: ROUTE_NAMES.LOGIN,
|
||||
component: Login,
|
||||
meta: {
|
||||
title: '登录',
|
||||
requireAuth: false,
|
||||
hideForAuth: true,
|
||||
transition: 'slide-up'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'register',
|
||||
name: ROUTE_NAMES.REGISTER,
|
||||
component: Register,
|
||||
meta: {
|
||||
title: '注册',
|
||||
requireAuth: false,
|
||||
hideForAuth: true,
|
||||
transition: 'slide-up'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// 聊天相关路由
|
||||
{
|
||||
path: '/chat',
|
||||
component: ChatLayout,
|
||||
meta: {
|
||||
requireAuth: true
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: ROUTE_NAMES.CHAT,
|
||||
component: Chat,
|
||||
meta: {
|
||||
title: 'AI对话',
|
||||
icon: 'ChatDotRound',
|
||||
transition: 'slide-left'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'history',
|
||||
name: ROUTE_NAMES.CHAT_HISTORY,
|
||||
component: ChatHistory,
|
||||
meta: {
|
||||
title: '聊天历史',
|
||||
icon: 'Clock',
|
||||
transition: 'slide-left'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// 主要功能路由
|
||||
{
|
||||
path: '/app',
|
||||
component: DefaultLayout,
|
||||
meta: {
|
||||
requireAuth: true
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'diary',
|
||||
name: ROUTE_NAMES.DIARY,
|
||||
component: Diary,
|
||||
meta: {
|
||||
title: '情绪日记',
|
||||
icon: 'EditPen',
|
||||
transition: 'fade'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'diary/create',
|
||||
name: 'DiaryCreate',
|
||||
component: DiaryEditor,
|
||||
meta: {
|
||||
title: '写日记',
|
||||
icon: 'EditPen',
|
||||
transition: 'slide-up'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'diary/edit/:id',
|
||||
name: 'DiaryEdit',
|
||||
component: DiaryEditor,
|
||||
meta: {
|
||||
title: '编辑日记',
|
||||
icon: 'EditPen',
|
||||
transition: 'slide-up'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'diary/:id',
|
||||
name: 'DiaryDetail',
|
||||
component: DiaryDetail,
|
||||
meta: {
|
||||
title: '日记详情',
|
||||
icon: 'EditPen',
|
||||
transition: 'fade'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: ROUTE_NAMES.PERSONAL_DASHBOARD,
|
||||
component: PersonalDashboard,
|
||||
meta: {
|
||||
title: '个人仪表盘',
|
||||
icon: 'DataBoard',
|
||||
transition: 'fade'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
name: ROUTE_NAMES.PROFILE,
|
||||
component: Profile,
|
||||
meta: {
|
||||
title: '个人资料',
|
||||
icon: 'User',
|
||||
transition: 'slide-up'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'analysis',
|
||||
name: ROUTE_NAMES.ANALYSIS,
|
||||
component: Analysis,
|
||||
meta: {
|
||||
title: '情绪分析',
|
||||
icon: 'TrendCharts',
|
||||
transition: 'fade'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'milestones',
|
||||
name: 'LifeMilestones',
|
||||
component: LifeMilestones,
|
||||
meta: {
|
||||
title: '人生里程碑',
|
||||
icon: 'Trophy',
|
||||
transition: 'fade'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'trajectory',
|
||||
name: 'LifeTrajectory',
|
||||
component: LifeTrajectory,
|
||||
meta: {
|
||||
title: '人生轨迹',
|
||||
icon: 'Connection',
|
||||
transition: 'fade'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'messages',
|
||||
name: 'Messages',
|
||||
component: Messages,
|
||||
meta: {
|
||||
title: '消息中心',
|
||||
icon: 'Message',
|
||||
transition: 'slide-up'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: ROUTE_NAMES.SETTINGS,
|
||||
component: Settings,
|
||||
meta: {
|
||||
title: '设置',
|
||||
icon: 'Setting',
|
||||
transition: 'slide-up'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'topic-tracker',
|
||||
name: 'TopicTracker',
|
||||
component: TopicTracker,
|
||||
meta: {
|
||||
title: '话题追踪',
|
||||
icon: 'Search',
|
||||
transition: 'fade'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'emotion',
|
||||
name: 'EmotionManagement',
|
||||
component: EmotionManagement,
|
||||
meta: {
|
||||
title: '情绪管理',
|
||||
icon: 'Sunny',
|
||||
transition: 'fade'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'map',
|
||||
name: 'EmotionMap',
|
||||
component: EmotionMap,
|
||||
meta: {
|
||||
title: '情绪地图',
|
||||
icon: 'Location',
|
||||
transition: 'fade'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'social',
|
||||
name: 'SocialShare',
|
||||
component: SocialShare,
|
||||
meta: {
|
||||
title: '社交分享',
|
||||
icon: 'Share',
|
||||
transition: 'fade'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// 调试页面(仅开发环境)
|
||||
{
|
||||
path: '/debug',
|
||||
name: 'Debug',
|
||||
component: Debug,
|
||||
meta: {
|
||||
title: '调试页面',
|
||||
requireAuth: true,
|
||||
hideInProduction: true,
|
||||
transition: 'fade'
|
||||
}
|
||||
},
|
||||
|
||||
// 错误页面
|
||||
{
|
||||
path: '/403',
|
||||
name: 'Forbidden',
|
||||
component: Forbidden,
|
||||
meta: {
|
||||
title: '权限不足',
|
||||
requireAuth: false,
|
||||
transition: 'fade'
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
path: '/404',
|
||||
name: ROUTE_NAMES.NOT_FOUND,
|
||||
component: NotFound,
|
||||
meta: {
|
||||
title: '页面不存在',
|
||||
requireAuth: false,
|
||||
transition: 'fade'
|
||||
}
|
||||
},
|
||||
|
||||
// 捕获所有未匹配的路由
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/404'
|
||||
}
|
||||
]
|
||||
|
||||
// 导出路由配置
|
||||
export default routes
|
||||
|
||||
// 导出菜单配置(用于导航菜单生成)
|
||||
export const menuConfig = [
|
||||
{
|
||||
title: '首页',
|
||||
path: '/home',
|
||||
icon: 'House',
|
||||
requireAuth: false
|
||||
},
|
||||
{
|
||||
title: 'AI对话',
|
||||
path: '/chat',
|
||||
icon: 'ChatDotRound',
|
||||
requireAuth: true
|
||||
},
|
||||
{
|
||||
title: '情绪日记',
|
||||
path: '/app/diary',
|
||||
icon: 'EditPen',
|
||||
requireAuth: true
|
||||
},
|
||||
{
|
||||
title: '个人仪表盘',
|
||||
path: '/app/dashboard',
|
||||
icon: 'DataBoard',
|
||||
requireAuth: true
|
||||
},
|
||||
{
|
||||
title: '情绪分析',
|
||||
path: '/app/analysis',
|
||||
icon: 'TrendCharts',
|
||||
requireAuth: true
|
||||
},
|
||||
{
|
||||
title: '更多功能',
|
||||
icon: 'More',
|
||||
requireAuth: true,
|
||||
children: [
|
||||
{
|
||||
title: '人生里程碑',
|
||||
path: '/app/milestones',
|
||||
icon: 'Trophy'
|
||||
},
|
||||
{
|
||||
title: '人生轨迹',
|
||||
path: '/app/trajectory',
|
||||
icon: 'Connection'
|
||||
},
|
||||
{
|
||||
title: '话题追踪',
|
||||
path: '/app/topic-tracker',
|
||||
icon: 'Search'
|
||||
},
|
||||
{
|
||||
title: '情绪管理',
|
||||
path: '/app/emotion',
|
||||
icon: 'Sunny'
|
||||
},
|
||||
{
|
||||
title: '情绪地图',
|
||||
path: '/app/map',
|
||||
icon: 'Location'
|
||||
},
|
||||
{
|
||||
title: '社交分享',
|
||||
path: '/app/social',
|
||||
icon: 'Share'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1,411 +0,0 @@
|
||||
/**
|
||||
* 应用状态管理
|
||||
* 管理全局应用状态、主题、语言等
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ElMessage } from 'element-plus'
|
||||
// import { envConfig, validateConfig } from '@/config/env'
|
||||
// import { THEMES, LANGUAGES } from '@/config/constants'
|
||||
// import { getTheme, setTheme, getLanguage, setLanguage } from '@/utils/storage'
|
||||
|
||||
interface AppState {
|
||||
// 应用信息
|
||||
title: string
|
||||
version: string
|
||||
environment: string
|
||||
|
||||
// 加载状态
|
||||
isLoading: boolean
|
||||
loadingText: string
|
||||
|
||||
// 主题设置
|
||||
theme: string
|
||||
|
||||
// 语言设置
|
||||
language: string
|
||||
|
||||
// 设备信息
|
||||
device: {
|
||||
isMobile: boolean
|
||||
isTablet: boolean
|
||||
isDesktop: boolean
|
||||
userAgent: string
|
||||
}
|
||||
|
||||
// 网络状态
|
||||
isOnline: boolean
|
||||
|
||||
// 侧边栏状态
|
||||
sidebarCollapsed: boolean
|
||||
|
||||
// 页面设置
|
||||
pageSettings: {
|
||||
showBreadcrumb: boolean
|
||||
showTabs: boolean
|
||||
fixedHeader: boolean
|
||||
showFooter: boolean
|
||||
}
|
||||
|
||||
// 通知设置
|
||||
notifications: {
|
||||
desktop: boolean
|
||||
sound: boolean
|
||||
vibration: boolean
|
||||
}
|
||||
|
||||
// 错误信息
|
||||
errors: Array<{
|
||||
id: string
|
||||
message: string
|
||||
timestamp: number
|
||||
type: 'error' | 'warning' | 'info'
|
||||
}>
|
||||
}
|
||||
|
||||
export const useAppStore = defineStore('app', {
|
||||
state: (): AppState => ({
|
||||
// 应用信息
|
||||
title: envConfig.appTitle,
|
||||
version: envConfig.appVersion,
|
||||
environment: envConfig.name,
|
||||
|
||||
// 加载状态
|
||||
isLoading: false,
|
||||
loadingText: '加载中...',
|
||||
|
||||
// 主题设置
|
||||
theme: getTheme() || THEMES.LIGHT,
|
||||
|
||||
// 语言设置
|
||||
language: getLanguage() || LANGUAGES.ZH_CN,
|
||||
|
||||
// 设备信息
|
||||
device: {
|
||||
isMobile: false,
|
||||
isTablet: false,
|
||||
isDesktop: true,
|
||||
userAgent: navigator.userAgent
|
||||
},
|
||||
|
||||
// 网络状态
|
||||
isOnline: navigator.onLine,
|
||||
|
||||
// 侧边栏状态
|
||||
sidebarCollapsed: false,
|
||||
|
||||
// 页面设置
|
||||
pageSettings: {
|
||||
showBreadcrumb: true,
|
||||
showTabs: true,
|
||||
fixedHeader: true,
|
||||
showFooter: true
|
||||
},
|
||||
|
||||
// 通知设置
|
||||
notifications: {
|
||||
desktop: false,
|
||||
sound: true,
|
||||
vibration: true
|
||||
},
|
||||
|
||||
// 错误信息
|
||||
errors: []
|
||||
}),
|
||||
|
||||
getters: {
|
||||
/**
|
||||
* 是否为暗色主题
|
||||
*/
|
||||
isDarkTheme: (state) => state.theme === THEMES.DARK,
|
||||
|
||||
/**
|
||||
* 是否为移动端
|
||||
*/
|
||||
isMobileDevice: (state) => state.device.isMobile,
|
||||
|
||||
/**
|
||||
* 应用配置信息
|
||||
*/
|
||||
appInfo: (state) => ({
|
||||
title: state.title,
|
||||
version: state.version,
|
||||
environment: state.environment,
|
||||
buildTime: new Date().toISOString()
|
||||
}),
|
||||
|
||||
/**
|
||||
* 设备类型
|
||||
*/
|
||||
deviceType: (state) => {
|
||||
if (state.device.isMobile) return 'mobile'
|
||||
if (state.device.isTablet) return 'tablet'
|
||||
return 'desktop'
|
||||
},
|
||||
|
||||
/**
|
||||
* 未读错误数量
|
||||
*/
|
||||
unreadErrorCount: (state) => state.errors.length
|
||||
},
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* 初始化应用
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
// 验证配置
|
||||
if (!validateConfig()) {
|
||||
throw new Error('应用配置验证失败')
|
||||
}
|
||||
|
||||
// 检测设备类型
|
||||
this.detectDevice()
|
||||
|
||||
// 初始化主题
|
||||
this.initializeTheme()
|
||||
|
||||
// 监听网络状态
|
||||
this.setupNetworkListener()
|
||||
|
||||
// 请求通知权限
|
||||
await this.requestNotificationPermission()
|
||||
|
||||
console.log('✅ 应用初始化完成')
|
||||
} catch (error) {
|
||||
console.error('❌ 应用初始化失败:', error)
|
||||
this.addError('应用初始化失败', 'error')
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置加载状态
|
||||
*/
|
||||
setLoading(loading: boolean, text = '加载中...') {
|
||||
this.isLoading = loading
|
||||
this.loadingText = text
|
||||
},
|
||||
|
||||
/**
|
||||
* 切换主题
|
||||
*/
|
||||
toggleTheme() {
|
||||
const newTheme = this.theme === THEMES.LIGHT ? THEMES.DARK : THEMES.LIGHT
|
||||
this.setTheme(newTheme)
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置主题
|
||||
*/
|
||||
setTheme(theme: string) {
|
||||
if (!Object.values(THEMES).includes(theme as any)) {
|
||||
console.warn('无效的主题:', theme)
|
||||
return
|
||||
}
|
||||
|
||||
this.theme = theme
|
||||
setTheme(theme)
|
||||
|
||||
// 应用主题到DOM
|
||||
this.applyTheme(theme)
|
||||
|
||||
ElMessage.success(`已切换到${theme === THEMES.DARK ? '暗色' : '亮色'}主题`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置语言
|
||||
*/
|
||||
setLanguage(language: string) {
|
||||
if (!Object.values(LANGUAGES).includes(language as any)) {
|
||||
console.warn('无效的语言:', language)
|
||||
return
|
||||
}
|
||||
|
||||
this.language = language
|
||||
setLanguage(language)
|
||||
|
||||
// 这里可以触发i18n语言切换
|
||||
// i18n.global.locale = language
|
||||
|
||||
ElMessage.success('语言设置已更新')
|
||||
},
|
||||
|
||||
/**
|
||||
* 切换侧边栏
|
||||
*/
|
||||
toggleSidebar() {
|
||||
this.sidebarCollapsed = !this.sidebarCollapsed
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置侧边栏状态
|
||||
*/
|
||||
setSidebarCollapsed(collapsed: boolean) {
|
||||
this.sidebarCollapsed = collapsed
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新页面设置
|
||||
*/
|
||||
updatePageSettings(settings: Partial<AppState['pageSettings']>) {
|
||||
this.pageSettings = { ...this.pageSettings, ...settings }
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新通知设置
|
||||
*/
|
||||
updateNotificationSettings(settings: Partial<AppState['notifications']>) {
|
||||
this.notifications = { ...this.notifications, ...settings }
|
||||
},
|
||||
|
||||
/**
|
||||
* 添加错误信息
|
||||
*/
|
||||
addError(message: string, type: 'error' | 'warning' | 'info' = 'error') {
|
||||
const error = {
|
||||
id: Date.now().toString(),
|
||||
message,
|
||||
type,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
this.errors.unshift(error)
|
||||
|
||||
// 限制错误数量
|
||||
if (this.errors.length > 100) {
|
||||
this.errors = this.errors.slice(0, 100)
|
||||
}
|
||||
|
||||
// 显示错误消息
|
||||
if (type === 'error') {
|
||||
ElMessage.error(message)
|
||||
} else if (type === 'warning') {
|
||||
ElMessage.warning(message)
|
||||
} else {
|
||||
ElMessage.info(message)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除错误信息
|
||||
*/
|
||||
clearErrors() {
|
||||
this.errors = []
|
||||
},
|
||||
|
||||
/**
|
||||
* 移除指定错误
|
||||
*/
|
||||
removeError(id: string) {
|
||||
const index = this.errors.findIndex(error => error.id === id)
|
||||
if (index > -1) {
|
||||
this.errors.splice(index, 1)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 检测设备类型
|
||||
*/
|
||||
detectDevice() {
|
||||
const userAgent = navigator.userAgent.toLowerCase()
|
||||
const width = window.innerWidth
|
||||
|
||||
this.device = {
|
||||
userAgent: navigator.userAgent,
|
||||
isMobile: width <= 768 || /mobile|android|iphone|ipad|phone/i.test(userAgent),
|
||||
isTablet: width > 768 && width <= 1024,
|
||||
isDesktop: width > 1024
|
||||
}
|
||||
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener('resize', () => {
|
||||
const newWidth = window.innerWidth
|
||||
this.device.isMobile = newWidth <= 768
|
||||
this.device.isTablet = newWidth > 768 && newWidth <= 1024
|
||||
this.device.isDesktop = newWidth > 1024
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 初始化主题
|
||||
*/
|
||||
initializeTheme() {
|
||||
// 如果是自动主题,根据系统设置
|
||||
if (this.theme === THEMES.AUTO) {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
this.theme = prefersDark ? THEMES.DARK : THEMES.LIGHT
|
||||
}
|
||||
|
||||
this.applyTheme(this.theme)
|
||||
|
||||
// 监听系统主题变化
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||
if (this.theme === THEMES.AUTO) {
|
||||
const newTheme = e.matches ? THEMES.DARK : THEMES.LIGHT
|
||||
this.applyTheme(newTheme)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 应用主题到DOM
|
||||
*/
|
||||
applyTheme(theme: string) {
|
||||
const root = document.documentElement
|
||||
|
||||
if (theme === THEMES.DARK) {
|
||||
root.classList.add('dark')
|
||||
root.classList.remove('light')
|
||||
} else {
|
||||
root.classList.add('light')
|
||||
root.classList.remove('dark')
|
||||
}
|
||||
|
||||
// 更新meta标签
|
||||
const metaTheme = document.querySelector('meta[name="theme-color"]')
|
||||
if (metaTheme) {
|
||||
metaTheme.setAttribute('content', theme === THEMES.DARK ? '#1f2937' : '#ffffff')
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置网络状态监听
|
||||
*/
|
||||
setupNetworkListener() {
|
||||
window.addEventListener('online', () => {
|
||||
this.isOnline = true
|
||||
ElMessage.success('网络连接已恢复')
|
||||
})
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
this.isOnline = false
|
||||
ElMessage.warning('网络连接已断开')
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 请求通知权限
|
||||
*/
|
||||
async requestNotificationPermission() {
|
||||
if ('Notification' in window) {
|
||||
const permission = await Notification.requestPermission()
|
||||
this.notifications.desktop = permission === 'granted'
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 发送桌面通知
|
||||
*/
|
||||
sendNotification(title: string, options?: NotificationOptions) {
|
||||
if (this.notifications.desktop && 'Notification' in window) {
|
||||
new Notification(title, {
|
||||
icon: '/favicon.ico',
|
||||
badge: '/favicon.ico',
|
||||
...options
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,399 +0,0 @@
|
||||
/**
|
||||
* 认证状态管理
|
||||
* 管理用户登录、注册、Token等认证相关状态
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { UserInfo, LoginRequest, RegisterRequest } from '@/types/api'
|
||||
import { authApi } from '@/api/auth'
|
||||
import {
|
||||
getToken,
|
||||
setToken,
|
||||
removeToken,
|
||||
getRefreshToken,
|
||||
setRefreshToken,
|
||||
removeRefreshToken,
|
||||
getUserInfo,
|
||||
setUserInfo,
|
||||
removeUserInfo
|
||||
} from '@/utils/storage'
|
||||
import { getWebSocketInstance, destroyWebSocketInstance } from '@/utils/websocket'
|
||||
import router from '@/router'
|
||||
|
||||
interface AuthState {
|
||||
// 用户信息
|
||||
user: UserInfo | null
|
||||
|
||||
// Token信息
|
||||
token: string | null
|
||||
refreshToken: string | null
|
||||
tokenExpireTime: number | null
|
||||
|
||||
// 登录状态
|
||||
isLoggedIn: boolean
|
||||
isLoggingIn: boolean
|
||||
isRegistering: boolean
|
||||
|
||||
// 权限信息
|
||||
permissions: string[]
|
||||
roles: string[]
|
||||
|
||||
// 登录历史
|
||||
loginHistory: Array<{
|
||||
time: number
|
||||
ip?: string
|
||||
device?: string
|
||||
location?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: (): AuthState => ({
|
||||
user: getUserInfo(),
|
||||
token: getToken(),
|
||||
refreshToken: getRefreshToken(),
|
||||
tokenExpireTime: null,
|
||||
isLoggedIn: false,
|
||||
isLoggingIn: false,
|
||||
isRegistering: false,
|
||||
permissions: [],
|
||||
roles: [],
|
||||
loginHistory: []
|
||||
}),
|
||||
|
||||
getters: {
|
||||
/**
|
||||
* 用户ID
|
||||
*/
|
||||
userId: (state) => state.user?.id,
|
||||
|
||||
/**
|
||||
* 用户名
|
||||
*/
|
||||
username: (state) => state.user?.username,
|
||||
|
||||
/**
|
||||
* 用户昵称
|
||||
*/
|
||||
nickname: (state) => state.user?.nickname || state.user?.username,
|
||||
|
||||
/**
|
||||
* 用户头像
|
||||
*/
|
||||
avatar: (state) => state.user?.avatar || '/default-avatar.png',
|
||||
|
||||
/**
|
||||
* 是否有指定权限
|
||||
*/
|
||||
hasPermission: (state) => (permission: string) => {
|
||||
return state.permissions.includes(permission)
|
||||
},
|
||||
|
||||
/**
|
||||
* 是否有指定角色
|
||||
*/
|
||||
hasRole: (state) => (role: string) => {
|
||||
return state.roles.includes(role)
|
||||
},
|
||||
|
||||
/**
|
||||
* Token是否即将过期(30分钟内)
|
||||
*/
|
||||
isTokenExpiringSoon: (state) => {
|
||||
if (!state.tokenExpireTime) return false
|
||||
const now = Date.now()
|
||||
const thirtyMinutes = 30 * 60 * 1000
|
||||
return state.tokenExpireTime - now < thirtyMinutes
|
||||
},
|
||||
|
||||
/**
|
||||
* 用户完整信息
|
||||
*/
|
||||
userProfile: (state) => ({
|
||||
...state.user,
|
||||
isLoggedIn: state.isLoggedIn,
|
||||
permissions: state.permissions,
|
||||
roles: state.roles
|
||||
})
|
||||
},
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* 用户登录
|
||||
*/
|
||||
async login(loginData: LoginRequest) {
|
||||
try {
|
||||
this.isLoggingIn = true
|
||||
|
||||
const response = await authApi.login(loginData)
|
||||
|
||||
// 保存认证信息
|
||||
this.setAuthData(response)
|
||||
|
||||
// 建立WebSocket连接
|
||||
this.connectWebSocket()
|
||||
|
||||
// 记录登录历史
|
||||
this.addLoginHistory()
|
||||
|
||||
ElMessage.success('登录成功')
|
||||
|
||||
// 跳转到首页或之前访问的页面
|
||||
const redirect = router.currentRoute.value.query.redirect as string
|
||||
await router.push(redirect || '/home')
|
||||
|
||||
return response
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '登录失败')
|
||||
throw error
|
||||
} finally {
|
||||
this.isLoggingIn = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*/
|
||||
async register(registerData: RegisterRequest) {
|
||||
try {
|
||||
this.isRegistering = true
|
||||
|
||||
const response = await authApi.register(registerData)
|
||||
|
||||
// 注册成功后自动登录
|
||||
this.setAuthData(response)
|
||||
this.connectWebSocket()
|
||||
this.addLoginHistory()
|
||||
|
||||
ElMessage.success('注册成功')
|
||||
|
||||
await router.push('/home')
|
||||
|
||||
return response
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '注册失败')
|
||||
throw error
|
||||
} finally {
|
||||
this.isRegistering = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 用户登出
|
||||
*/
|
||||
async logout(showMessage = true) {
|
||||
try {
|
||||
// 调用登出接口
|
||||
if (this.token) {
|
||||
await authApi.logout()
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('登出接口调用失败:', error)
|
||||
} finally {
|
||||
// 清除本地数据
|
||||
this.clearAuthData()
|
||||
|
||||
// 断开WebSocket连接
|
||||
this.disconnectWebSocket()
|
||||
|
||||
if (showMessage) {
|
||||
ElMessage.success('已退出登录')
|
||||
}
|
||||
|
||||
// 跳转到登录页
|
||||
await router.push('/auth/login')
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 刷新Token
|
||||
*/
|
||||
async refreshToken() {
|
||||
try {
|
||||
if (!this.refreshToken) {
|
||||
throw new Error('没有刷新Token')
|
||||
}
|
||||
|
||||
const response = await authApi.refreshToken({
|
||||
refreshToken: this.refreshToken
|
||||
})
|
||||
|
||||
// 更新Token信息
|
||||
this.token = response.token
|
||||
this.refreshToken = response.refreshToken
|
||||
this.tokenExpireTime = Date.now() + response.expiresIn * 1000
|
||||
|
||||
// 保存到本地存储
|
||||
setToken(response.token)
|
||||
setRefreshToken(response.refreshToken)
|
||||
|
||||
// 更新WebSocket连接
|
||||
const wsInstance = getWebSocketInstance()
|
||||
wsInstance.updateToken(response.token)
|
||||
|
||||
console.log('✅ Token刷新成功')
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('❌ Token刷新失败:', error)
|
||||
// 刷新失败,执行登出
|
||||
await this.logout(false)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 检查认证状态
|
||||
*/
|
||||
async checkAuthStatus() {
|
||||
try {
|
||||
if (!this.token) {
|
||||
this.isLoggedIn = false
|
||||
return false
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
const userInfo = await authApi.getUserInfo()
|
||||
|
||||
this.user = userInfo
|
||||
this.isLoggedIn = true
|
||||
this.permissions = userInfo.permissions || []
|
||||
this.roles = userInfo.roles || []
|
||||
|
||||
// 保存用户信息
|
||||
setUserInfo(userInfo)
|
||||
|
||||
// 建立WebSocket连接
|
||||
this.connectWebSocket()
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.warn('认证状态检查失败:', error)
|
||||
this.clearAuthData()
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新用户信息
|
||||
*/
|
||||
async updateUserInfo(userInfo: Partial<UserInfo>) {
|
||||
if (this.user) {
|
||||
this.user = { ...this.user, ...userInfo }
|
||||
setUserInfo(this.user)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 检查Token是否需要刷新
|
||||
*/
|
||||
async refreshTokenIfNeeded() {
|
||||
if (this.isTokenExpiringSoon && this.refreshToken) {
|
||||
try {
|
||||
await this.refreshToken()
|
||||
} catch (error) {
|
||||
console.error('自动刷新Token失败:', error)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置认证数据
|
||||
*/
|
||||
setAuthData(data: {
|
||||
token: string
|
||||
refreshToken: string
|
||||
user: UserInfo
|
||||
expiresIn?: number
|
||||
}) {
|
||||
this.token = data.token
|
||||
this.refreshToken = data.refreshToken
|
||||
this.user = data.user
|
||||
this.isLoggedIn = true
|
||||
this.permissions = data.user.permissions || []
|
||||
this.roles = data.user.roles || []
|
||||
|
||||
if (data.expiresIn) {
|
||||
this.tokenExpireTime = Date.now() + data.expiresIn * 1000
|
||||
}
|
||||
|
||||
// 保存到本地存储
|
||||
setToken(data.token)
|
||||
setRefreshToken(data.refreshToken)
|
||||
setUserInfo(data.user)
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除认证数据
|
||||
*/
|
||||
clearAuthData() {
|
||||
this.user = null
|
||||
this.token = null
|
||||
this.refreshToken = null
|
||||
this.tokenExpireTime = null
|
||||
this.isLoggedIn = false
|
||||
this.permissions = []
|
||||
this.roles = []
|
||||
|
||||
// 清除本地存储
|
||||
removeToken()
|
||||
removeRefreshToken()
|
||||
removeUserInfo()
|
||||
},
|
||||
|
||||
/**
|
||||
* 连接WebSocket
|
||||
*/
|
||||
connectWebSocket() {
|
||||
if (this.token) {
|
||||
const wsInstance = getWebSocketInstance({
|
||||
onTokenExpired: () => {
|
||||
this.logout(false)
|
||||
}
|
||||
})
|
||||
wsInstance.connect(this.token)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 断开WebSocket连接
|
||||
*/
|
||||
disconnectWebSocket() {
|
||||
destroyWebSocketInstance()
|
||||
},
|
||||
|
||||
/**
|
||||
* 添加登录历史
|
||||
*/
|
||||
addLoginHistory() {
|
||||
const loginRecord = {
|
||||
time: Date.now(),
|
||||
ip: '', // 这里可以通过API获取
|
||||
device: navigator.userAgent,
|
||||
location: '' // 这里可以通过地理位置API获取
|
||||
}
|
||||
|
||||
this.loginHistory.unshift(loginRecord)
|
||||
|
||||
// 限制历史记录数量
|
||||
if (this.loginHistory.length > 10) {
|
||||
this.loginHistory = this.loginHistory.slice(0, 10)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 检查权限
|
||||
*/
|
||||
checkPermission(permission: string): boolean {
|
||||
return this.hasPermission(permission)
|
||||
},
|
||||
|
||||
/**
|
||||
* 检查角色
|
||||
*/
|
||||
checkRole(role: string): boolean {
|
||||
return this.hasRole(role)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,382 +0,0 @@
|
||||
/**
|
||||
* 通知状态管理
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElNotification } from 'element-plus'
|
||||
import storage from '@/utils/storage'
|
||||
import { STORAGE_KEYS } from '@/config/constants'
|
||||
|
||||
export interface NotificationItem {
|
||||
id: string
|
||||
type: 'message' | 'system' | 'user' | 'warning' | 'info' | 'success' | 'error'
|
||||
title: string
|
||||
message: string
|
||||
read: boolean
|
||||
createTime: number
|
||||
data?: any
|
||||
actions?: NotificationAction[]
|
||||
}
|
||||
|
||||
export interface NotificationAction {
|
||||
label: string
|
||||
action: string
|
||||
type?: 'primary' | 'success' | 'warning' | 'danger'
|
||||
}
|
||||
|
||||
export interface NotificationSettings {
|
||||
desktop: boolean
|
||||
sound: boolean
|
||||
vibration: boolean
|
||||
showInApp: boolean
|
||||
autoMarkRead: boolean
|
||||
maxCount: number
|
||||
}
|
||||
|
||||
export const useNotificationStore = defineStore('notification', () => {
|
||||
// 状态
|
||||
const notifications = ref<NotificationItem[]>([])
|
||||
const settings = ref<NotificationSettings>({
|
||||
desktop: true,
|
||||
sound: true,
|
||||
vibration: true,
|
||||
showInApp: true,
|
||||
autoMarkRead: false,
|
||||
maxCount: 100
|
||||
})
|
||||
const permission = ref<NotificationPermission>('default')
|
||||
|
||||
// 计算属性
|
||||
const unreadCount = computed(() => {
|
||||
return notifications.value.filter(n => !n.read).length
|
||||
})
|
||||
|
||||
const hasUnread = computed(() => {
|
||||
return unreadCount.value > 0
|
||||
})
|
||||
|
||||
const recentNotifications = computed(() => {
|
||||
return notifications.value
|
||||
.sort((a, b) => b.createTime - a.createTime)
|
||||
.slice(0, 10)
|
||||
})
|
||||
|
||||
// 方法
|
||||
const requestPermission = async (): Promise<boolean> => {
|
||||
if (!('Notification' in window)) {
|
||||
console.warn('浏览器不支持桌面通知')
|
||||
return false
|
||||
}
|
||||
|
||||
if (Notification.permission === 'granted') {
|
||||
permission.value = 'granted'
|
||||
return true
|
||||
}
|
||||
|
||||
if (Notification.permission === 'denied') {
|
||||
permission.value = 'denied'
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await Notification.requestPermission()
|
||||
permission.value = result
|
||||
return result === 'granted'
|
||||
} catch (error) {
|
||||
console.error('请求通知权限失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const addNotification = (notification: Omit<NotificationItem, 'id' | 'createTime' | 'read'>) => {
|
||||
const newNotification: NotificationItem = {
|
||||
id: generateId(),
|
||||
createTime: Date.now(),
|
||||
read: false,
|
||||
...notification
|
||||
}
|
||||
|
||||
// 添加到列表开头
|
||||
notifications.value.unshift(newNotification)
|
||||
|
||||
// 限制通知数量
|
||||
if (notifications.value.length > settings.value.maxCount) {
|
||||
notifications.value = notifications.value.slice(0, settings.value.maxCount)
|
||||
}
|
||||
|
||||
// 显示通知
|
||||
if (settings.value.showInApp) {
|
||||
showInAppNotification(newNotification)
|
||||
}
|
||||
|
||||
// 桌面通知
|
||||
if (settings.value.desktop && permission.value === 'granted') {
|
||||
showDesktopNotification(newNotification)
|
||||
}
|
||||
|
||||
// 声音提醒
|
||||
if (settings.value.sound) {
|
||||
playNotificationSound()
|
||||
}
|
||||
|
||||
// 震动提醒
|
||||
if (settings.value.vibration && 'vibrate' in navigator) {
|
||||
navigator.vibrate([200, 100, 200])
|
||||
}
|
||||
|
||||
// 保存到本地存储
|
||||
saveNotifications()
|
||||
|
||||
return newNotification
|
||||
}
|
||||
|
||||
const markAsRead = (notificationId: string) => {
|
||||
const notification = notifications.value.find(n => n.id === notificationId)
|
||||
if (notification && !notification.read) {
|
||||
notification.read = true
|
||||
saveNotifications()
|
||||
}
|
||||
}
|
||||
|
||||
const markAsUnread = (notificationId: string) => {
|
||||
const notification = notifications.value.find(n => n.id === notificationId)
|
||||
if (notification && notification.read) {
|
||||
notification.read = false
|
||||
saveNotifications()
|
||||
}
|
||||
}
|
||||
|
||||
const markAllAsRead = () => {
|
||||
notifications.value.forEach(n => {
|
||||
n.read = true
|
||||
})
|
||||
saveNotifications()
|
||||
}
|
||||
|
||||
const removeNotification = (notificationId: string) => {
|
||||
const index = notifications.value.findIndex(n => n.id === notificationId)
|
||||
if (index > -1) {
|
||||
notifications.value.splice(index, 1)
|
||||
saveNotifications()
|
||||
}
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
notifications.value = []
|
||||
saveNotifications()
|
||||
}
|
||||
|
||||
const clearRead = () => {
|
||||
notifications.value = notifications.value.filter(n => !n.read)
|
||||
saveNotifications()
|
||||
}
|
||||
|
||||
const updateSettings = (newSettings: Partial<NotificationSettings>) => {
|
||||
settings.value = { ...settings.value, ...newSettings }
|
||||
saveSettings()
|
||||
}
|
||||
|
||||
const showInAppNotification = (notification: NotificationItem) => {
|
||||
const type = getElNotificationType(notification.type)
|
||||
|
||||
ElNotification({
|
||||
title: notification.title,
|
||||
message: notification.message,
|
||||
type,
|
||||
duration: 4000,
|
||||
showClose: true,
|
||||
onClick: () => {
|
||||
markAsRead(notification.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const showDesktopNotification = (notification: NotificationItem) => {
|
||||
if (permission.value !== 'granted') return
|
||||
|
||||
const desktopNotification = new Notification(notification.title, {
|
||||
body: notification.message,
|
||||
icon: '/favicon.ico',
|
||||
tag: notification.id,
|
||||
requireInteraction: false
|
||||
})
|
||||
|
||||
desktopNotification.onclick = () => {
|
||||
markAsRead(notification.id)
|
||||
window.focus()
|
||||
desktopNotification.close()
|
||||
}
|
||||
|
||||
// 自动关闭
|
||||
setTimeout(() => {
|
||||
desktopNotification.close()
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
const playNotificationSound = () => {
|
||||
try {
|
||||
const audio = new Audio('/sounds/notification.mp3')
|
||||
audio.volume = 0.5
|
||||
audio.play().catch(error => {
|
||||
console.warn('播放通知声音失败:', error)
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('创建音频对象失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const getElNotificationType = (type: string): 'success' | 'warning' | 'info' | 'error' => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return 'success'
|
||||
case 'warning':
|
||||
return 'warning'
|
||||
case 'error':
|
||||
return 'error'
|
||||
default:
|
||||
return 'info'
|
||||
}
|
||||
}
|
||||
|
||||
const generateId = (): string => {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2)
|
||||
}
|
||||
|
||||
const saveNotifications = () => {
|
||||
try {
|
||||
storage.set(STORAGE_KEYS.NOTIFICATIONS, notifications.value)
|
||||
} catch (error) {
|
||||
console.error('保存通知失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadNotifications = () => {
|
||||
try {
|
||||
const saved = storage.get(STORAGE_KEYS.NOTIFICATIONS)
|
||||
if (saved && Array.isArray(saved)) {
|
||||
notifications.value = saved
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载通知失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const saveSettings = () => {
|
||||
try {
|
||||
storage.set(STORAGE_KEYS.NOTIFICATION_SETTINGS, settings.value)
|
||||
} catch (error) {
|
||||
console.error('保存通知设置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadSettings = () => {
|
||||
try {
|
||||
const saved = storage.get(STORAGE_KEYS.NOTIFICATION_SETTINGS)
|
||||
if (saved) {
|
||||
settings.value = { ...settings.value, ...saved }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载通知设置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 便捷方法
|
||||
const success = (title: string, message: string, data?: any) => {
|
||||
return addNotification({
|
||||
type: 'success',
|
||||
title,
|
||||
message,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
const error = (title: string, message: string, data?: any) => {
|
||||
return addNotification({
|
||||
type: 'error',
|
||||
title,
|
||||
message,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
const warning = (title: string, message: string, data?: any) => {
|
||||
return addNotification({
|
||||
type: 'warning',
|
||||
title,
|
||||
message,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
const info = (title: string, message: string, data?: any) => {
|
||||
return addNotification({
|
||||
type: 'info',
|
||||
title,
|
||||
message,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
const message = (title: string, message: string, data?: any) => {
|
||||
return addNotification({
|
||||
type: 'message',
|
||||
title,
|
||||
message,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
const system = (title: string, message: string, data?: any) => {
|
||||
return addNotification({
|
||||
type: 'system',
|
||||
title,
|
||||
message,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 初始化
|
||||
const init = async () => {
|
||||
loadSettings()
|
||||
loadNotifications()
|
||||
|
||||
if (settings.value.desktop) {
|
||||
await requestPermission()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
notifications,
|
||||
settings,
|
||||
permission,
|
||||
|
||||
// 计算属性
|
||||
unreadCount,
|
||||
hasUnread,
|
||||
recentNotifications,
|
||||
|
||||
// 方法
|
||||
requestPermission,
|
||||
addNotification,
|
||||
markAsRead,
|
||||
markAsUnread,
|
||||
markAllAsRead,
|
||||
removeNotification,
|
||||
clearAll,
|
||||
clearRead,
|
||||
updateSettings,
|
||||
|
||||
// 便捷方法
|
||||
success,
|
||||
error,
|
||||
warning,
|
||||
info,
|
||||
message,
|
||||
system,
|
||||
|
||||
// 初始化
|
||||
init
|
||||
}
|
||||
})
|
||||
@@ -1,316 +0,0 @@
|
||||
/**
|
||||
* API 相关类型定义
|
||||
*/
|
||||
|
||||
// 认证相关类型
|
||||
export interface LoginRequest {
|
||||
username: string
|
||||
password: string
|
||||
captcha?: string
|
||||
captchaId?: string
|
||||
rememberMe?: boolean
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string
|
||||
refreshToken: string
|
||||
user: UserInfo
|
||||
expiresIn: number
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
username: string
|
||||
password: string
|
||||
confirmPassword: string
|
||||
email: string
|
||||
phone?: string
|
||||
captcha: string
|
||||
captchaId: string
|
||||
inviteCode?: string
|
||||
}
|
||||
|
||||
export interface RegisterResponse {
|
||||
token: string
|
||||
refreshToken: string
|
||||
user: UserInfo
|
||||
}
|
||||
|
||||
export interface RefreshTokenRequest {
|
||||
refreshToken: string
|
||||
}
|
||||
|
||||
export interface RefreshTokenResponse {
|
||||
token: string
|
||||
refreshToken: string
|
||||
expiresIn: number
|
||||
}
|
||||
|
||||
export interface CaptchaResponse {
|
||||
captchaId: string
|
||||
captchaImage: string // base64 图片
|
||||
}
|
||||
|
||||
export interface OAuthLoginRequest {
|
||||
provider: 'wechat' | 'qq' | 'github' | 'google'
|
||||
code: string
|
||||
state?: string
|
||||
}
|
||||
|
||||
// 用户相关类型
|
||||
export interface UserInfo {
|
||||
id: string
|
||||
username: string
|
||||
nickname: string
|
||||
email: string
|
||||
phone?: string
|
||||
avatar: string
|
||||
gender?: 'male' | 'female' | 'unknown'
|
||||
birthday?: string
|
||||
location?: string
|
||||
bio?: string
|
||||
status: 'active' | 'inactive' | 'banned'
|
||||
roles: string[]
|
||||
permissions: string[]
|
||||
createTime: Timestamp
|
||||
updateTime: Timestamp
|
||||
lastLoginTime?: Timestamp
|
||||
}
|
||||
|
||||
export interface UpdateUserProfileRequest {
|
||||
nickname?: string
|
||||
email?: string
|
||||
phone?: string
|
||||
gender?: 'male' | 'female' | 'unknown'
|
||||
birthday?: string
|
||||
location?: string
|
||||
bio?: string
|
||||
}
|
||||
|
||||
export interface ChangePasswordRequest {
|
||||
oldPassword: string
|
||||
newPassword: string
|
||||
confirmPassword: string
|
||||
}
|
||||
|
||||
export interface UploadAvatarResponse {
|
||||
url: string
|
||||
thumbnailUrl?: string
|
||||
}
|
||||
|
||||
export interface VerifyEmailRequest {
|
||||
email: string
|
||||
code: string
|
||||
}
|
||||
|
||||
export interface SendEmailCodeRequest {
|
||||
email: string
|
||||
type: 'register' | 'reset_password' | 'verify_email'
|
||||
}
|
||||
|
||||
export interface VerifyPhoneRequest {
|
||||
phone: string
|
||||
code: string
|
||||
}
|
||||
|
||||
export interface SendPhoneCodeRequest {
|
||||
phone: string
|
||||
type: 'register' | 'reset_password' | 'verify_phone'
|
||||
}
|
||||
|
||||
// 用户成长数据类型
|
||||
export interface UserGrowthStats {
|
||||
totalDays: number
|
||||
totalMessages: number
|
||||
totalDiaries: number
|
||||
emotionDistribution: {
|
||||
[emotion: string]: number
|
||||
}
|
||||
weeklyActivity: {
|
||||
date: string
|
||||
count: number
|
||||
}[]
|
||||
monthlyTrend: {
|
||||
month: string
|
||||
messages: number
|
||||
diaries: number
|
||||
}[]
|
||||
achievements: Achievement[]
|
||||
}
|
||||
|
||||
export interface Achievement {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
unlockTime: Timestamp
|
||||
category: string
|
||||
}
|
||||
|
||||
// 对话相关类型
|
||||
export interface CreateConversationRequest {
|
||||
title?: string
|
||||
type: 'chat' | 'support'
|
||||
}
|
||||
|
||||
export interface ConversationInfo {
|
||||
id: string
|
||||
title: string
|
||||
type: 'chat' | 'support'
|
||||
userId: string
|
||||
status: 'active' | 'archived' | 'deleted'
|
||||
createTime: Timestamp
|
||||
updateTime: Timestamp
|
||||
lastMessageTime?: Timestamp
|
||||
messageCount: number
|
||||
}
|
||||
|
||||
export interface GetUserConversationsRequest extends PageRequest {
|
||||
status?: 'active' | 'archived'
|
||||
type?: 'chat' | 'support'
|
||||
}
|
||||
|
||||
// 消息相关类型
|
||||
export interface MessageInfo {
|
||||
id: string
|
||||
conversationId: string
|
||||
content: string
|
||||
type: 'text' | 'image' | 'file' | 'emoji' | 'system'
|
||||
senderId: string
|
||||
senderType: 'USER' | 'AI' | 'SYSTEM'
|
||||
senderName: string
|
||||
senderAvatar?: string
|
||||
status: 'sending' | 'sent' | 'delivered' | 'read' | 'failed'
|
||||
timestamp: Timestamp
|
||||
replyTo?: string
|
||||
metadata?: {
|
||||
fileSize?: number
|
||||
fileName?: string
|
||||
imageWidth?: number
|
||||
imageHeight?: number
|
||||
emotion?: string
|
||||
confidence?: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface GetUserMessagesRequest extends PageRequest {
|
||||
conversationId?: string
|
||||
type?: 'text' | 'image' | 'file' | 'emoji' | 'system'
|
||||
startTime?: Timestamp
|
||||
endTime?: Timestamp
|
||||
}
|
||||
|
||||
export interface SearchUserMessagesRequest extends PageRequest {
|
||||
keyword: string
|
||||
conversationId?: string
|
||||
type?: 'text' | 'image' | 'file' | 'emoji' | 'system'
|
||||
startTime?: Timestamp
|
||||
endTime?: Timestamp
|
||||
}
|
||||
|
||||
export interface GetRecentMessagesRequest {
|
||||
limit?: number
|
||||
conversationId?: string
|
||||
}
|
||||
|
||||
// 日记相关类型
|
||||
export interface DiaryPost {
|
||||
id: string
|
||||
userId: string
|
||||
title: string
|
||||
content: string
|
||||
emotion: string
|
||||
mood: number // 1-10
|
||||
weather?: string
|
||||
location?: string
|
||||
tags: string[]
|
||||
images: string[]
|
||||
isPublic: boolean
|
||||
status: 'draft' | 'published' | 'archived'
|
||||
createTime: Timestamp
|
||||
updateTime: Timestamp
|
||||
viewCount: number
|
||||
likeCount: number
|
||||
commentCount: number
|
||||
aiComment?: {
|
||||
content: string
|
||||
emotion: string
|
||||
suggestions: string[]
|
||||
generateTime: Timestamp
|
||||
}
|
||||
}
|
||||
|
||||
export interface PublishDiaryRequest {
|
||||
title: string
|
||||
content: string
|
||||
emotion: string
|
||||
mood: number
|
||||
weather?: string
|
||||
location?: string
|
||||
tags: string[]
|
||||
images: string[]
|
||||
isPublic: boolean
|
||||
}
|
||||
|
||||
export interface GetUserDiariesRequest extends PageRequest {
|
||||
status?: 'draft' | 'published' | 'archived'
|
||||
emotion?: string
|
||||
startTime?: Timestamp
|
||||
endTime?: Timestamp
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
// WebSocket 消息类型
|
||||
export interface WSMessage<T = any> {
|
||||
type: string
|
||||
data: T
|
||||
timestamp: Timestamp
|
||||
messageId: string
|
||||
}
|
||||
|
||||
export interface WSChatMessage {
|
||||
conversationId: string
|
||||
content: string
|
||||
type: 'text' | 'image' | 'file' | 'emoji'
|
||||
replyTo?: string
|
||||
metadata?: any
|
||||
}
|
||||
|
||||
export interface WSTypingMessage {
|
||||
conversationId: string
|
||||
isTyping: boolean
|
||||
}
|
||||
|
||||
export interface WSNotificationMessage {
|
||||
id: string
|
||||
type: 'system' | 'chat' | 'diary' | 'achievement'
|
||||
title: string
|
||||
content: string
|
||||
data?: any
|
||||
}
|
||||
|
||||
// 文件上传类型
|
||||
export interface UploadFileRequest {
|
||||
file: File
|
||||
type: 'avatar' | 'image' | 'document'
|
||||
category?: string
|
||||
}
|
||||
|
||||
export interface UploadFileResponse {
|
||||
id: string
|
||||
name: string
|
||||
size: number
|
||||
type: string
|
||||
url: string
|
||||
thumbnailUrl?: string
|
||||
uploadTime: Timestamp
|
||||
}
|
||||
|
||||
// 错误响应类型
|
||||
export interface ErrorResponse {
|
||||
code: number
|
||||
message: string
|
||||
details?: any
|
||||
timestamp: Timestamp
|
||||
path?: string
|
||||
method?: string
|
||||
}
|
||||
Vendored
-228
@@ -1,228 +0,0 @@
|
||||
/**
|
||||
* 全局类型定义
|
||||
*/
|
||||
|
||||
declare global {
|
||||
// 环境变量类型
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_ENV: string
|
||||
readonly VITE_APP_TITLE: string
|
||||
readonly VITE_APP_VERSION: string
|
||||
readonly VITE_API_BASE_URL: string
|
||||
readonly VITE_WS_BASE_URL: string
|
||||
readonly VITE_UPLOAD_URL: string
|
||||
readonly VITE_DEBUG: string
|
||||
readonly VITE_MOCK: string
|
||||
readonly VITE_APP_DESCRIPTION: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
// 窗口对象扩展
|
||||
interface Window {
|
||||
// 全局配置
|
||||
__APP_CONFIG__?: {
|
||||
version: string
|
||||
buildTime: string
|
||||
env: string
|
||||
}
|
||||
|
||||
// 第三方库
|
||||
AMap?: any
|
||||
BMap?: any
|
||||
|
||||
// 调试工具
|
||||
__VUE_DEVTOOLS_GLOBAL_HOOK__?: any
|
||||
}
|
||||
|
||||
// 通用响应类型
|
||||
interface ApiResponse<T = any> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
success: boolean
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
// 分页响应类型
|
||||
interface PageResponse<T = any> {
|
||||
list: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
// 分页请求类型
|
||||
interface PageRequest {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
sortBy?: string
|
||||
sortOrder?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
// 通用ID类型
|
||||
type ID = string | number
|
||||
|
||||
// 时间戳类型
|
||||
type Timestamp = number
|
||||
|
||||
// 文件类型
|
||||
interface FileInfo {
|
||||
id: string
|
||||
name: string
|
||||
size: number
|
||||
type: string
|
||||
url: string
|
||||
uploadTime: Timestamp
|
||||
}
|
||||
|
||||
// 坐标类型
|
||||
interface Coordinate {
|
||||
latitude: number
|
||||
longitude: number
|
||||
}
|
||||
|
||||
// 键值对类型
|
||||
interface KeyValue<T = any> {
|
||||
[key: string]: T
|
||||
}
|
||||
|
||||
// 选项类型
|
||||
interface Option<T = any> {
|
||||
label: string
|
||||
value: T
|
||||
disabled?: boolean
|
||||
children?: Option<T>[]
|
||||
}
|
||||
|
||||
// 菜单项类型
|
||||
interface MenuItem {
|
||||
id: string
|
||||
title: string
|
||||
icon?: string
|
||||
path?: string
|
||||
children?: MenuItem[]
|
||||
meta?: {
|
||||
requireAuth?: boolean
|
||||
roles?: string[]
|
||||
hidden?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
// 面包屑类型
|
||||
interface BreadcrumbItem {
|
||||
title: string
|
||||
path?: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
// 表格列类型
|
||||
interface TableColumn {
|
||||
prop: string
|
||||
label: string
|
||||
width?: number | string
|
||||
minWidth?: number | string
|
||||
fixed?: boolean | 'left' | 'right'
|
||||
sortable?: boolean
|
||||
formatter?: (row: any, column: any, cellValue: any) => string
|
||||
}
|
||||
|
||||
// 表单规则类型
|
||||
interface FormRule {
|
||||
required?: boolean
|
||||
message?: string
|
||||
trigger?: string | string[]
|
||||
min?: number
|
||||
max?: number
|
||||
pattern?: RegExp
|
||||
validator?: (rule: any, value: any, callback: any) => void
|
||||
}
|
||||
|
||||
// 图表数据类型
|
||||
interface ChartData {
|
||||
name: string
|
||||
value: number
|
||||
color?: string
|
||||
}
|
||||
|
||||
// 统计数据类型
|
||||
interface StatData {
|
||||
title: string
|
||||
value: number | string
|
||||
unit?: string
|
||||
trend?: 'up' | 'down' | 'stable'
|
||||
trendValue?: number
|
||||
icon?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
// 通知类型
|
||||
interface NotificationData {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
type: 'info' | 'success' | 'warning' | 'error'
|
||||
read: boolean
|
||||
createTime: Timestamp
|
||||
link?: string
|
||||
}
|
||||
|
||||
// 错误信息类型
|
||||
interface ErrorInfo {
|
||||
code: string | number
|
||||
message: string
|
||||
details?: any
|
||||
stack?: string
|
||||
timestamp: Timestamp
|
||||
}
|
||||
|
||||
// 主题配置类型
|
||||
interface ThemeConfig {
|
||||
primaryColor: string
|
||||
backgroundColor: string
|
||||
textColor: string
|
||||
borderColor: string
|
||||
shadowColor: string
|
||||
}
|
||||
|
||||
// 用户偏好设置类型
|
||||
interface UserPreferences {
|
||||
theme: 'light' | 'dark' | 'auto'
|
||||
language: string
|
||||
timezone: string
|
||||
notifications: {
|
||||
email: boolean
|
||||
push: boolean
|
||||
sms: boolean
|
||||
}
|
||||
}
|
||||
|
||||
// 设备信息类型
|
||||
interface DeviceInfo {
|
||||
userAgent: string
|
||||
platform: string
|
||||
isMobile: boolean
|
||||
isTablet: boolean
|
||||
isDesktop: boolean
|
||||
browser: string
|
||||
browserVersion: string
|
||||
os: string
|
||||
osVersion: string
|
||||
}
|
||||
|
||||
// 地理位置类型
|
||||
interface LocationInfo {
|
||||
country: string
|
||||
province: string
|
||||
city: string
|
||||
district?: string
|
||||
address?: string
|
||||
coordinate?: Coordinate
|
||||
}
|
||||
}
|
||||
|
||||
// 确保这个文件被当作模块处理
|
||||
export {}
|
||||
@@ -1,353 +0,0 @@
|
||||
/**
|
||||
* 格式化工具函数
|
||||
* 提供日期、数字、文件大小等格式化功能
|
||||
*/
|
||||
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import duration from 'dayjs/plugin/duration'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
|
||||
// 扩展dayjs插件
|
||||
dayjs.extend(relativeTime)
|
||||
dayjs.extend(duration)
|
||||
dayjs.locale('zh-cn')
|
||||
|
||||
/**
|
||||
* 格式化日期时间
|
||||
*/
|
||||
export const formatDateTime = (
|
||||
date: string | number | Date,
|
||||
format = 'YYYY-MM-DD HH:mm:ss'
|
||||
): string => {
|
||||
if (!date) return ''
|
||||
return dayjs(date).format(format)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期
|
||||
*/
|
||||
export const formatDate = (date: string | number | Date): string => {
|
||||
return formatDateTime(date, 'YYYY-MM-DD')
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间
|
||||
*/
|
||||
export const formatTime = (date: string | number | Date): string => {
|
||||
return formatDateTime(date, 'HH:mm:ss')
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化相对时间
|
||||
*/
|
||||
export const formatRelativeTime = (date: string | number | Date): string => {
|
||||
if (!date) return ''
|
||||
return dayjs(date).fromNow()
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化持续时间
|
||||
*/
|
||||
export const formatDuration = (milliseconds: number): string => {
|
||||
const duration = dayjs.duration(milliseconds)
|
||||
|
||||
if (duration.asHours() >= 1) {
|
||||
return duration.format('H小时m分钟')
|
||||
} else if (duration.asMinutes() >= 1) {
|
||||
return duration.format('m分钟s秒')
|
||||
} else {
|
||||
return duration.format('s秒')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
*/
|
||||
export const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化数字
|
||||
*/
|
||||
export const formatNumber = (
|
||||
num: number,
|
||||
options: {
|
||||
decimals?: number
|
||||
separator?: string
|
||||
prefix?: string
|
||||
suffix?: string
|
||||
} = {}
|
||||
): string => {
|
||||
const {
|
||||
decimals = 0,
|
||||
separator = ',',
|
||||
prefix = '',
|
||||
suffix = ''
|
||||
} = options
|
||||
|
||||
const parts = num.toFixed(decimals).split('.')
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, separator)
|
||||
|
||||
return prefix + parts.join('.') + suffix
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化百分比
|
||||
*/
|
||||
export const formatPercentage = (
|
||||
value: number,
|
||||
total: number,
|
||||
decimals = 1
|
||||
): string => {
|
||||
if (total === 0) return '0%'
|
||||
const percentage = (value / total) * 100
|
||||
return `${percentage.toFixed(decimals)}%`
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化货币
|
||||
*/
|
||||
export const formatCurrency = (
|
||||
amount: number,
|
||||
currency = '¥',
|
||||
decimals = 2
|
||||
): string => {
|
||||
return currency + formatNumber(amount, { decimals, separator: ',' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化手机号
|
||||
*/
|
||||
export const formatPhone = (phone: string): string => {
|
||||
if (!phone) return ''
|
||||
|
||||
// 移除所有非数字字符
|
||||
const cleaned = phone.replace(/\D/g, '')
|
||||
|
||||
// 中国手机号格式化
|
||||
if (cleaned.length === 11) {
|
||||
return cleaned.replace(/(\d{3})(\d{4})(\d{4})/, '$1 $2 $3')
|
||||
}
|
||||
|
||||
return phone
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化身份证号
|
||||
*/
|
||||
export const formatIdCard = (idCard: string): string => {
|
||||
if (!idCard) return ''
|
||||
|
||||
// 移除所有非字母数字字符
|
||||
const cleaned = idCard.replace(/[^0-9X]/gi, '')
|
||||
|
||||
if (cleaned.length === 18) {
|
||||
return cleaned.replace(/(\d{6})(\d{8})(\d{3}[0-9X])/i, '$1 $2 $3')
|
||||
}
|
||||
|
||||
return idCard
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化银行卡号
|
||||
*/
|
||||
export const formatBankCard = (cardNumber: string): string => {
|
||||
if (!cardNumber) return ''
|
||||
|
||||
// 移除所有非数字字符
|
||||
const cleaned = cardNumber.replace(/\D/g, '')
|
||||
|
||||
// 每4位添加一个空格
|
||||
return cleaned.replace(/(\d{4})(?=\d)/g, '$1 ')
|
||||
}
|
||||
|
||||
/**
|
||||
* 脱敏处理
|
||||
*/
|
||||
export const maskString = (
|
||||
str: string,
|
||||
start = 3,
|
||||
end = 4,
|
||||
mask = '*'
|
||||
): string => {
|
||||
if (!str || str.length <= start + end) return str
|
||||
|
||||
const startStr = str.substring(0, start)
|
||||
const endStr = str.substring(str.length - end)
|
||||
const maskStr = mask.repeat(str.length - start - end)
|
||||
|
||||
return startStr + maskStr + endStr
|
||||
}
|
||||
|
||||
/**
|
||||
* 脱敏手机号
|
||||
*/
|
||||
export const maskPhone = (phone: string): string => {
|
||||
return maskString(phone, 3, 4)
|
||||
}
|
||||
|
||||
/**
|
||||
* 脱敏邮箱
|
||||
*/
|
||||
export const maskEmail = (email: string): string => {
|
||||
if (!email || !email.includes('@')) return email
|
||||
|
||||
const [username, domain] = email.split('@')
|
||||
const maskedUsername = maskString(username, 1, 1)
|
||||
|
||||
return `${maskedUsername}@${domain}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 脱敏身份证
|
||||
*/
|
||||
export const maskIdCard = (idCard: string): string => {
|
||||
return maskString(idCard, 6, 4)
|
||||
}
|
||||
|
||||
/**
|
||||
* 脱敏银行卡
|
||||
*/
|
||||
export const maskBankCard = (cardNumber: string): string => {
|
||||
return maskString(cardNumber, 4, 4)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化地址
|
||||
*/
|
||||
export const formatAddress = (
|
||||
province?: string,
|
||||
city?: string,
|
||||
district?: string,
|
||||
detail?: string
|
||||
): string => {
|
||||
const parts = [province, city, district, detail].filter(Boolean)
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断文本
|
||||
*/
|
||||
export const truncateText = (
|
||||
text: string,
|
||||
maxLength: number,
|
||||
suffix = '...'
|
||||
): string => {
|
||||
if (!text || text.length <= maxLength) return text
|
||||
|
||||
return text.substring(0, maxLength - suffix.length) + suffix
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化JSON
|
||||
*/
|
||||
export const formatJSON = (obj: any, indent = 2): string => {
|
||||
try {
|
||||
return JSON.stringify(obj, null, indent)
|
||||
} catch {
|
||||
return String(obj)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化URL参数
|
||||
*/
|
||||
export const formatUrlParams = (params: Record<string, any>): string => {
|
||||
const searchParams = new URLSearchParams()
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== null && value !== undefined && value !== '') {
|
||||
searchParams.append(key, String(value))
|
||||
}
|
||||
})
|
||||
|
||||
return searchParams.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析URL参数
|
||||
*/
|
||||
export const parseUrlParams = (url: string): Record<string, string> => {
|
||||
const params: Record<string, string> = {}
|
||||
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
urlObj.searchParams.forEach((value, key) => {
|
||||
params[key] = value
|
||||
})
|
||||
} catch {
|
||||
// 如果不是完整URL,尝试解析查询字符串
|
||||
const queryString = url.includes('?') ? url.split('?')[1] : url
|
||||
const searchParams = new URLSearchParams(queryString)
|
||||
searchParams.forEach((value, key) => {
|
||||
params[key] = value
|
||||
})
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化HTML为纯文本
|
||||
*/
|
||||
export const formatHtmlToText = (html: string): string => {
|
||||
if (!html) return ''
|
||||
|
||||
// 创建临时DOM元素
|
||||
const temp = document.createElement('div')
|
||||
temp.innerHTML = html
|
||||
|
||||
return temp.textContent || temp.innerText || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化换行符为HTML
|
||||
*/
|
||||
export const formatTextToHtml = (text: string): string => {
|
||||
if (!text) return ''
|
||||
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/\n/g, '<br>')
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化颜色值
|
||||
*/
|
||||
export const formatColor = (color: string): string => {
|
||||
if (!color) return ''
|
||||
|
||||
// 如果是hex颜色,确保有#前缀
|
||||
if (/^[0-9A-F]{6}$/i.test(color)) {
|
||||
return `#${color}`
|
||||
}
|
||||
|
||||
return color
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化版本号
|
||||
*/
|
||||
export const formatVersion = (version: string): string => {
|
||||
if (!version) return ''
|
||||
|
||||
// 确保版本号格式为 x.y.z
|
||||
const parts = version.split('.')
|
||||
while (parts.length < 3) {
|
||||
parts.push('0')
|
||||
}
|
||||
|
||||
return parts.slice(0, 3).join('.')
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
/**
|
||||
* 性能优化工具
|
||||
*/
|
||||
|
||||
// 防抖函数
|
||||
export function debounce<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
wait: number,
|
||||
immediate = false
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: NodeJS.Timeout | null = null
|
||||
|
||||
return function executedFunction(...args: Parameters<T>) {
|
||||
const later = () => {
|
||||
timeout = null
|
||||
if (!immediate) func(...args)
|
||||
}
|
||||
|
||||
const callNow = immediate && !timeout
|
||||
|
||||
if (timeout) clearTimeout(timeout)
|
||||
timeout = setTimeout(later, wait)
|
||||
|
||||
if (callNow) func(...args)
|
||||
}
|
||||
}
|
||||
|
||||
// 节流函数
|
||||
export function throttle<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
limit: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let inThrottle: boolean
|
||||
|
||||
return function executedFunction(...args: Parameters<T>) {
|
||||
if (!inThrottle) {
|
||||
func.apply(this, args)
|
||||
inThrottle = true
|
||||
setTimeout(() => inThrottle = false, limit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 延迟执行
|
||||
export function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
// 空闲时执行
|
||||
export function requestIdleCallback(callback: () => void, timeout = 5000): void {
|
||||
if ('requestIdleCallback' in window) {
|
||||
window.requestIdleCallback(callback, { timeout })
|
||||
} else {
|
||||
setTimeout(callback, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 图片懒加载
|
||||
export class LazyImageLoader {
|
||||
private observer: IntersectionObserver | null = null
|
||||
private images: Set<HTMLImageElement> = new Set()
|
||||
|
||||
constructor(options: IntersectionObserverInit = {}) {
|
||||
if ('IntersectionObserver' in window) {
|
||||
this.observer = new IntersectionObserver(
|
||||
this.handleIntersection.bind(this),
|
||||
{
|
||||
rootMargin: '50px',
|
||||
threshold: 0.1,
|
||||
...options
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
observe(img: HTMLImageElement): void {
|
||||
if (this.observer) {
|
||||
this.images.add(img)
|
||||
this.observer.observe(img)
|
||||
} else {
|
||||
// 降级处理
|
||||
this.loadImage(img)
|
||||
}
|
||||
}
|
||||
|
||||
unobserve(img: HTMLImageElement): void {
|
||||
if (this.observer) {
|
||||
this.images.delete(img)
|
||||
this.observer.unobserve(img)
|
||||
}
|
||||
}
|
||||
|
||||
private handleIntersection(entries: IntersectionObserverEntry[]): void {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target as HTMLImageElement
|
||||
this.loadImage(img)
|
||||
this.unobserve(img)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private loadImage(img: HTMLImageElement): void {
|
||||
const src = img.dataset.src
|
||||
if (src) {
|
||||
img.src = src
|
||||
img.removeAttribute('data-src')
|
||||
img.classList.add('loaded')
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.observer) {
|
||||
this.observer.disconnect()
|
||||
this.images.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 虚拟滚动
|
||||
export class VirtualScroller {
|
||||
private container: HTMLElement
|
||||
private items: any[]
|
||||
private itemHeight: number
|
||||
private visibleCount: number
|
||||
private startIndex = 0
|
||||
private endIndex = 0
|
||||
private scrollTop = 0
|
||||
|
||||
constructor(
|
||||
container: HTMLElement,
|
||||
items: any[],
|
||||
itemHeight: number,
|
||||
renderItem: (item: any, index: number) => HTMLElement
|
||||
) {
|
||||
this.container = container
|
||||
this.items = items
|
||||
this.itemHeight = itemHeight
|
||||
this.visibleCount = Math.ceil(container.clientHeight / itemHeight) + 2
|
||||
|
||||
this.setupContainer()
|
||||
this.bindEvents()
|
||||
this.updateVisibleItems()
|
||||
}
|
||||
|
||||
private setupContainer(): void {
|
||||
this.container.style.position = 'relative'
|
||||
this.container.style.overflow = 'auto'
|
||||
|
||||
// 创建占位元素
|
||||
const spacer = document.createElement('div')
|
||||
spacer.style.height = `${this.items.length * this.itemHeight}px`
|
||||
spacer.style.position = 'absolute'
|
||||
spacer.style.top = '0'
|
||||
spacer.style.left = '0'
|
||||
spacer.style.width = '1px'
|
||||
spacer.style.pointerEvents = 'none'
|
||||
this.container.appendChild(spacer)
|
||||
}
|
||||
|
||||
private bindEvents(): void {
|
||||
this.container.addEventListener('scroll', throttle(() => {
|
||||
this.scrollTop = this.container.scrollTop
|
||||
this.updateVisibleItems()
|
||||
}, 16))
|
||||
}
|
||||
|
||||
private updateVisibleItems(): void {
|
||||
const newStartIndex = Math.floor(this.scrollTop / this.itemHeight)
|
||||
const newEndIndex = Math.min(
|
||||
newStartIndex + this.visibleCount,
|
||||
this.items.length
|
||||
)
|
||||
|
||||
if (newStartIndex !== this.startIndex || newEndIndex !== this.endIndex) {
|
||||
this.startIndex = newStartIndex
|
||||
this.endIndex = newEndIndex
|
||||
this.renderVisibleItems()
|
||||
}
|
||||
}
|
||||
|
||||
private renderVisibleItems(): void {
|
||||
// 清除现有项目(除了占位元素)
|
||||
const children = Array.from(this.container.children)
|
||||
children.slice(1).forEach(child => child.remove())
|
||||
|
||||
// 渲染可见项目
|
||||
for (let i = this.startIndex; i < this.endIndex; i++) {
|
||||
const item = this.items[i]
|
||||
const element = this.renderItem(item, i)
|
||||
element.style.position = 'absolute'
|
||||
element.style.top = `${i * this.itemHeight}px`
|
||||
element.style.width = '100%'
|
||||
this.container.appendChild(element)
|
||||
}
|
||||
}
|
||||
|
||||
private renderItem(item: any, index: number): HTMLElement {
|
||||
// 默认渲染函数,应该被重写
|
||||
const div = document.createElement('div')
|
||||
div.textContent = `Item ${index}`
|
||||
div.style.height = `${this.itemHeight}px`
|
||||
return div
|
||||
}
|
||||
|
||||
updateItems(newItems: any[]): void {
|
||||
this.items = newItems
|
||||
this.updateVisibleItems()
|
||||
|
||||
// 更新占位元素高度
|
||||
const spacer = this.container.firstElementChild as HTMLElement
|
||||
if (spacer) {
|
||||
spacer.style.height = `${this.items.length * this.itemHeight}px`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 内存管理
|
||||
export class MemoryManager {
|
||||
private cache = new Map<string, any>()
|
||||
private maxSize: number
|
||||
private ttl: number
|
||||
|
||||
constructor(maxSize = 100, ttl = 5 * 60 * 1000) { // 默认5分钟TTL
|
||||
this.maxSize = maxSize
|
||||
this.ttl = ttl
|
||||
}
|
||||
|
||||
set(key: string, value: any): void {
|
||||
// 如果缓存已满,删除最旧的项
|
||||
if (this.cache.size >= this.maxSize) {
|
||||
const firstKey = this.cache.keys().next().value
|
||||
this.cache.delete(firstKey)
|
||||
}
|
||||
|
||||
this.cache.set(key, {
|
||||
value,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
get(key: string): any {
|
||||
const item = this.cache.get(key)
|
||||
|
||||
if (!item) return null
|
||||
|
||||
// 检查是否过期
|
||||
if (Date.now() - item.timestamp > this.ttl) {
|
||||
this.cache.delete(key)
|
||||
return null
|
||||
}
|
||||
|
||||
return item.value
|
||||
}
|
||||
|
||||
delete(key: string): boolean {
|
||||
return this.cache.delete(key)
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear()
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
const now = Date.now()
|
||||
for (const [key, item] of this.cache.entries()) {
|
||||
if (now - item.timestamp > this.ttl) {
|
||||
this.cache.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.cache.size
|
||||
}
|
||||
}
|
||||
|
||||
// 性能监控
|
||||
export class PerformanceMonitor {
|
||||
private metrics: Map<string, number[]> = new Map()
|
||||
|
||||
mark(name: string): void {
|
||||
if ('performance' in window && performance.mark) {
|
||||
performance.mark(name)
|
||||
}
|
||||
}
|
||||
|
||||
measure(name: string, startMark: string, endMark?: string): number {
|
||||
if ('performance' in window && performance.measure) {
|
||||
performance.measure(name, startMark, endMark)
|
||||
|
||||
const entries = performance.getEntriesByName(name, 'measure')
|
||||
if (entries.length > 0) {
|
||||
const duration = entries[entries.length - 1].duration
|
||||
this.recordMetric(name, duration)
|
||||
return duration
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
recordMetric(name: string, value: number): void {
|
||||
if (!this.metrics.has(name)) {
|
||||
this.metrics.set(name, [])
|
||||
}
|
||||
|
||||
const values = this.metrics.get(name)!
|
||||
values.push(value)
|
||||
|
||||
// 保持最近100个值
|
||||
if (values.length > 100) {
|
||||
values.shift()
|
||||
}
|
||||
}
|
||||
|
||||
getMetrics(name: string): { avg: number; min: number; max: number } | null {
|
||||
const values = this.metrics.get(name)
|
||||
if (!values || values.length === 0) return null
|
||||
|
||||
const avg = values.reduce((sum, val) => sum + val, 0) / values.length
|
||||
const min = Math.min(...values)
|
||||
const max = Math.max(...values)
|
||||
|
||||
return { avg, min, max }
|
||||
}
|
||||
|
||||
getAllMetrics(): Record<string, { avg: number; min: number; max: number }> {
|
||||
const result: Record<string, { avg: number; min: number; max: number }> = {}
|
||||
|
||||
for (const [name] of this.metrics) {
|
||||
const metrics = this.getMetrics(name)
|
||||
if (metrics) {
|
||||
result[name] = metrics
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.metrics.clear()
|
||||
if ('performance' in window && performance.clearMarks) {
|
||||
performance.clearMarks()
|
||||
performance.clearMeasures()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 单例实例
|
||||
export const lazyImageLoader = new LazyImageLoader()
|
||||
export const memoryManager = new MemoryManager()
|
||||
export const performanceMonitor = new PerformanceMonitor()
|
||||
|
||||
// 工具函数
|
||||
export function measureAsync<T>(
|
||||
name: string,
|
||||
asyncFn: () => Promise<T>
|
||||
): Promise<T> {
|
||||
performanceMonitor.mark(`${name}-start`)
|
||||
|
||||
return asyncFn().finally(() => {
|
||||
performanceMonitor.mark(`${name}-end`)
|
||||
performanceMonitor.measure(name, `${name}-start`, `${name}-end`)
|
||||
})
|
||||
}
|
||||
|
||||
export function memoize<T extends (...args: any[]) => any>(
|
||||
fn: T,
|
||||
keyGenerator?: (...args: Parameters<T>) => string
|
||||
): T {
|
||||
const cache = new Map<string, ReturnType<T>>()
|
||||
|
||||
return ((...args: Parameters<T>): ReturnType<T> => {
|
||||
const key = keyGenerator ? keyGenerator(...args) : JSON.stringify(args)
|
||||
|
||||
if (cache.has(key)) {
|
||||
return cache.get(key)!
|
||||
}
|
||||
|
||||
const result = fn(...args)
|
||||
cache.set(key, result)
|
||||
return result
|
||||
}) as T
|
||||
}
|
||||
@@ -1,346 +0,0 @@
|
||||
/**
|
||||
* HTTP请求工具
|
||||
* 基于Axios封装,支持请求拦截、响应拦截、错误处理等
|
||||
*/
|
||||
|
||||
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { envConfig } from '@/config/env'
|
||||
import { STORAGE_KEYS, ERROR_CODES } from '@/config/constants'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import router from '@/router'
|
||||
|
||||
// 请求配置接口
|
||||
interface RequestConfig extends AxiosRequestConfig {
|
||||
skipAuth?: boolean
|
||||
skipErrorHandler?: boolean
|
||||
showLoading?: boolean
|
||||
loadingText?: string
|
||||
}
|
||||
|
||||
// 响应数据接口
|
||||
interface ResponseData<T = any> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
success: boolean
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
class RequestService {
|
||||
private instance: AxiosInstance
|
||||
private pendingRequests = new Map<string, AbortController>()
|
||||
|
||||
constructor() {
|
||||
// 创建axios实例
|
||||
this.instance = axios.create({
|
||||
baseURL: envConfig.apiBaseUrl,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json;charset=UTF-8'
|
||||
}
|
||||
})
|
||||
|
||||
// 设置请求拦截器
|
||||
this.setupRequestInterceptor()
|
||||
|
||||
// 设置响应拦截器
|
||||
this.setupResponseInterceptor()
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置请求拦截器
|
||||
*/
|
||||
private setupRequestInterceptor() {
|
||||
this.instance.interceptors.request.use(
|
||||
(config: any) => {
|
||||
const requestConfig = config as RequestConfig
|
||||
|
||||
// 生成请求ID用于追踪
|
||||
const requestId = this.generateRequestId(config)
|
||||
config.metadata = { requestId }
|
||||
|
||||
// 处理重复请求
|
||||
this.handleDuplicateRequest(config, requestId)
|
||||
|
||||
// 添加认证头
|
||||
if (!requestConfig.skipAuth) {
|
||||
const token = this.getToken()
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
|
||||
// 显示加载状态
|
||||
if (requestConfig.showLoading) {
|
||||
const appStore = useAppStore()
|
||||
appStore.setLoading(true, requestConfig.loadingText)
|
||||
}
|
||||
|
||||
// 调试模式下打印请求信息
|
||||
if (envConfig.debug) {
|
||||
console.log('🚀 发送请求:', {
|
||||
url: config.url,
|
||||
method: config.method,
|
||||
params: config.params,
|
||||
data: config.data,
|
||||
headers: config.headers
|
||||
})
|
||||
}
|
||||
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
console.error('❌ 请求拦截器错误:', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置响应拦截器
|
||||
*/
|
||||
private setupResponseInterceptor() {
|
||||
this.instance.interceptors.response.use(
|
||||
(response: AxiosResponse<ResponseData>) => {
|
||||
const config = response.config as RequestConfig
|
||||
const requestId = config.metadata?.requestId
|
||||
|
||||
// 移除pending请求
|
||||
if (requestId) {
|
||||
this.pendingRequests.delete(requestId)
|
||||
}
|
||||
|
||||
// 隐藏加载状态
|
||||
if (config.showLoading) {
|
||||
const appStore = useAppStore()
|
||||
appStore.setLoading(false)
|
||||
}
|
||||
|
||||
// 调试模式下打印响应信息
|
||||
if (envConfig.debug) {
|
||||
console.log('✅ 收到响应:', {
|
||||
url: response.config.url,
|
||||
status: response.status,
|
||||
data: response.data
|
||||
})
|
||||
}
|
||||
|
||||
const { data } = response
|
||||
|
||||
// 处理业务状态码
|
||||
if (data.code === 200 || data.success) {
|
||||
return data.data
|
||||
} else {
|
||||
return this.handleBusinessError(data, config)
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
const config = error.config as RequestConfig
|
||||
const requestId = config?.metadata?.requestId
|
||||
|
||||
// 移除pending请求
|
||||
if (requestId) {
|
||||
this.pendingRequests.delete(requestId)
|
||||
}
|
||||
|
||||
// 隐藏加载状态
|
||||
if (config?.showLoading) {
|
||||
const appStore = useAppStore()
|
||||
appStore.setLoading(false)
|
||||
}
|
||||
|
||||
return this.handleRequestError(error, config)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成请求ID
|
||||
*/
|
||||
private generateRequestId(config: AxiosRequestConfig): string {
|
||||
const { method, url, params, data } = config
|
||||
return `${method}_${url}_${JSON.stringify(params)}_${JSON.stringify(data)}_${Date.now()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理重复请求
|
||||
*/
|
||||
private handleDuplicateRequest(config: AxiosRequestConfig, requestId: string) {
|
||||
const duplicateKey = `${config.method}_${config.url}`
|
||||
|
||||
// 取消之前的相同请求
|
||||
if (this.pendingRequests.has(duplicateKey)) {
|
||||
const controller = this.pendingRequests.get(duplicateKey)
|
||||
controller?.abort('请求被取消:发起了新的相同请求')
|
||||
}
|
||||
|
||||
// 创建新的AbortController
|
||||
const controller = new AbortController()
|
||||
config.signal = controller.signal
|
||||
this.pendingRequests.set(duplicateKey, controller)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Token
|
||||
*/
|
||||
private getToken(): string | null {
|
||||
return localStorage.getItem(STORAGE_KEYS.TOKEN)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理业务错误
|
||||
*/
|
||||
private handleBusinessError(data: ResponseData, config: RequestConfig) {
|
||||
if (config.skipErrorHandler) {
|
||||
return Promise.reject(data)
|
||||
}
|
||||
|
||||
// 特殊错误码处理
|
||||
switch (data.code) {
|
||||
case ERROR_CODES.UNAUTHORIZED:
|
||||
this.handleUnauthorized()
|
||||
break
|
||||
case ERROR_CODES.FORBIDDEN:
|
||||
ElMessage.error('权限不足,无法访问该资源')
|
||||
break
|
||||
case ERROR_CODES.NOT_FOUND:
|
||||
ElMessage.error('请求的资源不存在')
|
||||
break
|
||||
default:
|
||||
ElMessage.error(data.message || '请求失败')
|
||||
}
|
||||
|
||||
return Promise.reject(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理请求错误
|
||||
*/
|
||||
private handleRequestError(error: any, config: RequestConfig) {
|
||||
if (config?.skipErrorHandler) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
let message = '网络错误,请稍后重试'
|
||||
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
message = '请求超时,请稍后重试'
|
||||
} else if (error.response) {
|
||||
const { status } = error.response
|
||||
switch (status) {
|
||||
case ERROR_CODES.UNAUTHORIZED:
|
||||
this.handleUnauthorized()
|
||||
return Promise.reject(error)
|
||||
case ERROR_CODES.FORBIDDEN:
|
||||
message = '权限不足,无法访问该资源'
|
||||
break
|
||||
case ERROR_CODES.NOT_FOUND:
|
||||
message = '请求的资源不存在'
|
||||
break
|
||||
case ERROR_CODES.INTERNAL_SERVER_ERROR:
|
||||
message = '服务器内部错误'
|
||||
break
|
||||
default:
|
||||
message = `请求失败 (${status})`
|
||||
}
|
||||
} else if (error.request) {
|
||||
message = '网络连接失败,请检查网络设置'
|
||||
}
|
||||
|
||||
ElMessage.error(message)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理未授权错误
|
||||
*/
|
||||
private async handleUnauthorized() {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
try {
|
||||
// 尝试刷新Token
|
||||
await authStore.refreshToken()
|
||||
} catch {
|
||||
// 刷新失败,跳转到登录页
|
||||
ElMessageBox.alert('登录已过期,请重新登录', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET请求
|
||||
*/
|
||||
get<T = any>(url: string, params?: any, config?: RequestConfig): Promise<T> {
|
||||
return this.instance.get(url, { params, ...config })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST请求
|
||||
*/
|
||||
post<T = any>(url: string, data?: any, config?: RequestConfig): Promise<T> {
|
||||
return this.instance.post(url, data, config)
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT请求
|
||||
*/
|
||||
put<T = any>(url: string, data?: any, config?: RequestConfig): Promise<T> {
|
||||
return this.instance.put(url, data, config)
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE请求
|
||||
*/
|
||||
delete<T = any>(url: string, config?: RequestConfig): Promise<T> {
|
||||
return this.instance.delete(url, config)
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
*/
|
||||
upload<T = any>(url: string, file: File, config?: RequestConfig): Promise<T> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
return this.instance.post(url, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
},
|
||||
...config
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消所有请求
|
||||
*/
|
||||
cancelAllRequests() {
|
||||
this.pendingRequests.forEach((controller) => {
|
||||
controller.abort('用户取消请求')
|
||||
})
|
||||
this.pendingRequests.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消指定请求
|
||||
*/
|
||||
cancelRequest(requestId: string) {
|
||||
const controller = this.pendingRequests.get(requestId)
|
||||
if (controller) {
|
||||
controller.abort('用户取消请求')
|
||||
this.pendingRequests.delete(requestId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建请求实例
|
||||
const request = new RequestService()
|
||||
|
||||
export default request
|
||||
export { type RequestConfig }
|
||||
@@ -1,339 +0,0 @@
|
||||
/**
|
||||
* 本地存储工具
|
||||
* 支持localStorage、sessionStorage,提供加密存储功能
|
||||
*/
|
||||
|
||||
import { STORAGE_KEYS } from '@/config/constants'
|
||||
|
||||
// 存储类型
|
||||
type StorageType = 'localStorage' | 'sessionStorage'
|
||||
|
||||
// 存储选项
|
||||
interface StorageOptions {
|
||||
type?: StorageType
|
||||
encrypt?: boolean
|
||||
expire?: number // 过期时间(毫秒)
|
||||
}
|
||||
|
||||
// 存储数据结构
|
||||
interface StorageData<T = any> {
|
||||
value: T
|
||||
expire?: number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
class StorageService {
|
||||
private readonly prefix = 'emotion_museum_'
|
||||
|
||||
/**
|
||||
* 获取存储实例
|
||||
*/
|
||||
private getStorage(type: StorageType): Storage {
|
||||
return type === 'localStorage' ? localStorage : sessionStorage
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成存储键名
|
||||
*/
|
||||
private getKey(key: string): string {
|
||||
return `${this.prefix}${key}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单加密
|
||||
*/
|
||||
private encrypt(data: string): string {
|
||||
try {
|
||||
return btoa(encodeURIComponent(data))
|
||||
} catch {
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单解密
|
||||
*/
|
||||
private decrypt(data: string): string {
|
||||
try {
|
||||
return decodeURIComponent(atob(data))
|
||||
} catch {
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否过期
|
||||
*/
|
||||
private isExpired(data: StorageData): boolean {
|
||||
if (!data.expire) return false
|
||||
return Date.now() > data.expire
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置存储
|
||||
*/
|
||||
set<T>(key: string, value: T, options: StorageOptions = {}): boolean {
|
||||
try {
|
||||
const {
|
||||
type = 'localStorage',
|
||||
encrypt = false,
|
||||
expire
|
||||
} = options
|
||||
|
||||
const storage = this.getStorage(type)
|
||||
const storageKey = this.getKey(key)
|
||||
|
||||
const data: StorageData<T> = {
|
||||
value,
|
||||
timestamp: Date.now(),
|
||||
expire: expire ? Date.now() + expire : undefined
|
||||
}
|
||||
|
||||
let serializedData = JSON.stringify(data)
|
||||
|
||||
if (encrypt) {
|
||||
serializedData = this.encrypt(serializedData)
|
||||
}
|
||||
|
||||
storage.setItem(storageKey, serializedData)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('存储设置失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取存储
|
||||
*/
|
||||
get<T>(key: string, options: StorageOptions = {}): T | null {
|
||||
try {
|
||||
const {
|
||||
type = 'localStorage',
|
||||
encrypt = false
|
||||
} = options
|
||||
|
||||
const storage = this.getStorage(type)
|
||||
const storageKey = this.getKey(key)
|
||||
|
||||
let serializedData = storage.getItem(storageKey)
|
||||
|
||||
if (!serializedData) return null
|
||||
|
||||
if (encrypt) {
|
||||
serializedData = this.decrypt(serializedData)
|
||||
}
|
||||
|
||||
const data: StorageData<T> = JSON.parse(serializedData)
|
||||
|
||||
// 检查是否过期
|
||||
if (this.isExpired(data)) {
|
||||
this.remove(key, options)
|
||||
return null
|
||||
}
|
||||
|
||||
return data.value
|
||||
} catch (error) {
|
||||
console.error('存储获取失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除存储
|
||||
*/
|
||||
remove(key: string, options: StorageOptions = {}): boolean {
|
||||
try {
|
||||
const { type = 'localStorage' } = options
|
||||
const storage = this.getStorage(type)
|
||||
const storageKey = this.getKey(key)
|
||||
|
||||
storage.removeItem(storageKey)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('存储移除失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空存储
|
||||
*/
|
||||
clear(type: StorageType = 'localStorage'): boolean {
|
||||
try {
|
||||
const storage = this.getStorage(type)
|
||||
|
||||
// 只清除带有前缀的项目
|
||||
const keys = Object.keys(storage)
|
||||
keys.forEach(key => {
|
||||
if (key.startsWith(this.prefix)) {
|
||||
storage.removeItem(key)
|
||||
}
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('存储清空失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取存储大小
|
||||
*/
|
||||
getSize(type: StorageType = 'localStorage'): number {
|
||||
try {
|
||||
const storage = this.getStorage(type)
|
||||
let size = 0
|
||||
|
||||
for (const key in storage) {
|
||||
if (key.startsWith(this.prefix)) {
|
||||
size += storage[key].length
|
||||
}
|
||||
}
|
||||
|
||||
return size
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查存储是否可用
|
||||
*/
|
||||
isAvailable(type: StorageType = 'localStorage'): boolean {
|
||||
try {
|
||||
const storage = this.getStorage(type)
|
||||
const testKey = '__storage_test__'
|
||||
|
||||
storage.setItem(testKey, 'test')
|
||||
storage.removeItem(testKey)
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有键名
|
||||
*/
|
||||
getKeys(type: StorageType = 'localStorage'): string[] {
|
||||
try {
|
||||
const storage = this.getStorage(type)
|
||||
const keys: string[] = []
|
||||
|
||||
for (let i = 0; i < storage.length; i++) {
|
||||
const key = storage.key(i)
|
||||
if (key && key.startsWith(this.prefix)) {
|
||||
keys.push(key.replace(this.prefix, ''))
|
||||
}
|
||||
}
|
||||
|
||||
return keys
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量设置
|
||||
*/
|
||||
setMultiple(data: Record<string, any>, options: StorageOptions = {}): boolean {
|
||||
try {
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
this.set(key, value, options)
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('批量设置失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取
|
||||
*/
|
||||
getMultiple<T>(keys: string[], options: StorageOptions = {}): Record<string, T | null> {
|
||||
const result: Record<string, T | null> = {}
|
||||
|
||||
keys.forEach(key => {
|
||||
result[key] = this.get<T>(key, options)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量移除
|
||||
*/
|
||||
removeMultiple(keys: string[], options: StorageOptions = {}): boolean {
|
||||
try {
|
||||
keys.forEach(key => {
|
||||
this.remove(key, options)
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('批量移除失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建存储实例
|
||||
const storage = new StorageService()
|
||||
|
||||
// 便捷方法
|
||||
export const setToken = (token: string) => {
|
||||
storage.set(STORAGE_KEYS.TOKEN, token, { encrypt: true })
|
||||
}
|
||||
|
||||
export const getToken = (): string | null => {
|
||||
return storage.get(STORAGE_KEYS.TOKEN, { encrypt: true })
|
||||
}
|
||||
|
||||
export const removeToken = () => {
|
||||
storage.remove(STORAGE_KEYS.TOKEN)
|
||||
}
|
||||
|
||||
export const setRefreshToken = (token: string) => {
|
||||
storage.set(STORAGE_KEYS.REFRESH_TOKEN, token, { encrypt: true })
|
||||
}
|
||||
|
||||
export const getRefreshToken = (): string | null => {
|
||||
return storage.get(STORAGE_KEYS.REFRESH_TOKEN, { encrypt: true })
|
||||
}
|
||||
|
||||
export const removeRefreshToken = () => {
|
||||
storage.remove(STORAGE_KEYS.REFRESH_TOKEN)
|
||||
}
|
||||
|
||||
export const setUserInfo = (userInfo: any) => {
|
||||
storage.set(STORAGE_KEYS.USER_INFO, userInfo)
|
||||
}
|
||||
|
||||
export const getUserInfo = () => {
|
||||
return storage.get(STORAGE_KEYS.USER_INFO)
|
||||
}
|
||||
|
||||
export const removeUserInfo = () => {
|
||||
storage.remove(STORAGE_KEYS.USER_INFO)
|
||||
}
|
||||
|
||||
export const setLanguage = (language: string) => {
|
||||
storage.set(STORAGE_KEYS.LANGUAGE, language)
|
||||
}
|
||||
|
||||
export const getLanguage = (): string | null => {
|
||||
return storage.get(STORAGE_KEYS.LANGUAGE)
|
||||
}
|
||||
|
||||
export const setTheme = (theme: string) => {
|
||||
storage.set(STORAGE_KEYS.THEME, theme)
|
||||
}
|
||||
|
||||
export const getTheme = (): string | null => {
|
||||
return storage.get(STORAGE_KEYS.THEME)
|
||||
}
|
||||
|
||||
export default storage
|
||||
@@ -1,400 +0,0 @@
|
||||
/**
|
||||
* 验证工具函数
|
||||
* 提供各种数据验证功能
|
||||
*/
|
||||
|
||||
import { VALIDATION_RULES } from '@/config/constants'
|
||||
|
||||
/**
|
||||
* 验证用户名
|
||||
*/
|
||||
export const validateUsername = (username: string): boolean => {
|
||||
if (!username) return false
|
||||
|
||||
const { MIN_LENGTH, MAX_LENGTH, PATTERN } = VALIDATION_RULES.USERNAME
|
||||
|
||||
return (
|
||||
username.length >= MIN_LENGTH &&
|
||||
username.length <= MAX_LENGTH &&
|
||||
PATTERN.test(username)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证密码
|
||||
*/
|
||||
export const validatePassword = (password: string): boolean => {
|
||||
if (!password) return false
|
||||
|
||||
const { MIN_LENGTH, MAX_LENGTH, PATTERN } = VALIDATION_RULES.PASSWORD
|
||||
|
||||
return (
|
||||
password.length >= MIN_LENGTH &&
|
||||
password.length <= MAX_LENGTH &&
|
||||
PATTERN.test(password)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证邮箱
|
||||
*/
|
||||
export const validateEmail = (email: string): boolean => {
|
||||
if (!email) return false
|
||||
return VALIDATION_RULES.EMAIL.PATTERN.test(email)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证手机号
|
||||
*/
|
||||
export const validatePhone = (phone: string): boolean => {
|
||||
if (!phone) return false
|
||||
return VALIDATION_RULES.PHONE.PATTERN.test(phone)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证身份证号
|
||||
*/
|
||||
export const validateIdCard = (idCard: string): boolean => {
|
||||
if (!idCard) return false
|
||||
|
||||
// 18位身份证号验证
|
||||
const pattern = /^[1-9]\d{5}(18|19|20)\d{2}((0[1-9])|(1[0-2]))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$/
|
||||
|
||||
if (!pattern.test(idCard)) return false
|
||||
|
||||
// 校验码验证
|
||||
const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
|
||||
const checkCodes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']
|
||||
|
||||
let sum = 0
|
||||
for (let i = 0; i < 17; i++) {
|
||||
sum += parseInt(idCard[i]) * weights[i]
|
||||
}
|
||||
|
||||
const checkCode = checkCodes[sum % 11]
|
||||
return checkCode === idCard[17].toUpperCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证银行卡号
|
||||
*/
|
||||
export const validateBankCard = (cardNumber: string): boolean => {
|
||||
if (!cardNumber) return false
|
||||
|
||||
// 移除空格和非数字字符
|
||||
const cleaned = cardNumber.replace(/\D/g, '')
|
||||
|
||||
// 长度检查(一般为16-19位)
|
||||
if (cleaned.length < 16 || cleaned.length > 19) return false
|
||||
|
||||
// Luhn算法验证
|
||||
let sum = 0
|
||||
let isEven = false
|
||||
|
||||
for (let i = cleaned.length - 1; i >= 0; i--) {
|
||||
let digit = parseInt(cleaned[i])
|
||||
|
||||
if (isEven) {
|
||||
digit *= 2
|
||||
if (digit > 9) {
|
||||
digit -= 9
|
||||
}
|
||||
}
|
||||
|
||||
sum += digit
|
||||
isEven = !isEven
|
||||
}
|
||||
|
||||
return sum % 10 === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证URL
|
||||
*/
|
||||
export const validateUrl = (url: string): boolean => {
|
||||
if (!url) return false
|
||||
|
||||
try {
|
||||
new URL(url)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证IP地址
|
||||
*/
|
||||
export const validateIP = (ip: string): boolean => {
|
||||
if (!ip) return false
|
||||
|
||||
const ipv4Pattern = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/
|
||||
const ipv6Pattern = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/
|
||||
|
||||
return ipv4Pattern.test(ip) || ipv6Pattern.test(ip)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证端口号
|
||||
*/
|
||||
export const validatePort = (port: string | number): boolean => {
|
||||
const portNum = typeof port === 'string' ? parseInt(port) : port
|
||||
return !isNaN(portNum) && portNum >= 1 && portNum <= 65535
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证MAC地址
|
||||
*/
|
||||
export const validateMAC = (mac: string): boolean => {
|
||||
if (!mac) return false
|
||||
|
||||
const pattern = /^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/
|
||||
return pattern.test(mac)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证颜色值
|
||||
*/
|
||||
export const validateColor = (color: string): boolean => {
|
||||
if (!color) return false
|
||||
|
||||
// Hex颜色
|
||||
const hexPattern = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/
|
||||
if (hexPattern.test(color)) return true
|
||||
|
||||
// RGB颜色
|
||||
const rgbPattern = /^rgb\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})\)$/
|
||||
if (rgbPattern.test(color)) {
|
||||
const matches = color.match(rgbPattern)
|
||||
if (matches) {
|
||||
const [, r, g, b] = matches
|
||||
return [r, g, b].every(val => parseInt(val) >= 0 && parseInt(val) <= 255)
|
||||
}
|
||||
}
|
||||
|
||||
// RGBA颜色
|
||||
const rgbaPattern = /^rgba\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3}),\s*(0|1|0?\.\d+)\)$/
|
||||
if (rgbaPattern.test(color)) {
|
||||
const matches = color.match(rgbaPattern)
|
||||
if (matches) {
|
||||
const [, r, g, b, a] = matches
|
||||
return (
|
||||
[r, g, b].every(val => parseInt(val) >= 0 && parseInt(val) <= 255) &&
|
||||
parseFloat(a) >= 0 && parseFloat(a) <= 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证日期格式
|
||||
*/
|
||||
export const validateDate = (date: string, format = 'YYYY-MM-DD'): boolean => {
|
||||
if (!date) return false
|
||||
|
||||
const patterns: Record<string, RegExp> = {
|
||||
'YYYY-MM-DD': /^\d{4}-\d{2}-\d{2}$/,
|
||||
'YYYY/MM/DD': /^\d{4}\/\d{2}\/\d{2}$/,
|
||||
'DD/MM/YYYY': /^\d{2}\/\d{2}\/\d{4}$/,
|
||||
'MM/DD/YYYY': /^\d{2}\/\d{2}\/\d{4}$/,
|
||||
'YYYY-MM-DD HH:mm:ss': /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/
|
||||
}
|
||||
|
||||
const pattern = patterns[format]
|
||||
if (!pattern || !pattern.test(date)) return false
|
||||
|
||||
// 验证日期有效性
|
||||
const dateObj = new Date(date)
|
||||
return dateObj instanceof Date && !isNaN(dateObj.getTime())
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证文件类型
|
||||
*/
|
||||
export const validateFileType = (file: File, allowedTypes: string[]): boolean => {
|
||||
if (!file || !allowedTypes.length) return false
|
||||
|
||||
return allowedTypes.some(type => {
|
||||
if (type.includes('*')) {
|
||||
// 支持通配符,如 image/*
|
||||
const [mainType] = type.split('/')
|
||||
return file.type.startsWith(mainType + '/')
|
||||
}
|
||||
return file.type === type
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证文件大小
|
||||
*/
|
||||
export const validateFileSize = (file: File, maxSize: number): boolean => {
|
||||
if (!file) return false
|
||||
return file.size <= maxSize
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证图片尺寸
|
||||
*/
|
||||
export const validateImageSize = (
|
||||
file: File,
|
||||
options: {
|
||||
maxWidth?: number
|
||||
maxHeight?: number
|
||||
minWidth?: number
|
||||
minHeight?: number
|
||||
}
|
||||
): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
if (!file || !file.type.startsWith('image/')) {
|
||||
resolve(false)
|
||||
return
|
||||
}
|
||||
|
||||
const img = new Image()
|
||||
const url = URL.createObjectURL(file)
|
||||
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
const { width, height } = img
|
||||
const { maxWidth, maxHeight, minWidth, minHeight } = options
|
||||
|
||||
let valid = true
|
||||
|
||||
if (maxWidth && width > maxWidth) valid = false
|
||||
if (maxHeight && height > maxHeight) valid = false
|
||||
if (minWidth && width < minWidth) valid = false
|
||||
if (minHeight && height < minHeight) valid = false
|
||||
|
||||
resolve(valid)
|
||||
}
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
resolve(false)
|
||||
}
|
||||
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证JSON格式
|
||||
*/
|
||||
export const validateJSON = (str: string): boolean => {
|
||||
if (!str) return false
|
||||
|
||||
try {
|
||||
JSON.parse(str)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证正则表达式
|
||||
*/
|
||||
export const validateRegex = (pattern: string): boolean => {
|
||||
if (!pattern) return false
|
||||
|
||||
try {
|
||||
new RegExp(pattern)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证版本号
|
||||
*/
|
||||
export const validateVersion = (version: string): boolean => {
|
||||
if (!version) return false
|
||||
|
||||
const pattern = /^\d+\.\d+\.\d+(-[a-zA-Z0-9]+)?$/
|
||||
return pattern.test(version)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证中文字符
|
||||
*/
|
||||
export const validateChinese = (str: string): boolean => {
|
||||
if (!str) return false
|
||||
|
||||
const pattern = /^[\u4e00-\u9fa5]+$/
|
||||
return pattern.test(str)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证英文字符
|
||||
*/
|
||||
export const validateEnglish = (str: string): boolean => {
|
||||
if (!str) return false
|
||||
|
||||
const pattern = /^[a-zA-Z]+$/
|
||||
return pattern.test(str)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证数字
|
||||
*/
|
||||
export const validateNumber = (str: string): boolean => {
|
||||
if (!str) return false
|
||||
|
||||
return !isNaN(Number(str)) && isFinite(Number(str))
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证整数
|
||||
*/
|
||||
export const validateInteger = (str: string): boolean => {
|
||||
if (!str) return false
|
||||
|
||||
const pattern = /^-?\d+$/
|
||||
return pattern.test(str)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证正整数
|
||||
*/
|
||||
export const validatePositiveInteger = (str: string): boolean => {
|
||||
if (!str) return false
|
||||
|
||||
const pattern = /^\d+$/
|
||||
return pattern.test(str) && parseInt(str) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证小数
|
||||
*/
|
||||
export const validateDecimal = (str: string, decimals = 2): boolean => {
|
||||
if (!str) return false
|
||||
|
||||
const pattern = new RegExp(`^-?\\d+(\\.\\d{1,${decimals}})?$`)
|
||||
return pattern.test(str)
|
||||
}
|
||||
|
||||
/**
|
||||
* 综合验证函数
|
||||
*/
|
||||
export const validate = (
|
||||
value: any,
|
||||
rules: Array<{
|
||||
validator: (val: any) => boolean
|
||||
message: string
|
||||
}>
|
||||
): { valid: boolean; message?: string } => {
|
||||
for (const rule of rules) {
|
||||
if (!rule.validator(value)) {
|
||||
return {
|
||||
valid: false,
|
||||
message: rule.message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true }
|
||||
}
|
||||
@@ -1,395 +0,0 @@
|
||||
/**
|
||||
* WebSocket工具类
|
||||
* 基于@stomp/stompjs,支持Token认证、自动重连、心跳检测
|
||||
*/
|
||||
|
||||
import { Client, type IMessage } from '@stomp/stompjs'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { envConfig } from '@/config/env'
|
||||
import { STORAGE_KEYS } from '@/config/constants'
|
||||
|
||||
// WebSocket连接状态
|
||||
export enum WSConnectionState {
|
||||
CONNECTING = 'CONNECTING',
|
||||
CONNECTED = 'CONNECTED',
|
||||
DISCONNECTED = 'DISCONNECTED',
|
||||
ERROR = 'ERROR'
|
||||
}
|
||||
|
||||
// 消息订阅接口
|
||||
interface Subscription {
|
||||
destination: string
|
||||
callback: (message: any) => void
|
||||
unsubscribe: () => void
|
||||
}
|
||||
|
||||
// WebSocket事件回调
|
||||
interface WSEventCallbacks {
|
||||
onConnect?: () => void
|
||||
onDisconnect?: () => void
|
||||
onError?: (error: any) => void
|
||||
onTokenExpired?: () => void
|
||||
onReconnect?: (attempt: number) => void
|
||||
}
|
||||
|
||||
export class WebSocketService {
|
||||
private client: Client
|
||||
private connected = false
|
||||
private reconnectAttempts = 0
|
||||
private maxReconnectAttempts = 5
|
||||
private currentToken = ''
|
||||
private subscriptions = new Map<string, Subscription>()
|
||||
private messageQueue: Array<{ destination: string; body: any }> = []
|
||||
private callbacks: WSEventCallbacks = {}
|
||||
private connectionState = WSConnectionState.DISCONNECTED
|
||||
|
||||
constructor(callbacks?: WSEventCallbacks) {
|
||||
this.callbacks = callbacks || {}
|
||||
this.initializeClient()
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化STOMP客户端
|
||||
*/
|
||||
private initializeClient() {
|
||||
this.client = new Client({
|
||||
// 使用原生WebSocket,支持Token认证
|
||||
brokerURL: `${envConfig.wsBaseUrl}/ws`,
|
||||
|
||||
// 心跳检测
|
||||
heartbeatIncoming: 4000,
|
||||
heartbeatOutgoing: 4000,
|
||||
|
||||
// 重连配置
|
||||
reconnectDelay: 5000,
|
||||
|
||||
// 调试模式
|
||||
debug: envConfig.debug ? this.debugLog : undefined,
|
||||
|
||||
onConnect: () => {
|
||||
this.connected = true
|
||||
this.reconnectAttempts = 0
|
||||
this.connectionState = WSConnectionState.CONNECTED
|
||||
|
||||
console.log('✅ WebSocket连接成功')
|
||||
|
||||
// 处理消息队列
|
||||
this.processMessageQueue()
|
||||
|
||||
// 重新订阅
|
||||
this.resubscribeAll()
|
||||
|
||||
this.callbacks.onConnect?.()
|
||||
},
|
||||
|
||||
onDisconnect: () => {
|
||||
this.connected = false
|
||||
this.connectionState = WSConnectionState.DISCONNECTED
|
||||
|
||||
console.log('❌ WebSocket连接断开')
|
||||
this.callbacks.onDisconnect?.()
|
||||
},
|
||||
|
||||
onStompError: (frame) => {
|
||||
console.error('❌ STOMP错误:', frame)
|
||||
this.connectionState = WSConnectionState.ERROR
|
||||
this.handleStompError(frame)
|
||||
},
|
||||
|
||||
onWebSocketError: (error) => {
|
||||
console.error('❌ WebSocket错误:', error)
|
||||
this.connectionState = WSConnectionState.ERROR
|
||||
this.callbacks.onError?.(error)
|
||||
},
|
||||
|
||||
// WebSocket连接前的配置
|
||||
beforeConnect: () => {
|
||||
if (this.currentToken) {
|
||||
this.client.configure({
|
||||
connectHeaders: {
|
||||
Authorization: `Bearer ${this.currentToken}`,
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 调试日志
|
||||
*/
|
||||
private debugLog = (message: string) => {
|
||||
if (envConfig.debug) {
|
||||
console.log(`🔌 WebSocket: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接WebSocket
|
||||
*/
|
||||
connect(token?: string) {
|
||||
if (token) {
|
||||
this.currentToken = token
|
||||
} else {
|
||||
this.currentToken = localStorage.getItem(STORAGE_KEYS.TOKEN) || ''
|
||||
}
|
||||
|
||||
if (!this.currentToken) {
|
||||
console.warn('⚠️ 没有找到Token,无法建立WebSocket连接')
|
||||
return
|
||||
}
|
||||
|
||||
this.connectionState = WSConnectionState.CONNECTING
|
||||
|
||||
this.client.configure({
|
||||
connectHeaders: {
|
||||
Authorization: `Bearer ${this.currentToken}`,
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
this.client.activate()
|
||||
console.log('🔌 正在连接WebSocket...')
|
||||
} catch (error) {
|
||||
console.error('❌ WebSocket连接失败:', error)
|
||||
this.connectionState = WSConnectionState.ERROR
|
||||
this.callbacks.onError?.(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开WebSocket连接
|
||||
*/
|
||||
disconnect() {
|
||||
this.connected = false
|
||||
this.connectionState = WSConnectionState.DISCONNECTED
|
||||
this.subscriptions.clear()
|
||||
this.messageQueue = []
|
||||
|
||||
try {
|
||||
this.client.deactivate()
|
||||
console.log('🔌 WebSocket连接已断开')
|
||||
} catch (error) {
|
||||
console.error('❌ 断开WebSocket连接时出错:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新Token(用于Token刷新场景)
|
||||
*/
|
||||
updateToken(newToken: string) {
|
||||
this.currentToken = newToken
|
||||
|
||||
if (this.connected) {
|
||||
// 断开当前连接
|
||||
this.disconnect()
|
||||
// 使用新Token重新连接
|
||||
setTimeout(() => {
|
||||
this.connect(newToken)
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅消息
|
||||
*/
|
||||
subscribe(destination: string, callback: (message: any) => void): () => void {
|
||||
if (!this.connected) {
|
||||
console.warn('⚠️ WebSocket未连接,订阅将在连接后自动执行')
|
||||
}
|
||||
|
||||
const subscription: Subscription = {
|
||||
destination,
|
||||
callback,
|
||||
unsubscribe: () => {
|
||||
this.subscriptions.delete(destination)
|
||||
}
|
||||
}
|
||||
|
||||
this.subscriptions.set(destination, subscription)
|
||||
|
||||
// 如果已连接,立即订阅
|
||||
if (this.connected) {
|
||||
this.doSubscribe(destination, callback)
|
||||
}
|
||||
|
||||
// 返回取消订阅函数
|
||||
return () => {
|
||||
subscription.unsubscribe()
|
||||
if (this.connected) {
|
||||
// 这里可以添加STOMP取消订阅逻辑
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行订阅
|
||||
*/
|
||||
private doSubscribe(destination: string, callback: (message: any) => void) {
|
||||
try {
|
||||
this.client.subscribe(destination, (message: IMessage) => {
|
||||
try {
|
||||
const data = JSON.parse(message.body)
|
||||
callback(data)
|
||||
} catch (error) {
|
||||
console.error('❌ 消息解析失败:', error, message.body)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('❌ 订阅失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新订阅所有频道
|
||||
*/
|
||||
private resubscribeAll() {
|
||||
this.subscriptions.forEach((subscription, destination) => {
|
||||
this.doSubscribe(destination, subscription.callback)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
*/
|
||||
send(destination: string, body: any) {
|
||||
if (!this.connected) {
|
||||
console.warn('⚠️ WebSocket未连接,消息将被缓存')
|
||||
this.messageQueue.push({ destination, body })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
this.client.publish({
|
||||
destination,
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
if (envConfig.debug) {
|
||||
console.log('📤 发送消息:', { destination, body })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 发送消息失败:', error)
|
||||
// 发送失败时加入队列
|
||||
this.messageQueue.push({ destination, body })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理消息队列
|
||||
*/
|
||||
private processMessageQueue() {
|
||||
while (this.messageQueue.length > 0) {
|
||||
const message = this.messageQueue.shift()
|
||||
if (message) {
|
||||
this.send(message.destination, message.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理STOMP错误
|
||||
*/
|
||||
private handleStompError(frame: any) {
|
||||
if (frame.headers && frame.headers.message) {
|
||||
const errorMessage = frame.headers.message.toLowerCase()
|
||||
|
||||
if (errorMessage.includes('unauthorized') ||
|
||||
errorMessage.includes('invalid token') ||
|
||||
errorMessage.includes('token expired')) {
|
||||
|
||||
console.warn('⚠️ Token认证失败,触发重新登录')
|
||||
ElMessage.warning('登录状态已过期,请重新登录')
|
||||
this.callbacks.onTokenExpired?.()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 其他错误进行重连
|
||||
this.handleReconnect()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理重连
|
||||
*/
|
||||
private handleReconnect() {
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.reconnectAttempts++
|
||||
const delay = 5000 * this.reconnectAttempts
|
||||
|
||||
console.log(`🔄 尝试重连 (${this.reconnectAttempts}/${this.maxReconnectAttempts}),${delay}ms后重试`)
|
||||
|
||||
setTimeout(() => {
|
||||
if (!this.connected) {
|
||||
this.connect()
|
||||
this.callbacks.onReconnect?.(this.reconnectAttempts)
|
||||
}
|
||||
}, delay)
|
||||
} else {
|
||||
console.error('❌ 重连次数已达上限,停止重连')
|
||||
ElMessage.error('网络连接不稳定,请刷新页面重试')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取连接状态
|
||||
*/
|
||||
getConnectionState(): WSConnectionState {
|
||||
return this.connectionState
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否已连接
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.connected
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取订阅数量
|
||||
*/
|
||||
getSubscriptionCount(): number {
|
||||
return this.subscriptions.size
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息队列长度
|
||||
*/
|
||||
getQueueLength(): number {
|
||||
return this.messageQueue.length
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空消息队列
|
||||
*/
|
||||
clearQueue() {
|
||||
this.messageQueue = []
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局WebSocket实例
|
||||
let globalWSInstance: WebSocketService | null = null
|
||||
|
||||
/**
|
||||
* 获取全局WebSocket实例
|
||||
*/
|
||||
export function getWebSocketInstance(callbacks?: WSEventCallbacks): WebSocketService {
|
||||
if (!globalWSInstance) {
|
||||
globalWSInstance = new WebSocketService(callbacks)
|
||||
}
|
||||
return globalWSInstance
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁全局WebSocket实例
|
||||
*/
|
||||
export function destroyWebSocketInstance() {
|
||||
if (globalWSInstance) {
|
||||
globalWSInstance.disconnect()
|
||||
globalWSInstance = null
|
||||
}
|
||||
}
|
||||
|
||||
export default WebSocketService
|
||||
@@ -1,242 +0,0 @@
|
||||
<template>
|
||||
<div class="home-page">
|
||||
<!-- Hero Section -->
|
||||
<section class="hero bg-gradient-to-br from-blue-50 to-indigo-100 py-20">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h1 class="text-4xl md:text-6xl font-bold text-gray-900 mb-6">
|
||||
欢迎来到
|
||||
<span class="text-gradient">情绪博物馆</span>
|
||||
</h1>
|
||||
<p class="text-xl text-gray-600 mb-8 max-w-3xl mx-auto">
|
||||
记录情绪,分享心情的温暖空间。与AI对话,写下情绪日记,分析情感轨迹,让每一份情感都被珍藏。
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<el-button
|
||||
v-if="!isLoggedIn"
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="goToLogin"
|
||||
class="px-8 py-3"
|
||||
>
|
||||
开始使用
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="goToChat"
|
||||
class="px-8 py-3"
|
||||
>
|
||||
开始对话
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
size="large"
|
||||
@click="learnMore"
|
||||
class="px-8 py-3"
|
||||
>
|
||||
了解更多
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features Section -->
|
||||
<section class="features py-20 bg-white">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl font-bold text-gray-900 mb-4">核心功能</h2>
|
||||
<p class="text-lg text-gray-600">探索情绪博物馆的强大功能</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<div
|
||||
v-for="feature in features"
|
||||
:key="feature.title"
|
||||
class="feature-card p-6 bg-white rounded-xl shadow-soft hover:shadow-medium transition-shadow cursor-pointer"
|
||||
@click="goToFeature(feature.path)"
|
||||
>
|
||||
<div class="w-12 h-12 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center mb-4">
|
||||
<el-icon size="24" class="text-white">
|
||||
<component :is="feature.icon" />
|
||||
</el-icon>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">{{ feature.title }}</h3>
|
||||
<p class="text-gray-600">{{ feature.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Stats Section -->
|
||||
<section class="stats py-20 bg-gray-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl font-bold text-gray-900 mb-4">用户数据</h2>
|
||||
<p class="text-lg text-gray-600">看看大家都在做什么</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<div
|
||||
v-for="stat in stats"
|
||||
:key="stat.label"
|
||||
class="text-center"
|
||||
>
|
||||
<div class="text-4xl font-bold text-blue-600 mb-2">{{ stat.value }}</div>
|
||||
<div class="text-gray-600">{{ stat.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA Section -->
|
||||
<section class="cta py-20 bg-gradient-to-r from-blue-600 to-purple-600">
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h2 class="text-3xl font-bold text-white mb-4">
|
||||
开始您的情绪记录之旅
|
||||
</h2>
|
||||
<p class="text-xl text-blue-100 mb-8">
|
||||
加入我们,让每一份情感都被理解和珍藏
|
||||
</p>
|
||||
|
||||
<el-button
|
||||
v-if="!isLoggedIn"
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="goToRegister"
|
||||
class="bg-white text-blue-600 hover:bg-gray-50 px-8 py-3"
|
||||
>
|
||||
立即注册
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="goToDashboard"
|
||||
class="bg-white text-blue-600 hover:bg-gray-50 px-8 py-3"
|
||||
>
|
||||
查看仪表盘
|
||||
</el-button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
ChatDotRound,
|
||||
EditPen,
|
||||
TrendCharts,
|
||||
DataBoard,
|
||||
User,
|
||||
Setting
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 计算属性
|
||||
const isLoggedIn = computed(() => authStore.isLoggedIn)
|
||||
|
||||
// 功能特性
|
||||
const features = [
|
||||
{
|
||||
title: 'AI智能对话',
|
||||
description: '与AI助手进行深度对话,获得情绪支持和建议',
|
||||
icon: ChatDotRound,
|
||||
path: '/chat'
|
||||
},
|
||||
{
|
||||
title: '情绪日记',
|
||||
description: '记录每日情绪变化,AI自动分析和点评',
|
||||
icon: EditPen,
|
||||
path: '/app/diary'
|
||||
},
|
||||
{
|
||||
title: '情绪分析',
|
||||
description: '可视化展示情绪趋势,深入了解自己',
|
||||
icon: TrendCharts,
|
||||
path: '/app/analysis'
|
||||
},
|
||||
{
|
||||
title: '个人仪表盘',
|
||||
description: '全面展示个人成长数据和统计信息',
|
||||
icon: DataBoard,
|
||||
path: '/app/dashboard'
|
||||
},
|
||||
{
|
||||
title: '个人资料',
|
||||
description: '管理个人信息,自定义偏好设置',
|
||||
icon: User,
|
||||
path: '/app/profile'
|
||||
},
|
||||
{
|
||||
title: '系统设置',
|
||||
description: '个性化设置,打造专属的使用体验',
|
||||
icon: Setting,
|
||||
path: '/app/settings'
|
||||
}
|
||||
]
|
||||
|
||||
// 统计数据
|
||||
const stats = [
|
||||
{ label: '注册用户', value: '10,000+' },
|
||||
{ label: '对话次数', value: '100,000+' },
|
||||
{ label: '日记篇数', value: '50,000+' },
|
||||
{ label: '情绪记录', value: '200,000+' }
|
||||
]
|
||||
|
||||
// 方法
|
||||
const goToLogin = () => {
|
||||
router.push('/auth/login')
|
||||
}
|
||||
|
||||
const goToRegister = () => {
|
||||
router.push('/auth/register')
|
||||
}
|
||||
|
||||
const goToChat = () => {
|
||||
router.push('/chat')
|
||||
}
|
||||
|
||||
const goToDashboard = () => {
|
||||
router.push('/app/dashboard')
|
||||
}
|
||||
|
||||
const goToFeature = (path: string) => {
|
||||
if (isLoggedIn.value) {
|
||||
router.push(path)
|
||||
} else {
|
||||
router.push('/auth/login')
|
||||
}
|
||||
}
|
||||
|
||||
const learnMore = () => {
|
||||
// 滚动到功能介绍部分
|
||||
const featuresSection = document.querySelector('.features')
|
||||
if (featuresSection) {
|
||||
featuresSection.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.feature-card {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.text-gradient {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
</style>
|
||||
@@ -1,538 +0,0 @@
|
||||
<template>
|
||||
<div class="analysis-page">
|
||||
<!-- 页面头部 -->
|
||||
<div class="analysis-header bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-2">情绪分析</h1>
|
||||
<p class="text-gray-600">深入了解您的情绪变化趋势和心理健康状态</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
<el-select v-model="timeRange" @change="handleTimeRangeChange">
|
||||
<el-option label="最近7天" value="7d" />
|
||||
<el-option label="最近30天" value="30d" />
|
||||
<el-option label="最近90天" value="90d" />
|
||||
<el-option label="最近一年" value="1y" />
|
||||
</el-select>
|
||||
|
||||
<el-button @click="exportReport">
|
||||
<el-icon class="mr-2"><Download /></el-icon>
|
||||
导出报告
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 概览卡片 -->
|
||||
<div class="overview-cards grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||
<div
|
||||
v-for="card in overviewCards"
|
||||
:key="card.title"
|
||||
class="overview-card bg-white rounded-lg shadow-sm p-6"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 mb-1">{{ card.title }}</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ card.value }}</p>
|
||||
<div v-if="card.change" class="flex items-center mt-2">
|
||||
<el-icon
|
||||
:class="card.change > 0 ? 'text-green-500' : 'text-red-500'"
|
||||
size="16"
|
||||
>
|
||||
<ArrowUp v-if="card.change > 0" />
|
||||
<ArrowDown v-else />
|
||||
</el-icon>
|
||||
<span
|
||||
:class="card.change > 0 ? 'text-green-500' : 'text-red-500'"
|
||||
class="text-sm ml-1"
|
||||
>
|
||||
{{ Math.abs(card.change) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="w-12 h-12 rounded-lg flex items-center justify-center"
|
||||
:style="{ backgroundColor: card.color + '20' }"
|
||||
>
|
||||
<el-icon :size="24" :style="{ color: card.color }">
|
||||
<component :is="card.icon" />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<div class="charts-section grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<!-- 情绪趋势图 -->
|
||||
<div class="chart-card bg-white rounded-lg shadow-sm p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">情绪趋势</h3>
|
||||
<el-button-group size="small">
|
||||
<el-button
|
||||
:type="chartType === 'line' ? 'primary' : ''"
|
||||
@click="chartType = 'line'"
|
||||
>
|
||||
线图
|
||||
</el-button>
|
||||
<el-button
|
||||
:type="chartType === 'bar' ? 'primary' : ''"
|
||||
@click="chartType = 'bar'"
|
||||
>
|
||||
柱图
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
|
||||
<div ref="emotionTrendChartRef" class="h-80"></div>
|
||||
</div>
|
||||
|
||||
<!-- 情绪分布图 -->
|
||||
<div class="chart-card bg-white rounded-lg shadow-sm p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">情绪分布</h3>
|
||||
<el-button size="small" @click="showEmotionDetail">
|
||||
查看详情
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div ref="emotionDistributionChartRef" class="h-80"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详细分析 -->
|
||||
<div class="detailed-analysis grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- 心情指数变化 -->
|
||||
<div class="mood-analysis bg-white rounded-lg shadow-sm p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">心情指数变化</h3>
|
||||
|
||||
<div ref="moodIndexChartRef" class="h-64 mb-4"></div>
|
||||
|
||||
<div class="mood-stats space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600">平均心情指数</span>
|
||||
<span class="text-sm font-medium text-gray-900">{{ avgMoodIndex }}/10</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600">最高心情指数</span>
|
||||
<span class="text-sm font-medium text-gray-900">{{ maxMoodIndex }}/10</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600">最低心情指数</span>
|
||||
<span class="text-sm font-medium text-gray-900">{{ minMoodIndex }}/10</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 活动统计 -->
|
||||
<div class="activity-stats bg-white rounded-lg shadow-sm p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">活动统计</h3>
|
||||
|
||||
<div class="stats-list space-y-4">
|
||||
<div
|
||||
v-for="stat in activityStats"
|
||||
:key="stat.title"
|
||||
class="stat-item flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div
|
||||
class="w-8 h-8 rounded-lg flex items-center justify-center"
|
||||
:style="{ backgroundColor: stat.color + '20' }"
|
||||
>
|
||||
<el-icon :size="16" :style="{ color: stat.color }">
|
||||
<component :is="stat.icon" />
|
||||
</el-icon>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-900">{{ stat.title }}</span>
|
||||
</div>
|
||||
<span class="text-sm text-gray-600">{{ stat.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI洞察 -->
|
||||
<div class="ai-insights bg-white rounded-lg shadow-sm p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">AI洞察</h3>
|
||||
|
||||
<div class="insights-list space-y-4">
|
||||
<div
|
||||
v-for="insight in aiInsights"
|
||||
:key="insight.id"
|
||||
class="insight-item p-4 border border-gray-200 rounded-lg"
|
||||
>
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="w-8 h-8 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<el-icon size="16" class="text-white">
|
||||
<ChatDotRound />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-1">{{ insight.title }}</h4>
|
||||
<p class="text-xs text-gray-600">{{ insight.content }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-button class="w-full mt-4" @click="generateMoreInsights">
|
||||
<el-icon class="mr-2"><Refresh /></el-icon>
|
||||
生成更多洞察
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
Download,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
TrendCharts,
|
||||
Sunny,
|
||||
Calendar,
|
||||
ChatDotRound,
|
||||
EditPen,
|
||||
Trophy,
|
||||
Refresh
|
||||
} from '@element-plus/icons-vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { EMOTION_COLORS } from '@/config/constants'
|
||||
|
||||
// 响应式数据
|
||||
const timeRange = ref('30d')
|
||||
const chartType = ref('line')
|
||||
const emotionTrendChartRef = ref<HTMLElement>()
|
||||
const emotionDistributionChartRef = ref<HTMLElement>()
|
||||
const moodIndexChartRef = ref<HTMLElement>()
|
||||
|
||||
let emotionTrendChart: echarts.ECharts | null = null
|
||||
let emotionDistributionChart: echarts.ECharts | null = null
|
||||
let moodIndexChart: echarts.ECharts | null = null
|
||||
|
||||
// 概览数据
|
||||
const overviewCards = [
|
||||
{
|
||||
title: '平均心情指数',
|
||||
value: '7.2',
|
||||
change: 5.2,
|
||||
icon: Sunny,
|
||||
color: '#f59e0b'
|
||||
},
|
||||
{
|
||||
title: '记录天数',
|
||||
value: '28',
|
||||
change: 12.5,
|
||||
icon: Calendar,
|
||||
color: '#10b981'
|
||||
},
|
||||
{
|
||||
title: '对话次数',
|
||||
value: '156',
|
||||
change: -3.1,
|
||||
icon: ChatDotRound,
|
||||
color: '#3b82f6'
|
||||
},
|
||||
{
|
||||
title: '情绪稳定性',
|
||||
value: '85%',
|
||||
change: 8.7,
|
||||
icon: TrendCharts,
|
||||
color: '#8b5cf6'
|
||||
}
|
||||
]
|
||||
|
||||
// 统计数据
|
||||
const avgMoodIndex = ref(7.2)
|
||||
const maxMoodIndex = ref(9.5)
|
||||
const minMoodIndex = ref(4.8)
|
||||
|
||||
// 活动统计
|
||||
const activityStats = [
|
||||
{ title: '日记篇数', value: '23篇', icon: EditPen, color: '#10b981' },
|
||||
{ title: '对话次数', value: '156次', icon: ChatDotRound, color: '#3b82f6' },
|
||||
{ title: '获得成就', value: '12个', icon: Trophy, color: '#f59e0b' },
|
||||
{ title: '连续天数', value: '15天', icon: Calendar, color: '#8b5cf6' }
|
||||
]
|
||||
|
||||
// AI洞察
|
||||
const aiInsights = [
|
||||
{
|
||||
id: '1',
|
||||
title: '情绪稳定性良好',
|
||||
content: '您的情绪波动较小,心理状态相对稳定,建议继续保持当前的生活节奏。'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '积极情绪占主导',
|
||||
content: '最近30天中,积极情绪占比达到68%,说明您的心态比较乐观。'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: '周末心情更佳',
|
||||
content: '数据显示您在周末的心情指数普遍较高,建议合理安排工作与休息。'
|
||||
}
|
||||
]
|
||||
|
||||
// 方法
|
||||
const handleTimeRangeChange = () => {
|
||||
// 重新加载数据和图表
|
||||
loadChartData()
|
||||
}
|
||||
|
||||
const initEmotionTrendChart = () => {
|
||||
if (!emotionTrendChartRef.value) return
|
||||
|
||||
emotionTrendChart = echarts.init(emotionTrendChartRef.value)
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross'
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: ['心情指数', '情绪强度']
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['1月', '2月', '3月', '4月', '5月', '6月', '7月']
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: 10
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '心情指数',
|
||||
type: chartType.value,
|
||||
smooth: true,
|
||||
data: [6.5, 7.2, 6.8, 7.5, 8.1, 7.8, 8.3],
|
||||
itemStyle: {
|
||||
color: '#3b82f6'
|
||||
},
|
||||
areaStyle: chartType.value === 'line' ? {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(59, 130, 246, 0.3)' },
|
||||
{ offset: 1, color: 'rgba(59, 130, 246, 0.1)' }
|
||||
])
|
||||
} : undefined
|
||||
},
|
||||
{
|
||||
name: '情绪强度',
|
||||
type: chartType.value,
|
||||
smooth: true,
|
||||
data: [5.8, 6.5, 6.2, 7.0, 7.5, 7.2, 7.8],
|
||||
itemStyle: {
|
||||
color: '#10b981'
|
||||
},
|
||||
areaStyle: chartType.value === 'line' ? {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(16, 185, 129, 0.3)' },
|
||||
{ offset: 1, color: 'rgba(16, 185, 129, 0.1)' }
|
||||
])
|
||||
} : undefined
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
emotionTrendChart.setOption(option)
|
||||
}
|
||||
|
||||
const initEmotionDistributionChart = () => {
|
||||
if (!emotionDistributionChartRef.value) return
|
||||
|
||||
emotionDistributionChart = echarts.init(emotionDistributionChartRef.value)
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
left: 'left'
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '情绪分布',
|
||||
type: 'pie',
|
||||
radius: '50%',
|
||||
data: [
|
||||
{ value: 35, name: '开心', itemStyle: { color: EMOTION_COLORS.happy } },
|
||||
{ value: 25, name: '平静', itemStyle: { color: EMOTION_COLORS.calm } },
|
||||
{ value: 20, name: '兴奋', itemStyle: { color: EMOTION_COLORS.excited } },
|
||||
{ value: 15, name: '焦虑', itemStyle: { color: EMOTION_COLORS.anxious } },
|
||||
{ value: 5, name: '难过', itemStyle: { color: EMOTION_COLORS.sad } }
|
||||
],
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
emotionDistributionChart.setOption(option)
|
||||
}
|
||||
|
||||
const initMoodIndexChart = () => {
|
||||
if (!moodIndexChartRef.value) return
|
||||
|
||||
moodIndexChart = echarts.init(moodIndexChartRef.value)
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'axis'
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: 10
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: [6.5, 7.2, 6.8, 7.5, 8.1, 8.8, 8.3],
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#667eea' },
|
||||
{ offset: 1, color: '#764ba2' }
|
||||
])
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
moodIndexChart.setOption(option)
|
||||
}
|
||||
|
||||
const loadChartData = () => {
|
||||
// 根据时间范围重新加载数据
|
||||
if (emotionTrendChart) {
|
||||
emotionTrendChart.setOption({
|
||||
series: [
|
||||
{
|
||||
type: chartType.value,
|
||||
data: timeRange.value === '7d'
|
||||
? [6, 7, 8, 6, 9, 7, 8]
|
||||
: [6.5, 7.2, 6.8, 7.5, 8.1, 7.8, 8.3]
|
||||
},
|
||||
{
|
||||
type: chartType.value,
|
||||
data: timeRange.value === '7d'
|
||||
? [5.5, 6.8, 7.2, 5.8, 8.5, 6.9, 7.6]
|
||||
: [5.8, 6.5, 6.2, 7.0, 7.5, 7.2, 7.8]
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const exportReport = () => {
|
||||
ElMessage.info('报告导出功能开发中...')
|
||||
}
|
||||
|
||||
const showEmotionDetail = () => {
|
||||
ElMessage.info('情绪详情功能开发中...')
|
||||
}
|
||||
|
||||
const generateMoreInsights = () => {
|
||||
ElMessage.info('正在生成更多AI洞察...')
|
||||
}
|
||||
|
||||
// 监听图表类型变化
|
||||
watch(chartType, () => {
|
||||
loadChartData()
|
||||
})
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
initEmotionTrendChart()
|
||||
initEmotionDistributionChart()
|
||||
initMoodIndexChart()
|
||||
})
|
||||
})
|
||||
|
||||
// 响应式处理
|
||||
onUnmounted(() => {
|
||||
if (emotionTrendChart) {
|
||||
emotionTrendChart.dispose()
|
||||
}
|
||||
if (emotionDistributionChart) {
|
||||
emotionDistributionChart.dispose()
|
||||
}
|
||||
if (moodIndexChart) {
|
||||
moodIndexChart.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
// 窗口大小变化时重新调整图表
|
||||
window.addEventListener('resize', () => {
|
||||
if (emotionTrendChart) {
|
||||
emotionTrendChart.resize()
|
||||
}
|
||||
if (emotionDistributionChart) {
|
||||
emotionDistributionChart.resize()
|
||||
}
|
||||
if (moodIndexChart) {
|
||||
moodIndexChart.resize()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.overview-card {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.overview-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-item:hover {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.insight-item {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.insight-item:hover {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
</style>
|
||||
@@ -1,295 +0,0 @@
|
||||
<template>
|
||||
<div class="login-form">
|
||||
<!-- 标题 -->
|
||||
<div class="text-center mb-6">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-2">欢迎回来</h2>
|
||||
<p class="text-gray-600">登录您的账户继续使用</p>
|
||||
</div>
|
||||
|
||||
<!-- 登录表单 -->
|
||||
<el-form
|
||||
ref="loginFormRef"
|
||||
:model="loginForm"
|
||||
:rules="loginRules"
|
||||
size="large"
|
||||
@submit.prevent="handleLogin"
|
||||
>
|
||||
<!-- 用户名 -->
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model="loginForm.username"
|
||||
placeholder="请输入用户名/邮箱/手机号"
|
||||
clearable
|
||||
:prefix-icon="User"
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 密码 -->
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
show-password
|
||||
clearable
|
||||
:prefix-icon="Lock"
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 验证码 -->
|
||||
<el-form-item v-if="showCaptcha" prop="captcha">
|
||||
<div class="flex space-x-2">
|
||||
<el-input
|
||||
v-model="loginForm.captcha"
|
||||
placeholder="请输入验证码"
|
||||
clearable
|
||||
class="flex-1"
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
<div
|
||||
class="w-24 h-10 bg-gray-100 rounded cursor-pointer flex items-center justify-center"
|
||||
@click="refreshCaptcha"
|
||||
>
|
||||
<img
|
||||
v-if="captchaImage"
|
||||
:src="captchaImage"
|
||||
alt="验证码"
|
||||
class="w-full h-full object-contain"
|
||||
/>
|
||||
<span v-else class="text-xs text-gray-500">点击刷新</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 记住我和忘记密码 -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<el-checkbox v-model="loginForm.rememberMe">
|
||||
记住我
|
||||
</el-checkbox>
|
||||
<el-link type="primary" @click="showForgotPassword">
|
||||
忘记密码?
|
||||
</el-link>
|
||||
</div>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="w-full"
|
||||
:loading="isLoggingIn"
|
||||
@click="handleLogin"
|
||||
>
|
||||
{{ isLoggingIn ? '登录中...' : '登录' }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 第三方登录 -->
|
||||
<div class="mt-6">
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-sm">
|
||||
<span class="px-2 bg-white text-gray-500">或使用以下方式登录</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid grid-cols-3 gap-3">
|
||||
<el-button
|
||||
v-for="provider in oauthProviders"
|
||||
:key="provider.name"
|
||||
class="oauth-button"
|
||||
@click="handleOAuthLogin(provider.name)"
|
||||
>
|
||||
<el-icon :size="20">
|
||||
<component :is="provider.icon" />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 注册链接 -->
|
||||
<div class="text-center mt-6">
|
||||
<span class="text-gray-600">还没有账户?</span>
|
||||
<router-link to="/auth/register" class="text-blue-600 hover:text-blue-500 ml-1">
|
||||
立即注册
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { User, Lock, ChatDotRound, Share, Link } from '@element-plus/icons-vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { authApi } from '@/api/auth'
|
||||
import type { LoginRequest } from '@/types/api'
|
||||
import { validateUsername, validatePassword } from '@/utils/validation'
|
||||
|
||||
// 状态管理
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 响应式数据
|
||||
const loginFormRef = ref<FormInstance>()
|
||||
const isLoggingIn = computed(() => authStore.isLoggingIn)
|
||||
const showCaptcha = ref(false)
|
||||
const captchaImage = ref('')
|
||||
const captchaId = ref('')
|
||||
|
||||
// 登录表单
|
||||
const loginForm = reactive<LoginRequest>({
|
||||
username: '',
|
||||
password: '',
|
||||
captcha: '',
|
||||
captchaId: '',
|
||||
rememberMe: false
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const loginRules: FormRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 3, max: 50, message: '用户名长度在 3 到 50 个字符', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' }
|
||||
],
|
||||
captcha: [
|
||||
{
|
||||
required: true,
|
||||
message: '请输入验证码',
|
||||
trigger: 'blur',
|
||||
validator: (rule: any, value: string, callback: any) => {
|
||||
if (showCaptcha.value && !value) {
|
||||
callback(new Error('请输入验证码'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 第三方登录提供商
|
||||
const oauthProviders = [
|
||||
{ name: 'wechat', icon: ChatDotRound, title: '微信' },
|
||||
{ name: 'qq', icon: Share, title: 'QQ' },
|
||||
{ name: 'github', icon: Link, title: 'GitHub' }
|
||||
]
|
||||
|
||||
// 方法
|
||||
const handleLogin = async () => {
|
||||
if (!loginFormRef.value) return
|
||||
|
||||
try {
|
||||
await loginFormRef.value.validate()
|
||||
|
||||
// 设置验证码ID
|
||||
if (showCaptcha.value) {
|
||||
loginForm.captchaId = captchaId.value
|
||||
}
|
||||
|
||||
await authStore.login(loginForm)
|
||||
} catch (error: any) {
|
||||
console.error('登录失败:', error)
|
||||
|
||||
// 如果是验证码错误,刷新验证码
|
||||
if (error.message?.includes('验证码')) {
|
||||
await refreshCaptcha()
|
||||
}
|
||||
|
||||
// 连续登录失败后显示验证码
|
||||
if (!showCaptcha.value && error.code === 'LOGIN_FAILED_TOO_MANY') {
|
||||
showCaptcha.value = true
|
||||
await refreshCaptcha()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const refreshCaptcha = async () => {
|
||||
try {
|
||||
const response = await authApi.getCaptcha()
|
||||
captchaImage.value = response.captchaImage
|
||||
captchaId.value = response.captchaId
|
||||
loginForm.captcha = ''
|
||||
} catch (error) {
|
||||
console.error('获取验证码失败:', error)
|
||||
ElMessage.error('获取验证码失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
const handleOAuthLogin = async (provider: string) => {
|
||||
try {
|
||||
ElMessage.info(`${provider} 登录功能开发中...`)
|
||||
|
||||
// 这里实现第三方登录逻辑
|
||||
// 1. 跳转到第三方授权页面
|
||||
// 2. 获取授权码
|
||||
// 3. 调用后端接口完成登录
|
||||
|
||||
} catch (error) {
|
||||
console.error('第三方登录失败:', error)
|
||||
ElMessage.error('第三方登录失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
const showForgotPassword = () => {
|
||||
ElMessage.info('忘记密码功能开发中...')
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
// 如果已登录,跳转到首页
|
||||
if (authStore.isLoggedIn) {
|
||||
router.push('/home')
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否需要显示验证码
|
||||
const loginAttempts = localStorage.getItem('login_attempts')
|
||||
if (loginAttempts && parseInt(loginAttempts) >= 3) {
|
||||
showCaptcha.value = true
|
||||
refreshCaptcha()
|
||||
}
|
||||
})
|
||||
|
||||
// 监听登录失败次数
|
||||
let loginFailCount = 0
|
||||
watch(() => authStore.isLoggingIn, (isLogging) => {
|
||||
if (!isLogging && !authStore.isLoggedIn) {
|
||||
loginFailCount++
|
||||
localStorage.setItem('login_attempts', loginFailCount.toString())
|
||||
|
||||
// 失败3次后显示验证码
|
||||
if (loginFailCount >= 3 && !showCaptcha.value) {
|
||||
showCaptcha.value = true
|
||||
refreshCaptcha()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.oauth-button {
|
||||
@apply w-full h-12 border border-gray-300 rounded-lg hover:border-gray-400 transition-colors;
|
||||
}
|
||||
|
||||
.oauth-button:hover {
|
||||
@apply bg-gray-50;
|
||||
}
|
||||
|
||||
:deep(.el-input__inner) {
|
||||
@apply h-12;
|
||||
}
|
||||
|
||||
:deep(.el-button--large) {
|
||||
@apply h-12;
|
||||
}
|
||||
</style>
|
||||
@@ -1,399 +0,0 @@
|
||||
<template>
|
||||
<div class="register-form">
|
||||
<!-- 标题 -->
|
||||
<div class="text-center mb-6">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-2">创建账户</h2>
|
||||
<p class="text-gray-600">加入情绪博物馆,开始您的情绪记录之旅</p>
|
||||
</div>
|
||||
|
||||
<!-- 注册表单 -->
|
||||
<el-form
|
||||
ref="registerFormRef"
|
||||
:model="registerForm"
|
||||
:rules="registerRules"
|
||||
size="large"
|
||||
@submit.prevent="handleRegister"
|
||||
>
|
||||
<!-- 用户名 -->
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model="registerForm.username"
|
||||
placeholder="请输入用户名"
|
||||
clearable
|
||||
:prefix-icon="User"
|
||||
@blur="checkUsernameAvailable"
|
||||
/>
|
||||
<div v-if="usernameStatus" class="text-xs mt-1" :class="usernameStatusClass">
|
||||
{{ usernameStatusText }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 邮箱 -->
|
||||
<el-form-item prop="email">
|
||||
<el-input
|
||||
v-model="registerForm.email"
|
||||
placeholder="请输入邮箱地址"
|
||||
clearable
|
||||
:prefix-icon="Message"
|
||||
@blur="checkEmailAvailable"
|
||||
/>
|
||||
<div v-if="emailStatus" class="text-xs mt-1" :class="emailStatusClass">
|
||||
{{ emailStatusText }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 手机号 -->
|
||||
<el-form-item prop="phone">
|
||||
<el-input
|
||||
v-model="registerForm.phone"
|
||||
placeholder="请输入手机号(可选)"
|
||||
clearable
|
||||
:prefix-icon="Phone"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 密码 -->
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="registerForm.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
show-password
|
||||
clearable
|
||||
:prefix-icon="Lock"
|
||||
/>
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
密码长度6-20位,包含字母和数字
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 确认密码 -->
|
||||
<el-form-item prop="confirmPassword">
|
||||
<el-input
|
||||
v-model="registerForm.confirmPassword"
|
||||
type="password"
|
||||
placeholder="请确认密码"
|
||||
show-password
|
||||
clearable
|
||||
:prefix-icon="Lock"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 验证码 -->
|
||||
<el-form-item prop="captcha">
|
||||
<div class="flex space-x-2">
|
||||
<el-input
|
||||
v-model="registerForm.captcha"
|
||||
placeholder="请输入验证码"
|
||||
clearable
|
||||
class="flex-1"
|
||||
/>
|
||||
<div
|
||||
class="w-24 h-10 bg-gray-100 rounded cursor-pointer flex items-center justify-center"
|
||||
@click="refreshCaptcha"
|
||||
>
|
||||
<img
|
||||
v-if="captchaImage"
|
||||
:src="captchaImage"
|
||||
alt="验证码"
|
||||
class="w-full h-full object-contain"
|
||||
/>
|
||||
<span v-else class="text-xs text-gray-500">点击刷新</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 邀请码 -->
|
||||
<el-form-item prop="inviteCode">
|
||||
<el-input
|
||||
v-model="registerForm.inviteCode"
|
||||
placeholder="请输入邀请码(可选)"
|
||||
clearable
|
||||
:prefix-icon="Ticket"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 服务条款 -->
|
||||
<el-form-item prop="agreement">
|
||||
<el-checkbox v-model="registerForm.agreement">
|
||||
我已阅读并同意
|
||||
<el-link type="primary" @click="showTerms">《服务条款》</el-link>
|
||||
和
|
||||
<el-link type="primary" @click="showPrivacy">《隐私政策》</el-link>
|
||||
</el-checkbox>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 注册按钮 -->
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="w-full"
|
||||
:loading="isRegistering"
|
||||
@click="handleRegister"
|
||||
>
|
||||
{{ isRegistering ? '注册中...' : '注册' }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 登录链接 -->
|
||||
<div class="text-center mt-6">
|
||||
<span class="text-gray-600">已有账户?</span>
|
||||
<router-link to="/auth/login" class="text-blue-600 hover:text-blue-500 ml-1">
|
||||
立即登录
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { User, Message, Phone, Lock, Ticket } from '@element-plus/icons-vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { authApi } from '@/api/auth'
|
||||
import type { RegisterRequest } from '@/types/api'
|
||||
import { validateUsername, validateEmail, validatePhone, validatePassword } from '@/utils/validation'
|
||||
|
||||
// 状态管理
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 响应式数据
|
||||
const registerFormRef = ref<FormInstance>()
|
||||
const isRegistering = computed(() => authStore.isRegistering)
|
||||
const captchaImage = ref('')
|
||||
const captchaId = ref('')
|
||||
const usernameStatus = ref('')
|
||||
const emailStatus = ref('')
|
||||
|
||||
// 注册表单
|
||||
const registerForm = reactive<RegisterRequest & { agreement: boolean }>({
|
||||
username: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
captcha: '',
|
||||
captchaId: '',
|
||||
inviteCode: '',
|
||||
agreement: false
|
||||
})
|
||||
|
||||
// 用户名状态样式
|
||||
const usernameStatusClass = computed(() => {
|
||||
if (usernameStatus.value === 'available') return 'text-green-600'
|
||||
if (usernameStatus.value === 'unavailable') return 'text-red-600'
|
||||
return 'text-gray-500'
|
||||
})
|
||||
|
||||
const usernameStatusText = computed(() => {
|
||||
if (usernameStatus.value === 'available') return '✓ 用户名可用'
|
||||
if (usernameStatus.value === 'unavailable') return '✗ 用户名已被使用'
|
||||
if (usernameStatus.value === 'checking') return '检查中...'
|
||||
return ''
|
||||
})
|
||||
|
||||
// 邮箱状态样式
|
||||
const emailStatusClass = computed(() => {
|
||||
if (emailStatus.value === 'available') return 'text-green-600'
|
||||
if (emailStatus.value === 'unavailable') return 'text-red-600'
|
||||
return 'text-gray-500'
|
||||
})
|
||||
|
||||
const emailStatusText = computed(() => {
|
||||
if (emailStatus.value === 'available') return '✓ 邮箱可用'
|
||||
if (emailStatus.value === 'unavailable') return '✗ 邮箱已被注册'
|
||||
if (emailStatus.value === 'checking') return '检查中...'
|
||||
return ''
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const registerRules: FormRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 3, max: 20, message: '用户名长度在 3 到 20 个字符', trigger: 'blur' },
|
||||
{
|
||||
validator: (rule: any, value: string, callback: any) => {
|
||||
if (!validateUsername(value)) {
|
||||
callback(new Error('用户名只能包含字母、数字、下划线和中文'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱地址', trigger: 'blur' },
|
||||
{
|
||||
validator: (rule: any, value: string, callback: any) => {
|
||||
if (!validateEmail(value)) {
|
||||
callback(new Error('请输入正确的邮箱地址'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
phone: [
|
||||
{
|
||||
validator: (rule: any, value: string, callback: any) => {
|
||||
if (value && !validatePhone(value)) {
|
||||
callback(new Error('请输入正确的手机号'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' },
|
||||
{
|
||||
validator: (rule: any, value: string, callback: any) => {
|
||||
if (!validatePassword(value)) {
|
||||
callback(new Error('密码必须包含字母和数字'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: '请确认密码', trigger: 'blur' },
|
||||
{
|
||||
validator: (rule: any, value: string, callback: any) => {
|
||||
if (value !== registerForm.password) {
|
||||
callback(new Error('两次输入的密码不一致'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
captcha: [
|
||||
{ required: true, message: '请输入验证码', trigger: 'blur' }
|
||||
],
|
||||
agreement: [
|
||||
{
|
||||
validator: (rule: any, value: boolean, callback: any) => {
|
||||
if (!value) {
|
||||
callback(new Error('请阅读并同意服务条款和隐私政策'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 方法
|
||||
const handleRegister = async () => {
|
||||
if (!registerFormRef.value) return
|
||||
|
||||
try {
|
||||
await registerFormRef.value.validate()
|
||||
|
||||
// 设置验证码ID
|
||||
registerForm.captchaId = captchaId.value
|
||||
|
||||
await authStore.register(registerForm)
|
||||
} catch (error: any) {
|
||||
console.error('注册失败:', error)
|
||||
|
||||
// 如果是验证码错误,刷新验证码
|
||||
if (error.message?.includes('验证码')) {
|
||||
await refreshCaptcha()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const checkUsernameAvailable = async () => {
|
||||
if (!registerForm.username || !validateUsername(registerForm.username)) {
|
||||
usernameStatus.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
usernameStatus.value = 'checking'
|
||||
|
||||
try {
|
||||
// 这里调用检查用户名可用性的API
|
||||
// const available = await authApi.checkUsernameAvailable(registerForm.username)
|
||||
// 模拟检查结果
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
usernameStatus.value = Math.random() > 0.5 ? 'available' : 'unavailable'
|
||||
} catch (error) {
|
||||
usernameStatus.value = ''
|
||||
console.error('检查用户名失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const checkEmailAvailable = async () => {
|
||||
if (!registerForm.email || !validateEmail(registerForm.email)) {
|
||||
emailStatus.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
emailStatus.value = 'checking'
|
||||
|
||||
try {
|
||||
// 这里调用检查邮箱可用性的API
|
||||
// const available = await authApi.checkEmailAvailable(registerForm.email)
|
||||
// 模拟检查结果
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
emailStatus.value = Math.random() > 0.5 ? 'available' : 'unavailable'
|
||||
} catch (error) {
|
||||
emailStatus.value = ''
|
||||
console.error('检查邮箱失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const refreshCaptcha = async () => {
|
||||
try {
|
||||
const response = await authApi.getCaptcha()
|
||||
captchaImage.value = response.captchaImage
|
||||
captchaId.value = response.captchaId
|
||||
registerForm.captcha = ''
|
||||
} catch (error) {
|
||||
console.error('获取验证码失败:', error)
|
||||
ElMessage.error('获取验证码失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
const showTerms = () => {
|
||||
ElMessage.info('服务条款页面开发中...')
|
||||
}
|
||||
|
||||
const showPrivacy = () => {
|
||||
ElMessage.info('隐私政策页面开发中...')
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
// 如果已登录,跳转到首页
|
||||
if (authStore.isLoggedIn) {
|
||||
router.push('/home')
|
||||
return
|
||||
}
|
||||
|
||||
// 获取验证码
|
||||
refreshCaptcha()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.el-input__inner) {
|
||||
@apply h-12;
|
||||
}
|
||||
|
||||
:deep(.el-button--large) {
|
||||
@apply h-12;
|
||||
}
|
||||
</style>
|
||||
@@ -1,331 +0,0 @@
|
||||
<template>
|
||||
<div class="chat-page h-screen flex flex-col bg-gray-50">
|
||||
<!-- 聊天头部 -->
|
||||
<header class="chat-header bg-white border-b border-gray-200 px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center">
|
||||
<el-icon size="20" class="text-white">
|
||||
<ChatDotRound />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold text-gray-900">AI助手</h1>
|
||||
<p class="text-sm text-gray-500">
|
||||
<span :class="connectionStatusClass">{{ connectionStatusText }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<el-button circle @click="showHistory">
|
||||
<el-icon><Clock /></el-icon>
|
||||
</el-button>
|
||||
<el-button circle @click="clearChat">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<main class="chat-messages flex-1 overflow-hidden">
|
||||
<div ref="messagesContainer" class="h-full overflow-y-auto px-6 py-4">
|
||||
<div v-if="messages.length === 0" class="text-center py-20">
|
||||
<div class="w-20 h-20 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<el-icon size="32" class="text-gray-400">
|
||||
<ChatDotRound />
|
||||
</el-icon>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">开始对话</h3>
|
||||
<p class="text-gray-500">向AI助手说点什么吧,我会认真倾听</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
class="message-item"
|
||||
:class="message.senderType === 'USER' ? 'user-message' : 'ai-message'"
|
||||
>
|
||||
<div class="flex items-start space-x-3">
|
||||
<!-- 头像 -->
|
||||
<div class="flex-shrink-0">
|
||||
<el-avatar
|
||||
v-if="message.senderType === 'USER'"
|
||||
:src="message.senderAvatar"
|
||||
:size="32"
|
||||
>
|
||||
<el-icon><User /></el-icon>
|
||||
</el-avatar>
|
||||
<div
|
||||
v-else
|
||||
class="w-8 h-8 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center"
|
||||
>
|
||||
<el-icon size="16" class="text-white">
|
||||
<ChatDotRound />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息内容 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center space-x-2 mb-1">
|
||||
<span class="text-sm font-medium text-gray-900">
|
||||
{{ message.senderName }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500">
|
||||
{{ formatTime(message.timestamp) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="message-bubble"
|
||||
:class="message.senderType === 'USER' ? 'user-bubble' : 'ai-bubble'"
|
||||
>
|
||||
<p class="text-sm">{{ message.content }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 消息状态 -->
|
||||
<div v-if="message.status === 'sending'" class="text-xs text-gray-400 mt-1">
|
||||
发送中...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 正在输入指示器 -->
|
||||
<div v-if="isTyping" class="typing-indicator flex items-center space-x-2 mt-4">
|
||||
<div class="w-8 h-8 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center">
|
||||
<el-icon size="16" class="text-white">
|
||||
<ChatDotRound />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="bg-gray-100 rounded-lg px-3 py-2">
|
||||
<div class="flex space-x-1">
|
||||
<div class="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
|
||||
<div class="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay: 0.1s"></div>
|
||||
<div class="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay: 0.2s"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 输入区域 -->
|
||||
<footer class="chat-input bg-white border-t border-gray-200 px-6 py-4">
|
||||
<div class="flex items-end space-x-3">
|
||||
<div class="flex-1">
|
||||
<el-input
|
||||
v-model="inputMessage"
|
||||
type="textarea"
|
||||
:rows="1"
|
||||
:autosize="{ minRows: 1, maxRows: 4 }"
|
||||
placeholder="输入消息..."
|
||||
@keydown.enter.exact.prevent="handleSendMessage"
|
||||
@keydown.enter.shift.exact="handleNewLine"
|
||||
@input="handleTyping"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<el-button circle @click="showEmojiPicker">
|
||||
<el-icon><Sunny /></el-icon>
|
||||
</el-button>
|
||||
<el-button circle @click="showFileUpload">
|
||||
<el-icon><Paperclip /></el-icon>
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:disabled="!inputMessage.trim() || !isConnected"
|
||||
@click="handleSendMessage"
|
||||
>
|
||||
发送
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
ChatDotRound,
|
||||
Clock,
|
||||
Delete,
|
||||
User,
|
||||
Sunny,
|
||||
Paperclip
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useChat } from '@/composables/useChat'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { formatRelativeTime } from '@/utils/format'
|
||||
|
||||
// 状态管理
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 聊天功能
|
||||
const {
|
||||
messages,
|
||||
isConnected,
|
||||
connectionState,
|
||||
sendMessage,
|
||||
clearMessages
|
||||
} = useChat()
|
||||
|
||||
// 响应式数据
|
||||
const inputMessage = ref('')
|
||||
const isTyping = ref(false)
|
||||
const messagesContainer = ref<HTMLElement>()
|
||||
|
||||
// 计算属性
|
||||
const connectionStatusText = computed(() => {
|
||||
switch (connectionState.value) {
|
||||
case 'CONNECTED':
|
||||
return '在线'
|
||||
case 'CONNECTING':
|
||||
return '连接中...'
|
||||
case 'DISCONNECTED':
|
||||
return '离线'
|
||||
case 'ERROR':
|
||||
return '连接错误'
|
||||
default:
|
||||
return '未知状态'
|
||||
}
|
||||
})
|
||||
|
||||
const connectionStatusClass = computed(() => {
|
||||
switch (connectionState.value) {
|
||||
case 'CONNECTED':
|
||||
return 'text-green-500'
|
||||
case 'CONNECTING':
|
||||
return 'text-yellow-500'
|
||||
case 'DISCONNECTED':
|
||||
return 'text-gray-500'
|
||||
case 'ERROR':
|
||||
return 'text-red-500'
|
||||
default:
|
||||
return 'text-gray-500'
|
||||
}
|
||||
})
|
||||
|
||||
// 方法
|
||||
const handleSendMessage = () => {
|
||||
const content = inputMessage.value.trim()
|
||||
if (!content) return
|
||||
|
||||
if (!isConnected.value) {
|
||||
ElMessage.error('连接已断开,无法发送消息')
|
||||
return
|
||||
}
|
||||
|
||||
sendMessage(content)
|
||||
inputMessage.value = ''
|
||||
|
||||
// 滚动到底部
|
||||
nextTick(() => {
|
||||
scrollToBottom()
|
||||
})
|
||||
}
|
||||
|
||||
const handleNewLine = () => {
|
||||
inputMessage.value += '\n'
|
||||
}
|
||||
|
||||
const handleTyping = () => {
|
||||
// 这里可以实现正在输入状态的发送
|
||||
// sendTypingStatus(true)
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (messagesContainer.value) {
|
||||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
return formatRelativeTime(timestamp)
|
||||
}
|
||||
|
||||
const showHistory = () => {
|
||||
ElMessage.info('聊天历史功能开发中...')
|
||||
}
|
||||
|
||||
const clearChat = () => {
|
||||
ElMessageBox.confirm(
|
||||
'确定要清空聊天记录吗?',
|
||||
'提示',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
).then(() => {
|
||||
clearMessages()
|
||||
ElMessage.success('聊天记录已清空')
|
||||
})
|
||||
}
|
||||
|
||||
const showEmojiPicker = () => {
|
||||
ElMessage.info('表情选择功能开发中...')
|
||||
}
|
||||
|
||||
const showFileUpload = () => {
|
||||
ElMessage.info('文件上传功能开发中...')
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
// 滚动到底部
|
||||
nextTick(() => {
|
||||
scrollToBottom()
|
||||
})
|
||||
})
|
||||
|
||||
// 监听消息变化,自动滚动到底部
|
||||
watch(messages, () => {
|
||||
nextTick(() => {
|
||||
scrollToBottom()
|
||||
})
|
||||
}, { deep: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-item.user-message {
|
||||
@apply flex-row-reverse;
|
||||
}
|
||||
|
||||
.message-item.user-message .flex {
|
||||
@apply flex-row-reverse;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
@apply max-w-xs lg:max-w-md px-4 py-2 rounded-lg;
|
||||
}
|
||||
|
||||
.user-bubble {
|
||||
@apply bg-blue-500 text-white ml-auto;
|
||||
}
|
||||
|
||||
.ai-bubble {
|
||||
@apply bg-gray-100 text-gray-900;
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
@apply opacity-70;
|
||||
}
|
||||
|
||||
:deep(.el-textarea__inner) {
|
||||
resize: none;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
:deep(.el-textarea__inner):focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,474 +0,0 @@
|
||||
<template>
|
||||
<div class="chat-history-page h-full flex flex-col bg-gray-50">
|
||||
<!-- 头部 -->
|
||||
<header class="chat-history-header bg-white border-b border-gray-200 px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<el-icon size="24" class="text-gray-600">
|
||||
<Clock />
|
||||
</el-icon>
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold text-gray-900">聊天历史</h1>
|
||||
<p class="text-sm text-gray-500">查看和管理您的聊天记录</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<el-button @click="exportHistory">
|
||||
<el-icon class="mr-2"><Download /></el-icon>
|
||||
导出
|
||||
</el-button>
|
||||
<el-button @click="clearAllHistory" type="danger">
|
||||
<el-icon class="mr-2"><Delete /></el-icon>
|
||||
清空
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 搜索和筛选 -->
|
||||
<div class="search-filters bg-white border-b border-gray-200 px-6 py-4">
|
||||
<div class="flex flex-col lg:flex-row gap-4">
|
||||
<!-- 搜索框 -->
|
||||
<div class="flex-1">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索消息内容..."
|
||||
clearable
|
||||
@input="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<!-- 筛选条件 -->
|
||||
<div class="flex gap-4">
|
||||
<!-- 消息类型 -->
|
||||
<el-select
|
||||
v-model="filterType"
|
||||
placeholder="消息类型"
|
||||
clearable
|
||||
@change="handleFilter"
|
||||
>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="文本" value="text" />
|
||||
<el-option label="图片" value="image" />
|
||||
<el-option label="文件" value="file" />
|
||||
<el-option label="表情" value="emoji" />
|
||||
</el-select>
|
||||
|
||||
<!-- 发送者 -->
|
||||
<el-select
|
||||
v-model="filterSender"
|
||||
placeholder="发送者"
|
||||
clearable
|
||||
@change="handleFilter"
|
||||
>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="我" value="USER" />
|
||||
<el-option label="AI助手" value="AI" />
|
||||
<el-option label="系统" value="SYSTEM" />
|
||||
</el-select>
|
||||
|
||||
<!-- 时间范围 -->
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
@change="handleFilter"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<main class="message-list flex-1 overflow-hidden">
|
||||
<div class="h-full overflow-y-auto px-6 py-4">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="text-center py-8">
|
||||
<el-icon size="32" class="animate-spin text-blue-500">
|
||||
<Loading />
|
||||
</el-icon>
|
||||
<p class="text-gray-500 mt-2">加载中...</p>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="filteredMessages.length === 0" class="text-center py-20">
|
||||
<div class="w-20 h-20 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<el-icon size="32" class="text-gray-400">
|
||||
<ChatDotRound />
|
||||
</el-icon>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">暂无聊天记录</h3>
|
||||
<p class="text-gray-500">开始与AI助手对话吧</p>
|
||||
<el-button type="primary" class="mt-4" @click="goToChat">
|
||||
开始对话
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<div v-else class="space-y-6">
|
||||
<!-- 按日期分组 -->
|
||||
<div
|
||||
v-for="(group, date) in groupedMessages"
|
||||
:key="date"
|
||||
class="message-group"
|
||||
>
|
||||
<!-- 日期分隔符 -->
|
||||
<div class="date-divider flex items-center justify-center mb-4">
|
||||
<div class="bg-gray-100 px-3 py-1 rounded-full">
|
||||
<span class="text-sm text-gray-600">{{ formatDate(date) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 该日期的消息 -->
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="message in group"
|
||||
:key="message.id"
|
||||
class="message-item"
|
||||
:class="message.senderType === 'USER' ? 'user-message' : 'ai-message'"
|
||||
>
|
||||
<div class="flex items-start space-x-3">
|
||||
<!-- 头像 -->
|
||||
<div class="flex-shrink-0">
|
||||
<el-avatar
|
||||
v-if="message.senderType === 'USER'"
|
||||
:src="message.senderAvatar"
|
||||
:size="32"
|
||||
>
|
||||
<el-icon><User /></el-icon>
|
||||
</el-avatar>
|
||||
<div
|
||||
v-else
|
||||
class="w-8 h-8 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center"
|
||||
>
|
||||
<el-icon size="16" class="text-white">
|
||||
<ChatDotRound />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息内容 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center space-x-2 mb-1">
|
||||
<span class="text-sm font-medium text-gray-900">
|
||||
{{ message.senderName }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500">
|
||||
{{ formatTime(message.timestamp) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="message-bubble"
|
||||
:class="message.senderType === 'USER' ? 'user-bubble' : 'ai-bubble'"
|
||||
>
|
||||
<!-- 文本消息 -->
|
||||
<p v-if="message.type === 'text'" class="text-sm">
|
||||
{{ message.content }}
|
||||
</p>
|
||||
|
||||
<!-- 图片消息 -->
|
||||
<div v-else-if="message.type === 'image'" class="image-message">
|
||||
<img
|
||||
:src="message.content"
|
||||
alt="图片消息"
|
||||
class="max-w-xs rounded cursor-pointer"
|
||||
@click="previewImage(message.content)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 文件消息 -->
|
||||
<div v-else-if="message.type === 'file'" class="file-message">
|
||||
<div class="flex items-center space-x-2 p-2 bg-gray-50 rounded">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span class="text-sm">{{ message.metadata?.fileName }}</span>
|
||||
<el-button size="small" text @click="downloadFile(message)">
|
||||
下载
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表情消息 -->
|
||||
<div v-else-if="message.type === 'emoji'" class="emoji-message">
|
||||
<span class="text-2xl">{{ message.content }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="message-actions opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<el-dropdown @command="handleMessageAction">
|
||||
<el-button circle size="small" text>
|
||||
<el-icon><MoreFilled /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item :command="`copy_${message.id}`">
|
||||
复制
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item :command="`quote_${message.id}`">
|
||||
引用
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
:command="`delete_${message.id}`"
|
||||
divided
|
||||
>
|
||||
删除
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<div v-if="hasMore" class="text-center py-4">
|
||||
<el-button @click="loadMore" :loading="loadingMore">
|
||||
加载更多
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Clock,
|
||||
Download,
|
||||
Delete,
|
||||
Search,
|
||||
Loading,
|
||||
ChatDotRound,
|
||||
User,
|
||||
Document,
|
||||
MoreFilled
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { formatDate, formatTime } from '@/utils/format'
|
||||
import type { MessageInfo } from '@/types/api'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const hasMore = ref(true)
|
||||
const searchKeyword = ref('')
|
||||
const filterType = ref('')
|
||||
const filterSender = ref('')
|
||||
const dateRange = ref<[Date, Date] | null>(null)
|
||||
|
||||
// 模拟消息数据
|
||||
const messages = ref<MessageInfo[]>([
|
||||
{
|
||||
id: '1',
|
||||
conversationId: '1',
|
||||
content: '你好,我今天感觉有点焦虑',
|
||||
type: 'text',
|
||||
senderId: authStore.userId!,
|
||||
senderType: 'USER',
|
||||
senderName: authStore.nickname!,
|
||||
senderAvatar: authStore.avatar,
|
||||
status: 'read',
|
||||
timestamp: Date.now() - 1000 * 60 * 30
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
conversationId: '1',
|
||||
content: '我理解你的感受。焦虑是很常见的情绪,你能告诉我是什么让你感到焦虑吗?',
|
||||
type: 'text',
|
||||
senderId: 'ai',
|
||||
senderType: 'AI',
|
||||
senderName: 'AI助手',
|
||||
status: 'read',
|
||||
timestamp: Date.now() - 1000 * 60 * 29
|
||||
}
|
||||
])
|
||||
|
||||
// 计算属性
|
||||
const filteredMessages = computed(() => {
|
||||
let result = messages.value
|
||||
|
||||
// 搜索过滤
|
||||
if (searchKeyword.value) {
|
||||
result = result.filter(msg =>
|
||||
msg.content.toLowerCase().includes(searchKeyword.value.toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
// 类型过滤
|
||||
if (filterType.value) {
|
||||
result = result.filter(msg => msg.type === filterType.value)
|
||||
}
|
||||
|
||||
// 发送者过滤
|
||||
if (filterSender.value) {
|
||||
result = result.filter(msg => msg.senderType === filterSender.value)
|
||||
}
|
||||
|
||||
// 时间范围过滤
|
||||
if (dateRange.value) {
|
||||
const [start, end] = dateRange.value
|
||||
result = result.filter(msg => {
|
||||
const msgDate = new Date(msg.timestamp)
|
||||
return msgDate >= start && msgDate <= end
|
||||
})
|
||||
}
|
||||
|
||||
return result.sort((a, b) => b.timestamp - a.timestamp)
|
||||
})
|
||||
|
||||
const groupedMessages = computed(() => {
|
||||
const groups: Record<string, MessageInfo[]> = {}
|
||||
|
||||
filteredMessages.value.forEach(message => {
|
||||
const date = new Date(message.timestamp).toDateString()
|
||||
if (!groups[date]) {
|
||||
groups[date] = []
|
||||
}
|
||||
groups[date].push(message)
|
||||
})
|
||||
|
||||
return groups
|
||||
})
|
||||
|
||||
// 方法
|
||||
const handleSearch = () => {
|
||||
// 搜索逻辑已在计算属性中实现
|
||||
}
|
||||
|
||||
const handleFilter = () => {
|
||||
// 过滤逻辑已在计算属性中实现
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
loadingMore.value = true
|
||||
|
||||
// 模拟加载更多数据
|
||||
setTimeout(() => {
|
||||
loadingMore.value = false
|
||||
hasMore.value = false
|
||||
ElMessage.success('已加载全部消息')
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const goToChat = () => {
|
||||
router.push('/chat')
|
||||
}
|
||||
|
||||
const exportHistory = () => {
|
||||
ElMessage.info('导出功能开发中...')
|
||||
}
|
||||
|
||||
const clearAllHistory = () => {
|
||||
ElMessageBox.confirm(
|
||||
'确定要清空所有聊天记录吗?此操作不可恢复。',
|
||||
'警告',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
).then(() => {
|
||||
messages.value = []
|
||||
ElMessage.success('聊天记录已清空')
|
||||
})
|
||||
}
|
||||
|
||||
const previewImage = (imageUrl: string) => {
|
||||
ElMessage.info('图片预览功能开发中...')
|
||||
}
|
||||
|
||||
const downloadFile = (message: MessageInfo) => {
|
||||
ElMessage.info('文件下载功能开发中...')
|
||||
}
|
||||
|
||||
const handleMessageAction = (command: string) => {
|
||||
const [action, messageId] = command.split('_')
|
||||
|
||||
switch (action) {
|
||||
case 'copy':
|
||||
const message = messages.value.find(m => m.id === messageId)
|
||||
if (message) {
|
||||
navigator.clipboard.writeText(message.content)
|
||||
ElMessage.success('已复制到剪贴板')
|
||||
}
|
||||
break
|
||||
case 'quote':
|
||||
ElMessage.info('引用功能开发中...')
|
||||
break
|
||||
case 'delete':
|
||||
ElMessageBox.confirm(
|
||||
'确定要删除这条消息吗?',
|
||||
'提示',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
).then(() => {
|
||||
const index = messages.value.findIndex(m => m.id === messageId)
|
||||
if (index > -1) {
|
||||
messages.value.splice(index, 1)
|
||||
ElMessage.success('消息已删除')
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
loading.value = true
|
||||
|
||||
// 模拟加载数据
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
}, 1000)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-item {
|
||||
@apply group;
|
||||
}
|
||||
|
||||
.message-item.user-message {
|
||||
@apply flex-row-reverse;
|
||||
}
|
||||
|
||||
.message-item.user-message .flex {
|
||||
@apply flex-row-reverse;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
@apply max-w-xs lg:max-w-md px-4 py-2 rounded-lg;
|
||||
}
|
||||
|
||||
.user-bubble {
|
||||
@apply bg-blue-500 text-white ml-auto;
|
||||
}
|
||||
|
||||
.ai-bubble {
|
||||
@apply bg-gray-100 text-gray-900;
|
||||
}
|
||||
|
||||
.date-divider {
|
||||
@apply sticky top-0 z-10 bg-gray-50 py-2;
|
||||
}
|
||||
</style>
|
||||
@@ -1,440 +0,0 @@
|
||||
<template>
|
||||
<div class="dashboard-page">
|
||||
<!-- 欢迎区域 -->
|
||||
<div class="welcome-section bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg p-6 mb-6 text-white">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold mb-2">
|
||||
{{ getGreeting() }},{{ userNickname }}!
|
||||
</h1>
|
||||
<p class="text-blue-100">
|
||||
今天是您使用情绪博物馆的第 {{ totalDays }} 天
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="text-right">
|
||||
<div class="text-3xl font-bold">{{ todayMood }}/10</div>
|
||||
<div class="text-blue-100 text-sm">今日心情指数</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-grid grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||
<div
|
||||
v-for="stat in statsData"
|
||||
:key="stat.title"
|
||||
class="stat-card bg-white rounded-lg shadow-sm p-6 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-gray-600 text-sm mb-1">{{ stat.title }}</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ stat.value }}</p>
|
||||
<div v-if="stat.trend" class="flex items-center mt-2">
|
||||
<el-icon
|
||||
:class="stat.trend === 'up' ? 'text-green-500' : 'text-red-500'"
|
||||
size="16"
|
||||
>
|
||||
<ArrowUp v-if="stat.trend === 'up'" />
|
||||
<ArrowDown v-else />
|
||||
</el-icon>
|
||||
<span
|
||||
:class="stat.trend === 'up' ? 'text-green-500' : 'text-red-500'"
|
||||
class="text-sm ml-1"
|
||||
>
|
||||
{{ stat.trendValue }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="w-12 h-12 rounded-lg flex items-center justify-center"
|
||||
:style="{ backgroundColor: stat.color + '20' }"
|
||||
>
|
||||
<el-icon :size="24" :style="{ color: stat.color }">
|
||||
<component :is="stat.icon" />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<div class="charts-section grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<!-- 情绪趋势图 -->
|
||||
<div class="chart-card bg-white rounded-lg shadow-sm p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">情绪趋势</h3>
|
||||
<el-select v-model="emotionPeriod" size="small">
|
||||
<el-option label="最近7天" value="7d" />
|
||||
<el-option label="最近30天" value="30d" />
|
||||
<el-option label="最近90天" value="90d" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<div ref="emotionChartRef" class="h-64"></div>
|
||||
</div>
|
||||
|
||||
<!-- 情绪分布图 -->
|
||||
<div class="chart-card bg-white rounded-lg shadow-sm p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">情绪分布</h3>
|
||||
<el-button size="small" @click="exportChart">
|
||||
<el-icon class="mr-1"><Download /></el-icon>
|
||||
导出
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div ref="emotionPieChartRef" class="h-64"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 活动和成就 -->
|
||||
<div class="activity-section grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- 最近活动 -->
|
||||
<div class="activity-card bg-white rounded-lg shadow-sm p-6 lg:col-span-2">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">最近活动</h3>
|
||||
<el-link type="primary" @click="viewAllActivities">查看全部</el-link>
|
||||
</div>
|
||||
|
||||
<div class="activity-list space-y-4">
|
||||
<div
|
||||
v-for="activity in recentActivities"
|
||||
:key="activity.id"
|
||||
class="activity-item flex items-start space-x-3 p-3 hover:bg-gray-50 rounded-lg transition-colors"
|
||||
>
|
||||
<div
|
||||
class="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0"
|
||||
:style="{ backgroundColor: activity.color + '20' }"
|
||||
>
|
||||
<el-icon :size="16" :style="{ color: activity.color }">
|
||||
<component :is="activity.icon" />
|
||||
</el-icon>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-gray-900">{{ activity.title }}</p>
|
||||
<p class="text-xs text-gray-500">{{ formatTime(activity.time) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 成就徽章 -->
|
||||
<div class="achievements-card bg-white rounded-lg shadow-sm p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">成就徽章</h3>
|
||||
<el-link type="primary" @click="viewAllAchievements">查看全部</el-link>
|
||||
</div>
|
||||
|
||||
<div class="achievements-grid grid grid-cols-3 gap-3">
|
||||
<div
|
||||
v-for="achievement in recentAchievements"
|
||||
:key="achievement.id"
|
||||
class="achievement-item text-center p-3 hover:bg-gray-50 rounded-lg transition-colors cursor-pointer"
|
||||
@click="viewAchievement(achievement)"
|
||||
>
|
||||
<div class="w-12 h-12 mx-auto mb-2 text-2xl">
|
||||
{{ achievement.icon }}
|
||||
</div>
|
||||
<p class="text-xs text-gray-600 truncate">{{ achievement.name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<p class="text-sm text-gray-500">
|
||||
已获得 {{ totalAchievements }} 个成就
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Download,
|
||||
ChatDotRound,
|
||||
EditPen,
|
||||
TrendCharts,
|
||||
Calendar
|
||||
} from '@element-plus/icons-vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { EMOTION_COLORS } from '@/config/constants'
|
||||
import { formatRelativeTime } from '@/utils/format'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 响应式数据
|
||||
const emotionPeriod = ref('7d')
|
||||
const emotionChartRef = ref<HTMLElement>()
|
||||
const emotionPieChartRef = ref<HTMLElement>()
|
||||
let emotionChart: echarts.ECharts | null = null
|
||||
let emotionPieChart: echarts.ECharts | null = null
|
||||
|
||||
// 计算属性
|
||||
const userNickname = computed(() => authStore.nickname)
|
||||
const totalDays = ref(45)
|
||||
const todayMood = ref(7)
|
||||
const totalAchievements = ref(12)
|
||||
|
||||
// 统计数据
|
||||
const statsData = [
|
||||
{
|
||||
title: '总对话数',
|
||||
value: '156',
|
||||
trend: 'up',
|
||||
trendValue: 12,
|
||||
icon: ChatDotRound,
|
||||
color: '#3b82f6'
|
||||
},
|
||||
{
|
||||
title: '日记篇数',
|
||||
value: '23',
|
||||
trend: 'up',
|
||||
trendValue: 8,
|
||||
icon: EditPen,
|
||||
color: '#10b981'
|
||||
},
|
||||
{
|
||||
title: '平均心情',
|
||||
value: '7.2',
|
||||
trend: 'up',
|
||||
trendValue: 5,
|
||||
icon: TrendCharts,
|
||||
color: '#f59e0b'
|
||||
},
|
||||
{
|
||||
title: '连续天数',
|
||||
value: '12',
|
||||
trend: 'stable',
|
||||
trendValue: 0,
|
||||
icon: Calendar,
|
||||
color: '#8b5cf6'
|
||||
}
|
||||
]
|
||||
|
||||
// 最近活动
|
||||
const recentActivities = [
|
||||
{
|
||||
id: '1',
|
||||
title: '发布了新的情绪日记《今天的心情》',
|
||||
time: Date.now() - 1000 * 60 * 30,
|
||||
icon: EditPen,
|
||||
color: '#10b981'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '与AI助手进行了深度对话',
|
||||
time: Date.now() - 1000 * 60 * 60 * 2,
|
||||
icon: ChatDotRound,
|
||||
color: '#3b82f6'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: '获得了"连续记录7天"成就',
|
||||
time: Date.now() - 1000 * 60 * 60 * 24,
|
||||
icon: TrendCharts,
|
||||
color: '#f59e0b'
|
||||
}
|
||||
]
|
||||
|
||||
// 最近成就
|
||||
const recentAchievements = [
|
||||
{ id: '1', name: '初次记录', icon: '🎉' },
|
||||
{ id: '2', name: '连续7天', icon: '🔥' },
|
||||
{ id: '3', name: '情绪专家', icon: '🎯' },
|
||||
{ id: '4', name: '分享达人', icon: '📢' },
|
||||
{ id: '5', name: '深度思考', icon: '🤔' },
|
||||
{ id: '6', name: '积极向上', icon: '☀️' }
|
||||
]
|
||||
|
||||
// 方法
|
||||
const getGreeting = () => {
|
||||
const hour = new Date().getHours()
|
||||
if (hour < 12) return '早上好'
|
||||
if (hour < 18) return '下午好'
|
||||
return '晚上好'
|
||||
}
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
return formatRelativeTime(timestamp)
|
||||
}
|
||||
|
||||
const initEmotionChart = () => {
|
||||
if (!emotionChartRef.value) return
|
||||
|
||||
emotionChart = echarts.init(emotionChartRef.value)
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross'
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: 10
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '心情指数',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: [6, 7, 8, 6, 9, 7, 8],
|
||||
itemStyle: {
|
||||
color: '#3b82f6'
|
||||
},
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(59, 130, 246, 0.3)' },
|
||||
{ offset: 1, color: 'rgba(59, 130, 246, 0.1)' }
|
||||
])
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
emotionChart.setOption(option)
|
||||
}
|
||||
|
||||
const initEmotionPieChart = () => {
|
||||
if (!emotionPieChartRef.value) return
|
||||
|
||||
emotionPieChart = echarts.init(emotionPieChartRef.value)
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
left: 'left'
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '情绪分布',
|
||||
type: 'pie',
|
||||
radius: '50%',
|
||||
data: [
|
||||
{ value: 35, name: '开心', itemStyle: { color: EMOTION_COLORS.happy } },
|
||||
{ value: 25, name: '平静', itemStyle: { color: EMOTION_COLORS.calm } },
|
||||
{ value: 20, name: '兴奋', itemStyle: { color: EMOTION_COLORS.excited } },
|
||||
{ value: 15, name: '焦虑', itemStyle: { color: EMOTION_COLORS.anxious } },
|
||||
{ value: 5, name: '难过', itemStyle: { color: EMOTION_COLORS.sad } }
|
||||
],
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
emotionPieChart.setOption(option)
|
||||
}
|
||||
|
||||
const exportChart = () => {
|
||||
ElMessage.info('图表导出功能开发中...')
|
||||
}
|
||||
|
||||
const viewAllActivities = () => {
|
||||
ElMessage.info('查看全部活动功能开发中...')
|
||||
}
|
||||
|
||||
const viewAllAchievements = () => {
|
||||
ElMessage.info('查看全部成就功能开发中...')
|
||||
}
|
||||
|
||||
const viewAchievement = (achievement: any) => {
|
||||
ElMessage.success(`查看成就:${achievement.name}`)
|
||||
}
|
||||
|
||||
// 监听图表周期变化
|
||||
watch(emotionPeriod, () => {
|
||||
// 重新加载图表数据
|
||||
if (emotionChart) {
|
||||
// 这里可以根据周期加载不同的数据
|
||||
emotionChart.setOption({
|
||||
xAxis: {
|
||||
data: emotionPeriod.value === '7d'
|
||||
? ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||
: ['第1周', '第2周', '第3周', '第4周']
|
||||
},
|
||||
series: [{
|
||||
data: emotionPeriod.value === '7d'
|
||||
? [6, 7, 8, 6, 9, 7, 8]
|
||||
: [7.2, 6.8, 7.5, 8.1]
|
||||
}]
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
initEmotionChart()
|
||||
initEmotionPieChart()
|
||||
})
|
||||
})
|
||||
|
||||
// 响应式处理
|
||||
onUnmounted(() => {
|
||||
if (emotionChart) {
|
||||
emotionChart.dispose()
|
||||
}
|
||||
if (emotionPieChart) {
|
||||
emotionPieChart.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
// 窗口大小变化时重新调整图表
|
||||
window.addEventListener('resize', () => {
|
||||
if (emotionChart) {
|
||||
emotionChart.resize()
|
||||
}
|
||||
if (emotionPieChart) {
|
||||
emotionPieChart.resize()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.activity-item:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.achievement-item:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
</style>
|
||||
@@ -1,484 +0,0 @@
|
||||
<template>
|
||||
<div class="diary-page">
|
||||
<!-- 头部操作栏 -->
|
||||
<div class="diary-header bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-2">情绪日记</h1>
|
||||
<p class="text-gray-600">记录您的情绪变化,AI将为您提供专业的情感分析</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
<el-button @click="showDraftList">
|
||||
<el-icon class="mr-2"><Document /></el-icon>
|
||||
草稿箱 ({{ draftCount }})
|
||||
</el-button>
|
||||
<el-button type="primary" @click="createNewDiary">
|
||||
<el-icon class="mr-2"><EditPen /></el-icon>
|
||||
写日记
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 筛选和搜索 -->
|
||||
<div class="filter-section bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
<div class="flex flex-col lg:flex-row gap-4">
|
||||
<!-- 搜索框 -->
|
||||
<div class="flex-1">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索日记标题或内容..."
|
||||
clearable
|
||||
@input="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<!-- 筛选条件 -->
|
||||
<div class="flex gap-4">
|
||||
<!-- 情绪筛选 -->
|
||||
<el-select
|
||||
v-model="filterEmotion"
|
||||
placeholder="情绪类型"
|
||||
clearable
|
||||
@change="handleFilter"
|
||||
>
|
||||
<el-option
|
||||
v-for="emotion in emotionTypes"
|
||||
:key="emotion.value"
|
||||
:label="emotion.label"
|
||||
:value="emotion.value"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
:style="{ backgroundColor: emotion.color }"
|
||||
/>
|
||||
<span>{{ emotion.label }}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
|
||||
<!-- 状态筛选 -->
|
||||
<el-select
|
||||
v-model="filterStatus"
|
||||
placeholder="状态"
|
||||
clearable
|
||||
@change="handleFilter"
|
||||
>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="已发布" value="published" />
|
||||
<el-option label="草稿" value="draft" />
|
||||
<el-option label="已归档" value="archived" />
|
||||
</el-select>
|
||||
|
||||
<!-- 时间范围 -->
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
@change="handleFilter"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日记列表 -->
|
||||
<div class="diary-list">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<el-icon size="32" class="animate-spin text-blue-500">
|
||||
<Loading />
|
||||
</el-icon>
|
||||
<p class="text-gray-500 mt-2">加载中...</p>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="filteredDiaries.length === 0" class="empty-state bg-white rounded-lg shadow-sm p-12 text-center">
|
||||
<div class="w-20 h-20 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<el-icon size="32" class="text-gray-400">
|
||||
<EditPen />
|
||||
</el-icon>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">还没有日记</h3>
|
||||
<p class="text-gray-500 mb-6">开始记录您的第一篇情绪日记吧</p>
|
||||
<el-button type="primary" @click="createNewDiary">
|
||||
<el-icon class="mr-2"><EditPen /></el-icon>
|
||||
写日记
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 日记卡片列表 -->
|
||||
<div v-else class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
<div
|
||||
v-for="diary in filteredDiaries"
|
||||
:key="diary.id"
|
||||
class="diary-card bg-white rounded-lg shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
||||
@click="viewDiary(diary.id)"
|
||||
>
|
||||
<!-- 卡片头部 -->
|
||||
<div class="card-header p-4 border-b border-gray-100">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-gray-900 truncate mb-1">
|
||||
{{ diary.title }}
|
||||
</h3>
|
||||
<div class="flex items-center space-x-2 text-sm text-gray-500">
|
||||
<span>{{ formatDate(diary.createTime) }}</span>
|
||||
<span>·</span>
|
||||
<div class="flex items-center space-x-1">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
:style="{ backgroundColor: getEmotionColor(diary.emotion) }"
|
||||
/>
|
||||
<span>{{ getEmotionLabel(diary.emotion) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dropdown @command="handleDiaryAction">
|
||||
<el-button circle size="small" text @click.stop>
|
||||
<el-icon><MoreFilled /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item :command="`edit_${diary.id}`">
|
||||
编辑
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item :command="`share_${diary.id}`">
|
||||
分享
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
:command="`delete_${diary.id}`"
|
||||
divided
|
||||
>
|
||||
删除
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 卡片内容 -->
|
||||
<div class="card-content p-4">
|
||||
<p class="text-gray-600 text-sm line-clamp-3 mb-4">
|
||||
{{ diary.content }}
|
||||
</p>
|
||||
|
||||
<!-- 图片预览 -->
|
||||
<div v-if="diary.images.length > 0" class="images-preview mb-4">
|
||||
<div class="flex space-x-2">
|
||||
<img
|
||||
v-for="(image, index) in diary.images.slice(0, 3)"
|
||||
:key="index"
|
||||
:src="image"
|
||||
alt="日记图片"
|
||||
class="w-16 h-16 object-cover rounded"
|
||||
/>
|
||||
<div
|
||||
v-if="diary.images.length > 3"
|
||||
class="w-16 h-16 bg-gray-100 rounded flex items-center justify-center text-xs text-gray-500"
|
||||
>
|
||||
+{{ diary.images.length - 3 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签 -->
|
||||
<div v-if="diary.tags.length > 0" class="tags mb-4">
|
||||
<el-tag
|
||||
v-for="tag in diary.tags.slice(0, 3)"
|
||||
:key="tag"
|
||||
size="small"
|
||||
class="mr-2"
|
||||
>
|
||||
{{ tag }}
|
||||
</el-tag>
|
||||
<span v-if="diary.tags.length > 3" class="text-xs text-gray-500">
|
||||
+{{ diary.tags.length - 3 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 卡片底部 -->
|
||||
<div class="card-footer p-4 border-t border-gray-100">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4 text-sm text-gray-500">
|
||||
<div class="flex items-center space-x-1">
|
||||
<el-icon><View /></el-icon>
|
||||
<span>{{ diary.viewCount }}</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-1">
|
||||
<el-icon><Star /></el-icon>
|
||||
<span>{{ diary.likeCount }}</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-1">
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
<span>{{ diary.commentCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<el-tag
|
||||
:type="getStatusType(diary.status)"
|
||||
size="small"
|
||||
>
|
||||
{{ getStatusLabel(diary.status) }}
|
||||
</el-tag>
|
||||
|
||||
<div v-if="diary.aiComment" class="ai-comment-indicator">
|
||||
<el-tooltip content="AI已点评" placement="top">
|
||||
<el-icon class="text-blue-500">
|
||||
<ChatDotRound />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<div v-if="hasMore && filteredDiaries.length > 0" class="text-center py-6">
|
||||
<el-button @click="loadMore" :loading="loadingMore">
|
||||
加载更多
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Document,
|
||||
EditPen,
|
||||
Search,
|
||||
Loading,
|
||||
MoreFilled,
|
||||
View,
|
||||
Star,
|
||||
ChatDotRound
|
||||
} from '@element-plus/icons-vue'
|
||||
import { EMOTION_TYPES, EMOTION_COLORS } from '@/config/constants'
|
||||
import { formatDate } from '@/utils/format'
|
||||
import type { DiaryPost } from '@/types/api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const hasMore = ref(true)
|
||||
const searchKeyword = ref('')
|
||||
const filterEmotion = ref('')
|
||||
const filterStatus = ref('')
|
||||
const dateRange = ref<[Date, Date] | null>(null)
|
||||
const draftCount = ref(3)
|
||||
|
||||
// 情绪类型选项
|
||||
const emotionTypes = [
|
||||
{ label: '开心', value: EMOTION_TYPES.HAPPY, color: EMOTION_COLORS[EMOTION_TYPES.HAPPY] },
|
||||
{ label: '难过', value: EMOTION_TYPES.SAD, color: EMOTION_COLORS[EMOTION_TYPES.SAD] },
|
||||
{ label: '愤怒', value: EMOTION_TYPES.ANGRY, color: EMOTION_COLORS[EMOTION_TYPES.ANGRY] },
|
||||
{ label: '平静', value: EMOTION_TYPES.CALM, color: EMOTION_COLORS[EMOTION_TYPES.CALM] },
|
||||
{ label: '兴奋', value: EMOTION_TYPES.EXCITED, color: EMOTION_COLORS[EMOTION_TYPES.EXCITED] },
|
||||
{ label: '焦虑', value: EMOTION_TYPES.ANXIOUS, color: EMOTION_COLORS[EMOTION_TYPES.ANXIOUS] }
|
||||
]
|
||||
|
||||
// 模拟日记数据
|
||||
const diaries = ref<DiaryPost[]>([
|
||||
{
|
||||
id: '1',
|
||||
userId: 'user1',
|
||||
title: '今天的心情很不错',
|
||||
content: '今天天气很好,和朋友一起去公园散步,心情特别愉快。看到花开得很美,感觉生活充满了希望...',
|
||||
emotion: EMOTION_TYPES.HAPPY,
|
||||
mood: 8,
|
||||
weather: '晴天',
|
||||
location: '北京',
|
||||
tags: ['散步', '朋友', '公园'],
|
||||
images: ['/api/placeholder/200/200'],
|
||||
isPublic: true,
|
||||
status: 'published',
|
||||
createTime: Date.now() - 1000 * 60 * 60 * 2,
|
||||
updateTime: Date.now() - 1000 * 60 * 60 * 2,
|
||||
viewCount: 15,
|
||||
likeCount: 3,
|
||||
commentCount: 2,
|
||||
aiComment: {
|
||||
content: '从您的描述中可以感受到您今天的愉悦心情...',
|
||||
emotion: EMOTION_TYPES.HAPPY,
|
||||
suggestions: ['继续保持积极心态', '多与朋友交流'],
|
||||
generateTime: Date.now() - 1000 * 60 * 60
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
// 计算属性
|
||||
const filteredDiaries = computed(() => {
|
||||
let result = diaries.value
|
||||
|
||||
// 搜索过滤
|
||||
if (searchKeyword.value) {
|
||||
const keyword = searchKeyword.value.toLowerCase()
|
||||
result = result.filter(diary =>
|
||||
diary.title.toLowerCase().includes(keyword) ||
|
||||
diary.content.toLowerCase().includes(keyword)
|
||||
)
|
||||
}
|
||||
|
||||
// 情绪过滤
|
||||
if (filterEmotion.value) {
|
||||
result = result.filter(diary => diary.emotion === filterEmotion.value)
|
||||
}
|
||||
|
||||
// 状态过滤
|
||||
if (filterStatus.value) {
|
||||
result = result.filter(diary => diary.status === filterStatus.value)
|
||||
}
|
||||
|
||||
// 时间范围过滤
|
||||
if (dateRange.value) {
|
||||
const [start, end] = dateRange.value
|
||||
result = result.filter(diary => {
|
||||
const diaryDate = new Date(diary.createTime)
|
||||
return diaryDate >= start && diaryDate <= end
|
||||
})
|
||||
}
|
||||
|
||||
return result.sort((a, b) => b.createTime - a.createTime)
|
||||
})
|
||||
|
||||
// 方法
|
||||
const handleSearch = () => {
|
||||
// 搜索逻辑已在计算属性中实现
|
||||
}
|
||||
|
||||
const handleFilter = () => {
|
||||
// 过滤逻辑已在计算属性中实现
|
||||
}
|
||||
|
||||
const createNewDiary = () => {
|
||||
router.push('/app/diary/create')
|
||||
}
|
||||
|
||||
const viewDiary = (diaryId: string) => {
|
||||
router.push(`/app/diary/${diaryId}`)
|
||||
}
|
||||
|
||||
const showDraftList = () => {
|
||||
ElMessage.info('草稿箱功能开发中...')
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
loadingMore.value = true
|
||||
|
||||
setTimeout(() => {
|
||||
loadingMore.value = false
|
||||
hasMore.value = false
|
||||
ElMessage.success('已加载全部日记')
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const handleDiaryAction = (command: string) => {
|
||||
const [action, diaryId] = command.split('_')
|
||||
|
||||
switch (action) {
|
||||
case 'edit':
|
||||
router.push(`/app/diary/edit/${diaryId}`)
|
||||
break
|
||||
case 'share':
|
||||
ElMessage.info('分享功能开发中...')
|
||||
break
|
||||
case 'delete':
|
||||
ElMessageBox.confirm(
|
||||
'确定要删除这篇日记吗?',
|
||||
'提示',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
).then(() => {
|
||||
const index = diaries.value.findIndex(d => d.id === diaryId)
|
||||
if (index > -1) {
|
||||
diaries.value.splice(index, 1)
|
||||
ElMessage.success('日记已删除')
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const getEmotionColor = (emotion: string) => {
|
||||
return EMOTION_COLORS[emotion as keyof typeof EMOTION_COLORS] || '#6b7280'
|
||||
}
|
||||
|
||||
const getEmotionLabel = (emotion: string) => {
|
||||
const emotionType = emotionTypes.find(e => e.value === emotion)
|
||||
return emotionType?.label || '未知'
|
||||
}
|
||||
|
||||
const getStatusType = (status: string) => {
|
||||
switch (status) {
|
||||
case 'published':
|
||||
return 'success'
|
||||
case 'draft':
|
||||
return 'warning'
|
||||
case 'archived':
|
||||
return 'info'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'published':
|
||||
return '已发布'
|
||||
case 'draft':
|
||||
return '草稿'
|
||||
case 'archived':
|
||||
return '已归档'
|
||||
default:
|
||||
return '未知'
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
loading.value = true
|
||||
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
}, 1000)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.diary-card {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.diary-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,543 +0,0 @@
|
||||
<template>
|
||||
<div class="diary-detail-page">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-state">
|
||||
<el-skeleton :rows="8" animated />
|
||||
</div>
|
||||
|
||||
<!-- 日记内容 -->
|
||||
<div v-else-if="diary" class="diary-content">
|
||||
<!-- 头部操作栏 -->
|
||||
<div class="detail-header bg-white rounded-lg shadow-sm p-4 mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<el-button @click="goBack">
|
||||
<el-icon class="mr-2"><ArrowLeft /></el-icon>
|
||||
返回
|
||||
</el-button>
|
||||
|
||||
<div class="diary-meta">
|
||||
<h1 class="text-xl font-bold text-gray-900">{{ diary.title }}</h1>
|
||||
<div class="flex items-center space-x-4 text-sm text-gray-500 mt-1">
|
||||
<span>{{ formatDate(diary.createTime) }}</span>
|
||||
<span>·</span>
|
||||
<div class="flex items-center space-x-1">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
:style="{ backgroundColor: getEmotionColor(diary.emotion) }"
|
||||
/>
|
||||
<span>{{ getEmotionLabel(diary.emotion) }}</span>
|
||||
</div>
|
||||
<span>·</span>
|
||||
<span>心情指数 {{ diary.mood }}/10</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<el-button v-if="canEdit" @click="editDiary">
|
||||
<el-icon class="mr-2"><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
|
||||
<el-button @click="shareDiary">
|
||||
<el-icon class="mr-2"><Share /></el-icon>
|
||||
分享
|
||||
</el-button>
|
||||
|
||||
<el-dropdown @command="handleAction">
|
||||
<el-button circle>
|
||||
<el-icon><MoreFilled /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="export">导出</el-dropdown-item>
|
||||
<el-dropdown-item command="print">打印</el-dropdown-item>
|
||||
<el-dropdown-item v-if="canEdit" command="delete" divided>
|
||||
删除
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<div class="main-content grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- 日记正文 -->
|
||||
<div class="diary-body lg:col-span-2">
|
||||
<div class="content-card bg-white rounded-lg shadow-sm p-6">
|
||||
<!-- 日记内容 -->
|
||||
<div class="diary-text prose max-w-none" v-html="diary.content"></div>
|
||||
|
||||
<!-- 图片展示 -->
|
||||
<div v-if="diary.images.length > 0" class="diary-images mt-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">图片</h3>
|
||||
<div class="image-grid grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<div
|
||||
v-for="(image, index) in diary.images"
|
||||
:key="index"
|
||||
class="image-item cursor-pointer"
|
||||
@click="previewImage(image, index)"
|
||||
>
|
||||
<img
|
||||
:src="image"
|
||||
:alt="`图片 ${index + 1}`"
|
||||
class="w-full h-32 object-cover rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签 -->
|
||||
<div v-if="diary.tags.length > 0" class="diary-tags mt-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">标签</h3>
|
||||
<div class="tag-list">
|
||||
<el-tag
|
||||
v-for="tag in diary.tags"
|
||||
:key="tag"
|
||||
class="mr-2 mb-2"
|
||||
>
|
||||
{{ tag }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 侧边栏信息 -->
|
||||
<div class="diary-sidebar">
|
||||
<!-- 基本信息 -->
|
||||
<div class="info-card bg-white rounded-lg shadow-sm p-4 mb-4">
|
||||
<h3 class="card-title">基本信息</h3>
|
||||
|
||||
<div class="info-list space-y-3">
|
||||
<div class="info-item">
|
||||
<label>创建时间</label>
|
||||
<span>{{ formatDateTime(diary.createTime) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="diary.updateTime !== diary.createTime" class="info-item">
|
||||
<label>更新时间</label>
|
||||
<span>{{ formatDateTime(diary.updateTime) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<label>情绪类型</label>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
:style="{ backgroundColor: getEmotionColor(diary.emotion) }"
|
||||
/>
|
||||
<span>{{ getEmotionLabel(diary.emotion) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<label>心情指数</label>
|
||||
<div class="flex items-center space-x-2">
|
||||
<el-rate
|
||||
:model-value="diary.mood / 2"
|
||||
disabled
|
||||
show-score
|
||||
text-color="#ff9900"
|
||||
score-template="{value}/5"
|
||||
/>
|
||||
<span class="text-sm text-gray-500">({{ diary.mood }}/10)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="diary.weather" class="info-item">
|
||||
<label>天气</label>
|
||||
<span>{{ getWeatherLabel(diary.weather) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="diary.location" class="info-item">
|
||||
<label>位置</label>
|
||||
<span>{{ diary.location }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<label>可见性</label>
|
||||
<el-tag :type="diary.isPublic ? 'success' : 'info'" size="small">
|
||||
{{ diary.isPublic ? '公开' : '私密' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<div class="stats-card bg-white rounded-lg shadow-sm p-4 mb-4">
|
||||
<h3 class="card-title">统计信息</h3>
|
||||
|
||||
<div class="stats-list space-y-3">
|
||||
<div class="stat-item">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="stat-label">
|
||||
<el-icon class="mr-1"><View /></el-icon>
|
||||
浏览量
|
||||
</span>
|
||||
<span class="stat-value">{{ diary.viewCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-item">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="stat-label">
|
||||
<el-icon class="mr-1"><Star /></el-icon>
|
||||
点赞数
|
||||
</span>
|
||||
<span class="stat-value">{{ diary.likeCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-item">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="stat-label">
|
||||
<el-icon class="mr-1"><ChatDotRound /></el-icon>
|
||||
评论数
|
||||
</span>
|
||||
<span class="stat-value">{{ diary.commentCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-item">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="stat-label">
|
||||
<el-icon class="mr-1"><Document /></el-icon>
|
||||
字数
|
||||
</span>
|
||||
<span class="stat-value">{{ getWordCount(diary.content) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI点评 -->
|
||||
<div v-if="diary.aiComment" class="ai-comment-card bg-white rounded-lg shadow-sm p-4">
|
||||
<h3 class="card-title">AI点评</h3>
|
||||
|
||||
<div class="ai-comment-content">
|
||||
<div class="comment-text text-sm text-gray-700 mb-3">
|
||||
{{ diary.aiComment.content }}
|
||||
</div>
|
||||
|
||||
<div v-if="diary.aiComment.suggestions.length > 0" class="suggestions">
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-2">建议</h4>
|
||||
<ul class="suggestion-list text-sm text-gray-600 space-y-1">
|
||||
<li
|
||||
v-for="suggestion in diary.aiComment.suggestions"
|
||||
:key="suggestion"
|
||||
class="flex items-start space-x-2"
|
||||
>
|
||||
<span class="text-blue-500">•</span>
|
||||
<span>{{ suggestion }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="comment-time text-xs text-gray-500 mt-3">
|
||||
{{ formatTime(diary.aiComment.generateTime) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-else class="error-state text-center py-20">
|
||||
<div class="w-20 h-20 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<el-icon size="32" class="text-gray-400">
|
||||
<WarningFilled />
|
||||
</el-icon>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">日记不存在</h3>
|
||||
<p class="text-gray-500 mb-6">该日记可能已被删除或您没有访问权限</p>
|
||||
<el-button type="primary" @click="goBack">返回列表</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 图片预览 -->
|
||||
<el-dialog
|
||||
v-model="previewVisible"
|
||||
title="图片预览"
|
||||
width="80%"
|
||||
:close-on-click-modal="true"
|
||||
append-to-body
|
||||
>
|
||||
<div class="preview-container text-center">
|
||||
<img
|
||||
v-if="previewUrl"
|
||||
:src="previewUrl"
|
||||
:alt="previewAlt"
|
||||
class="max-w-full max-h-96 mx-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="preview-footer flex justify-center space-x-4">
|
||||
<el-button @click="previewVisible = false">关闭</el-button>
|
||||
<el-button type="primary" @click="downloadImage">下载</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Edit,
|
||||
Share,
|
||||
MoreFilled,
|
||||
View,
|
||||
Star,
|
||||
ChatDotRound,
|
||||
Document,
|
||||
WarningFilled
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useDiary } from '@/composables/useDiary'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { EMOTION_COLORS } from '@/config/constants'
|
||||
import { formatDate, formatDateTime, formatRelativeTime } from '@/utils/format'
|
||||
import type { DiaryPost } from '@/types/api'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 组合式API
|
||||
const { loading, currentDiary, fetchDiaryDetail, deleteDiary } = useDiary()
|
||||
|
||||
// 响应式数据
|
||||
const previewVisible = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const previewAlt = ref('')
|
||||
|
||||
// 计算属性
|
||||
const diary = computed(() => currentDiary.value)
|
||||
const canEdit = computed(() => {
|
||||
return diary.value && diary.value.userId === authStore.userId
|
||||
})
|
||||
|
||||
// 情绪选项映射
|
||||
const emotionLabels: Record<string, string> = {
|
||||
happy: '开心',
|
||||
sad: '难过',
|
||||
angry: '愤怒',
|
||||
calm: '平静',
|
||||
excited: '兴奋',
|
||||
anxious: '焦虑'
|
||||
}
|
||||
|
||||
// 天气选项映射
|
||||
const weatherLabels: Record<string, string> = {
|
||||
sunny: '☀️ 晴天',
|
||||
cloudy: '☁️ 多云',
|
||||
overcast: '🌫️ 阴天',
|
||||
rainy: '🌧️ 雨天',
|
||||
snowy: '❄️ 雪天',
|
||||
foggy: '🌫️ 雾天'
|
||||
}
|
||||
|
||||
// 方法
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
const editDiary = () => {
|
||||
if (diary.value) {
|
||||
router.push(`/app/diary/edit/${diary.value.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
const shareDiary = () => {
|
||||
if (diary.value) {
|
||||
const url = `${window.location.origin}/app/diary/${diary.value.id}`
|
||||
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: diary.value.title,
|
||||
text: diary.value.content.substring(0, 100) + '...',
|
||||
url
|
||||
}).catch(error => {
|
||||
console.error('分享失败:', error)
|
||||
copyToClipboard(url)
|
||||
})
|
||||
} else {
|
||||
copyToClipboard(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
ElMessage.success('链接已复制到剪贴板')
|
||||
}).catch(() => {
|
||||
ElMessage.error('复制失败,请手动复制')
|
||||
})
|
||||
}
|
||||
|
||||
const handleAction = (command: string) => {
|
||||
switch (command) {
|
||||
case 'export':
|
||||
exportDiary()
|
||||
break
|
||||
case 'print':
|
||||
printDiary()
|
||||
break
|
||||
case 'delete':
|
||||
handleDelete()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const exportDiary = () => {
|
||||
ElMessage.info('导出功能开发中...')
|
||||
}
|
||||
|
||||
const printDiary = () => {
|
||||
window.print()
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!diary.value) return
|
||||
|
||||
ElMessageBox.confirm(
|
||||
'确定要删除这篇日记吗?删除后无法恢复。',
|
||||
'删除确认',
|
||||
{
|
||||
confirmButtonText: '确定删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
).then(async () => {
|
||||
try {
|
||||
await deleteDiary(diary.value!.id)
|
||||
ElMessage.success('日记已删除')
|
||||
router.push('/app/diary')
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const previewImage = (imageUrl: string, index: number) => {
|
||||
previewUrl.value = imageUrl
|
||||
previewAlt.value = `图片 ${index + 1}`
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
const downloadImage = () => {
|
||||
if (previewUrl.value) {
|
||||
const link = document.createElement('a')
|
||||
link.href = previewUrl.value
|
||||
link.download = previewAlt.value
|
||||
link.click()
|
||||
}
|
||||
}
|
||||
|
||||
const getEmotionColor = (emotion: string) => {
|
||||
return EMOTION_COLORS[emotion as keyof typeof EMOTION_COLORS] || '#6b7280'
|
||||
}
|
||||
|
||||
const getEmotionLabel = (emotion: string) => {
|
||||
return emotionLabels[emotion] || '未知'
|
||||
}
|
||||
|
||||
const getWeatherLabel = (weather: string) => {
|
||||
return weatherLabels[weather] || weather
|
||||
}
|
||||
|
||||
const getWordCount = (content: string) => {
|
||||
// 移除HTML标签并计算字数
|
||||
const text = content.replace(/<[^>]*>/g, '')
|
||||
return text.length
|
||||
}
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
return formatRelativeTime(timestamp)
|
||||
}
|
||||
|
||||
const loadDiary = async () => {
|
||||
const diaryId = route.params.id as string
|
||||
if (diaryId) {
|
||||
try {
|
||||
await fetchDiaryDetail(diaryId)
|
||||
} catch (error) {
|
||||
console.error('加载日记失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
loadDiary()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-title {
|
||||
@apply text-sm font-semibold text-gray-900 mb-3;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
@apply flex items-center justify-between;
|
||||
}
|
||||
|
||||
.info-item label {
|
||||
@apply text-sm text-gray-600;
|
||||
}
|
||||
|
||||
.info-item span {
|
||||
@apply text-sm text-gray-900;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
@apply py-2 border-b border-gray-100 last:border-b-0;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
@apply flex items-center text-sm text-gray-600;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
@apply text-sm font-medium text-gray-900;
|
||||
}
|
||||
|
||||
.diary-text {
|
||||
@apply text-gray-800 leading-relaxed;
|
||||
}
|
||||
|
||||
.image-item {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.image-item:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.suggestion-list li {
|
||||
@apply leading-relaxed;
|
||||
}
|
||||
|
||||
/* 打印样式 */
|
||||
@media print {
|
||||
.detail-header,
|
||||
.diary-sidebar {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
@apply grid-cols-1;
|
||||
}
|
||||
|
||||
.diary-body {
|
||||
@apply col-span-1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,578 +0,0 @@
|
||||
<template>
|
||||
<div class="diary-editor-page">
|
||||
<!-- 头部操作栏 -->
|
||||
<div class="editor-header bg-white rounded-lg shadow-sm p-4 mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<el-button @click="goBack">
|
||||
<el-icon class="mr-2"><ArrowLeft /></el-icon>
|
||||
返回
|
||||
</el-button>
|
||||
|
||||
<div class="editor-status">
|
||||
<span v-if="isDraft" class="status-badge draft">草稿</span>
|
||||
<span v-else-if="isPublished" class="status-badge published">已发布</span>
|
||||
<span v-if="autoSaving" class="auto-save-indicator">
|
||||
<el-icon class="animate-spin"><Loading /></el-icon>
|
||||
自动保存中...
|
||||
</span>
|
||||
<span v-else-if="lastSaveTime" class="last-save-time">
|
||||
{{ formatTime(lastSaveTime) }} 已保存
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
<el-button @click="saveDraft" :loading="saving">
|
||||
<el-icon class="mr-2"><Document /></el-icon>
|
||||
保存草稿
|
||||
</el-button>
|
||||
|
||||
<el-button type="primary" @click="showPublishDialog = true" :loading="publishing">
|
||||
<el-icon class="mr-2"><Upload /></el-icon>
|
||||
{{ isEdit ? '更新' : '发布' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑器内容 -->
|
||||
<div class="editor-content grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- 主编辑区 -->
|
||||
<div class="main-editor lg:col-span-2">
|
||||
<div class="editor-card bg-white rounded-lg shadow-sm p-6">
|
||||
<!-- 标题输入 -->
|
||||
<div class="title-section mb-6">
|
||||
<el-input
|
||||
v-model="diaryForm.title"
|
||||
placeholder="请输入日记标题..."
|
||||
size="large"
|
||||
class="title-input"
|
||||
maxlength="100"
|
||||
show-word-limit
|
||||
@input="handleAutoSave"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 富文本编辑器 -->
|
||||
<div class="content-section">
|
||||
<RichTextEditor
|
||||
v-model="diaryForm.content"
|
||||
placeholder="记录下此刻的心情和想法..."
|
||||
height="500px"
|
||||
:max-length="5000"
|
||||
@change="handleAutoSave"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 侧边栏设置 -->
|
||||
<div class="sidebar-settings">
|
||||
<!-- 情绪设置 -->
|
||||
<div class="setting-card bg-white rounded-lg shadow-sm p-4 mb-4">
|
||||
<h3 class="setting-title">情绪设置</h3>
|
||||
|
||||
<div class="emotion-selector mb-4">
|
||||
<label class="setting-label">当前情绪</label>
|
||||
<el-select
|
||||
v-model="diaryForm.emotion"
|
||||
placeholder="选择情绪"
|
||||
class="w-full"
|
||||
@change="handleAutoSave"
|
||||
>
|
||||
<el-option
|
||||
v-for="emotion in emotionOptions"
|
||||
:key="emotion.value"
|
||||
:label="emotion.label"
|
||||
:value="emotion.value"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
:style="{ backgroundColor: emotion.color }"
|
||||
/>
|
||||
<span>{{ emotion.label }}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<div class="mood-slider mb-4">
|
||||
<label class="setting-label">心情指数: {{ diaryForm.mood }}/10</label>
|
||||
<el-slider
|
||||
v-model="diaryForm.mood"
|
||||
:min="1"
|
||||
:max="10"
|
||||
:step="1"
|
||||
show-stops
|
||||
@change="handleAutoSave"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 环境信息 -->
|
||||
<div class="setting-card bg-white rounded-lg shadow-sm p-4 mb-4">
|
||||
<h3 class="setting-title">环境信息</h3>
|
||||
|
||||
<div class="weather-selector mb-4">
|
||||
<label class="setting-label">天气</label>
|
||||
<el-select
|
||||
v-model="diaryForm.weather"
|
||||
placeholder="选择天气"
|
||||
class="w-full"
|
||||
@change="handleAutoSave"
|
||||
>
|
||||
<el-option
|
||||
v-for="weather in weatherOptions"
|
||||
:key="weather.value"
|
||||
:label="weather.label"
|
||||
:value="weather.value"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span>{{ weather.icon }}</span>
|
||||
<span>{{ weather.label }}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<div class="location-input">
|
||||
<label class="setting-label">位置</label>
|
||||
<el-input
|
||||
v-model="diaryForm.location"
|
||||
placeholder="记录当前位置"
|
||||
@input="handleAutoSave"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图片上传 -->
|
||||
<div class="setting-card bg-white rounded-lg shadow-sm p-4 mb-4">
|
||||
<h3 class="setting-title">图片</h3>
|
||||
|
||||
<ImageUpload
|
||||
v-model="diaryForm.images"
|
||||
:limit="9"
|
||||
:multiple="true"
|
||||
@change="handleAutoSave"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 标签设置 -->
|
||||
<div class="setting-card bg-white rounded-lg shadow-sm p-4 mb-4">
|
||||
<h3 class="setting-title">标签</h3>
|
||||
|
||||
<div class="tag-input mb-3">
|
||||
<el-input
|
||||
v-model="newTag"
|
||||
placeholder="添加标签"
|
||||
@keyup.enter="addTag"
|
||||
>
|
||||
<template #append>
|
||||
<el-button @click="addTag">添加</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<div class="tag-list">
|
||||
<el-tag
|
||||
v-for="tag in diaryForm.tags"
|
||||
:key="tag"
|
||||
closable
|
||||
class="mr-2 mb-2"
|
||||
@close="removeTag(tag)"
|
||||
>
|
||||
{{ tag }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="suggested-tags">
|
||||
<p class="text-xs text-gray-500 mb-2">建议标签:</p>
|
||||
<el-tag
|
||||
v-for="tag in suggestedTags"
|
||||
:key="tag"
|
||||
size="small"
|
||||
class="mr-1 mb-1 cursor-pointer"
|
||||
@click="addSuggestedTag(tag)"
|
||||
>
|
||||
{{ tag }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 发布设置 -->
|
||||
<div class="setting-card bg-white rounded-lg shadow-sm p-4">
|
||||
<h3 class="setting-title">发布设置</h3>
|
||||
|
||||
<div class="publish-settings space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-700">公开可见</span>
|
||||
<el-switch
|
||||
v-model="diaryForm.isPublic"
|
||||
@change="handleAutoSave"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-700">允许评论</span>
|
||||
<el-switch
|
||||
v-model="diaryForm.allowComment"
|
||||
@change="handleAutoSave"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 发布确认对话框 -->
|
||||
<el-dialog
|
||||
v-model="showPublishDialog"
|
||||
:title="isEdit ? '更新日记' : '发布日记'"
|
||||
width="400px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div class="publish-preview">
|
||||
<div class="preview-item">
|
||||
<label>标题:</label>
|
||||
<p>{{ diaryForm.title || '无标题' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="preview-item">
|
||||
<label>情绪:</label>
|
||||
<p>{{ getEmotionLabel(diaryForm.emotion) }} ({{ diaryForm.mood }}/10)</p>
|
||||
</div>
|
||||
|
||||
<div class="preview-item">
|
||||
<label>可见性:</label>
|
||||
<p>{{ diaryForm.isPublic ? '公开' : '私密' }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="diaryForm.tags.length > 0" class="preview-item">
|
||||
<label>标签:</label>
|
||||
<p>{{ diaryForm.tags.join(', ') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="showPublishDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="handlePublish" :loading="publishing">
|
||||
{{ isEdit ? '更新' : '发布' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Loading,
|
||||
Document,
|
||||
Upload
|
||||
} from '@element-plus/icons-vue'
|
||||
import RichTextEditor from '@/components/editor/RichTextEditor.vue'
|
||||
import ImageUpload from '@/components/upload/ImageUpload.vue'
|
||||
import { useDiary } from '@/composables/useDiary'
|
||||
import { EMOTION_TYPES, EMOTION_COLORS } from '@/config/constants'
|
||||
import { formatRelativeTime } from '@/utils/format'
|
||||
import type { PublishDiaryRequest } from '@/types/api'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
// 组合式API
|
||||
const {
|
||||
publishing,
|
||||
publishDiary,
|
||||
updateDiary,
|
||||
saveDraft,
|
||||
fetchDiaryDetail,
|
||||
autoSaveDraft
|
||||
} = useDiary()
|
||||
|
||||
// 响应式数据
|
||||
const saving = ref(false)
|
||||
const autoSaving = ref(false)
|
||||
const lastSaveTime = ref(0)
|
||||
const showPublishDialog = ref(false)
|
||||
const newTag = ref('')
|
||||
|
||||
// 表单数据
|
||||
const diaryForm = reactive<PublishDiaryRequest>({
|
||||
title: '',
|
||||
content: '',
|
||||
emotion: '',
|
||||
mood: 5,
|
||||
weather: '',
|
||||
location: '',
|
||||
tags: [],
|
||||
images: [],
|
||||
isPublic: true,
|
||||
allowComment: true
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
const isDraft = computed(() => route.query.draft === 'true')
|
||||
const isPublished = computed(() => !isDraft.value && isEdit.value)
|
||||
|
||||
// 情绪选项
|
||||
const emotionOptions = [
|
||||
{ label: '开心', value: EMOTION_TYPES.HAPPY, color: EMOTION_COLORS[EMOTION_TYPES.HAPPY] },
|
||||
{ label: '难过', value: EMOTION_TYPES.SAD, color: EMOTION_COLORS[EMOTION_TYPES.SAD] },
|
||||
{ label: '愤怒', value: EMOTION_TYPES.ANGRY, color: EMOTION_COLORS[EMOTION_TYPES.ANGRY] },
|
||||
{ label: '平静', value: EMOTION_TYPES.CALM, color: EMOTION_COLORS[EMOTION_TYPES.CALM] },
|
||||
{ label: '兴奋', value: EMOTION_TYPES.EXCITED, color: EMOTION_COLORS[EMOTION_TYPES.EXCITED] },
|
||||
{ label: '焦虑', value: EMOTION_TYPES.ANXIOUS, color: EMOTION_COLORS[EMOTION_TYPES.ANXIOUS] }
|
||||
]
|
||||
|
||||
// 天气选项
|
||||
const weatherOptions = [
|
||||
{ label: '晴天', value: 'sunny', icon: '☀️' },
|
||||
{ label: '多云', value: 'cloudy', icon: '☁️' },
|
||||
{ label: '阴天', value: 'overcast', icon: '🌫️' },
|
||||
{ label: '雨天', value: 'rainy', icon: '🌧️' },
|
||||
{ label: '雪天', value: 'snowy', icon: '❄️' },
|
||||
{ label: '雾天', value: 'foggy', icon: '🌫️' }
|
||||
]
|
||||
|
||||
// 建议标签
|
||||
const suggestedTags = ['日常', '工作', '学习', '旅行', '美食', '运动', '读书', '电影', '音乐', '朋友']
|
||||
|
||||
// 方法
|
||||
const goBack = () => {
|
||||
if (hasUnsavedChanges()) {
|
||||
ElMessageBox.confirm(
|
||||
'有未保存的更改,确定要离开吗?',
|
||||
'提示',
|
||||
{
|
||||
confirmButtonText: '离开',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
).then(() => {
|
||||
router.back()
|
||||
})
|
||||
} else {
|
||||
router.back()
|
||||
}
|
||||
}
|
||||
|
||||
const hasUnsavedChanges = (): boolean => {
|
||||
// 检查是否有未保存的更改
|
||||
return diaryForm.title.trim() !== '' || diaryForm.content.trim() !== ''
|
||||
}
|
||||
|
||||
const handleAutoSave = () => {
|
||||
if (!hasUnsavedChanges()) return
|
||||
|
||||
autoSaving.value = true
|
||||
|
||||
// 防抖处理
|
||||
clearTimeout(autoSaveTimer)
|
||||
autoSaveTimer = setTimeout(async () => {
|
||||
try {
|
||||
await autoSaveDraft(diaryForm)
|
||||
lastSaveTime.value = Date.now()
|
||||
} catch (error) {
|
||||
console.error('自动保存失败:', error)
|
||||
} finally {
|
||||
autoSaving.value = false
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
let autoSaveTimer: NodeJS.Timeout
|
||||
|
||||
const handleSaveDraft = async () => {
|
||||
if (!hasUnsavedChanges()) {
|
||||
ElMessage.warning('没有内容需要保存')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
saving.value = true
|
||||
await saveDraft(diaryForm)
|
||||
lastSaveTime.value = Date.now()
|
||||
ElMessage.success('草稿保存成功')
|
||||
} catch (error) {
|
||||
console.error('保存草稿失败:', error)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!diaryForm.title.trim()) {
|
||||
ElMessage.warning('请输入日记标题')
|
||||
return
|
||||
}
|
||||
|
||||
if (!diaryForm.content.trim()) {
|
||||
ElMessage.warning('请输入日记内容')
|
||||
}
|
||||
|
||||
if (!diaryForm.emotion) {
|
||||
ElMessage.warning('请选择情绪类型')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await updateDiary(route.params.id as string, diaryForm)
|
||||
ElMessage.success('日记更新成功')
|
||||
} else {
|
||||
await publishDiary(diaryForm)
|
||||
ElMessage.success('日记发布成功')
|
||||
}
|
||||
|
||||
showPublishDialog.value = false
|
||||
router.push('/app/diary')
|
||||
} catch (error) {
|
||||
console.error('发布失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const addTag = () => {
|
||||
const tag = newTag.value.trim()
|
||||
if (!tag) return
|
||||
|
||||
if (diaryForm.tags.includes(tag)) {
|
||||
ElMessage.warning('标签已存在')
|
||||
return
|
||||
}
|
||||
|
||||
if (diaryForm.tags.length >= 10) {
|
||||
ElMessage.warning('最多只能添加10个标签')
|
||||
return
|
||||
}
|
||||
|
||||
diaryForm.tags.push(tag)
|
||||
newTag.value = ''
|
||||
handleAutoSave()
|
||||
}
|
||||
|
||||
const removeTag = (tag: string) => {
|
||||
const index = diaryForm.tags.indexOf(tag)
|
||||
if (index > -1) {
|
||||
diaryForm.tags.splice(index, 1)
|
||||
handleAutoSave()
|
||||
}
|
||||
}
|
||||
|
||||
const addSuggestedTag = (tag: string) => {
|
||||
if (!diaryForm.tags.includes(tag)) {
|
||||
diaryForm.tags.push(tag)
|
||||
handleAutoSave()
|
||||
}
|
||||
}
|
||||
|
||||
const getEmotionLabel = (emotion: string) => {
|
||||
const option = emotionOptions.find(e => e.value === emotion)
|
||||
return option?.label || '未知'
|
||||
}
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
return formatRelativeTime(timestamp)
|
||||
}
|
||||
|
||||
const loadDiary = async () => {
|
||||
if (isEdit.value) {
|
||||
try {
|
||||
const diary = await fetchDiaryDetail(route.params.id as string)
|
||||
|
||||
// 填充表单
|
||||
Object.assign(diaryForm, {
|
||||
title: diary.title,
|
||||
content: diary.content,
|
||||
emotion: diary.emotion,
|
||||
mood: diary.mood,
|
||||
weather: diary.weather,
|
||||
location: diary.location,
|
||||
tags: [...diary.tags],
|
||||
images: [...diary.images],
|
||||
isPublic: diary.isPublic,
|
||||
allowComment: diary.allowComment
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('加载日记失败:', error)
|
||||
ElMessage.error('加载日记失败')
|
||||
router.back()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
loadDiary()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (autoSaveTimer) {
|
||||
clearTimeout(autoSaveTimer)
|
||||
}
|
||||
})
|
||||
|
||||
// 页面离开前确认
|
||||
window.addEventListener('beforeunload', (event) => {
|
||||
if (hasUnsavedChanges()) {
|
||||
event.preventDefault()
|
||||
event.returnValue = '有未保存的更改,确定要离开吗?'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.title-input :deep(.el-input__inner) {
|
||||
@apply text-xl font-semibold border-none shadow-none;
|
||||
}
|
||||
|
||||
.title-input :deep(.el-input__inner):focus {
|
||||
@apply border-none shadow-none;
|
||||
}
|
||||
|
||||
.setting-title {
|
||||
@apply text-sm font-semibold text-gray-900 mb-3;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
@apply block text-sm font-medium text-gray-700 mb-2;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
@apply px-2 py-1 text-xs rounded-full font-medium;
|
||||
}
|
||||
|
||||
.status-badge.draft {
|
||||
@apply bg-yellow-100 text-yellow-800;
|
||||
}
|
||||
|
||||
.status-badge.published {
|
||||
@apply bg-green-100 text-green-800;
|
||||
}
|
||||
|
||||
.auto-save-indicator {
|
||||
@apply flex items-center space-x-1 text-xs text-blue-600;
|
||||
}
|
||||
|
||||
.last-save-time {
|
||||
@apply text-xs text-gray-500;
|
||||
}
|
||||
|
||||
.preview-item {
|
||||
@apply mb-3;
|
||||
}
|
||||
|
||||
.preview-item label {
|
||||
@apply block text-sm font-medium text-gray-700 mb-1;
|
||||
}
|
||||
|
||||
.preview-item p {
|
||||
@apply text-sm text-gray-900;
|
||||
}
|
||||
</style>
|
||||
@@ -1,85 +0,0 @@
|
||||
<template>
|
||||
<div class="not-found-page min-h-screen bg-gray-50 flex items-center justify-center px-4">
|
||||
<div class="text-center">
|
||||
<!-- 404图标 -->
|
||||
<div class="mb-8">
|
||||
<div class="text-9xl font-bold text-gray-300 mb-4">404</div>
|
||||
<div class="w-32 h-32 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center mx-auto">
|
||||
<el-icon size="64" class="text-white">
|
||||
<QuestionFilled />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误信息 -->
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-4">页面不存在</h1>
|
||||
<p class="text-lg text-gray-600 mb-8 max-w-md mx-auto">
|
||||
抱歉,您访问的页面不存在或已被移除。请检查URL是否正确,或返回首页继续浏览。
|
||||
</p>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<el-button type="primary" size="large" @click="goHome">
|
||||
<el-icon class="mr-2"><House /></el-icon>
|
||||
返回首页
|
||||
</el-button>
|
||||
<el-button size="large" @click="goBack">
|
||||
<el-icon class="mr-2"><ArrowLeft /></el-icon>
|
||||
返回上页
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 建议链接 -->
|
||||
<div class="mt-12">
|
||||
<p class="text-sm text-gray-500 mb-4">您可能想要访问:</p>
|
||||
<div class="flex flex-wrap justify-center gap-4">
|
||||
<router-link
|
||||
v-for="link in suggestedLinks"
|
||||
:key="link.path"
|
||||
:to="link.path"
|
||||
class="text-blue-600 hover:text-blue-500 text-sm"
|
||||
>
|
||||
{{ link.title }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { QuestionFilled, House, ArrowLeft } from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 建议的链接
|
||||
const suggestedLinks = [
|
||||
{ title: '首页', path: '/home' },
|
||||
{ title: 'AI对话', path: '/chat' },
|
||||
{ title: '情绪日记', path: '/app/diary' },
|
||||
{ title: '个人仪表盘', path: '/app/dashboard' },
|
||||
{ title: '情绪分析', path: '/app/analysis' }
|
||||
]
|
||||
|
||||
// 方法
|
||||
const goHome = () => {
|
||||
router.push('/home')
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
if (window.history.length > 1) {
|
||||
router.go(-1)
|
||||
} else {
|
||||
router.push('/home')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.not-found-page {
|
||||
background-image:
|
||||
radial-gradient(circle at 20% 80%, rgba(120, 119, 198, 0.1) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, rgba(255, 119, 198, 0.1) 0%, transparent 50%);
|
||||
}
|
||||
</style>
|
||||
@@ -1,518 +0,0 @@
|
||||
<template>
|
||||
<div class="profile-page">
|
||||
<!-- 头部信息 -->
|
||||
<div class="profile-header bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
<div class="flex items-center space-x-6">
|
||||
<!-- 头像 -->
|
||||
<div class="relative">
|
||||
<el-avatar :src="userAvatar" :size="80" class="border-4 border-white shadow-lg">
|
||||
<el-icon size="32"><User /></el-icon>
|
||||
</el-avatar>
|
||||
|
||||
<el-upload
|
||||
:show-file-list="false"
|
||||
:before-upload="handleAvatarUpload"
|
||||
accept="image/jpeg,image/png"
|
||||
class="absolute -bottom-2 -right-2"
|
||||
>
|
||||
<el-button circle size="small" type="primary">
|
||||
<el-icon><Camera /></el-icon>
|
||||
</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
|
||||
<!-- 基本信息 -->
|
||||
<div class="flex-1">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-2">{{ userNickname }}</h1>
|
||||
<p class="text-gray-600 mb-3">{{ userProfile?.bio || '这个人很懒,什么都没有留下...' }}</p>
|
||||
|
||||
<div class="flex items-center space-x-6 text-sm text-gray-500">
|
||||
<div class="flex items-center space-x-1">
|
||||
<el-icon><Calendar /></el-icon>
|
||||
<span>加入于 {{ formatDate(userProfile?.createTime) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-1">
|
||||
<el-icon><Location /></el-icon>
|
||||
<span>{{ userProfile?.location || '未设置' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-1">
|
||||
<el-icon><Message /></el-icon>
|
||||
<span>{{ userProfile?.email || '未绑定' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex flex-col space-y-2">
|
||||
<el-button type="primary" @click="showEditDialog = true">
|
||||
<el-icon class="mr-2"><Edit /></el-icon>
|
||||
编辑资料
|
||||
</el-button>
|
||||
<el-button @click="showPasswordDialog = true">
|
||||
<el-icon class="mr-2"><Lock /></el-icon>
|
||||
修改密码
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<div class="stats-section grid grid-cols-1 md:grid-cols-4 gap-6 mb-6">
|
||||
<div
|
||||
v-for="stat in userStats"
|
||||
:key="stat.title"
|
||||
class="stat-card bg-white rounded-lg shadow-sm p-6 text-center"
|
||||
>
|
||||
<div
|
||||
class="w-12 h-12 rounded-lg mx-auto mb-3 flex items-center justify-center"
|
||||
:style="{ backgroundColor: stat.color + '20' }"
|
||||
>
|
||||
<el-icon :size="24" :style="{ color: stat.color }">
|
||||
<component :is="stat.icon" />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-gray-900 mb-1">{{ stat.value }}</div>
|
||||
<div class="text-sm text-gray-600">{{ stat.title }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详细信息 -->
|
||||
<div class="profile-details grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- 个人信息 -->
|
||||
<div class="info-card bg-white rounded-lg shadow-sm p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">个人信息</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="info-item">
|
||||
<label class="text-sm font-medium text-gray-700">用户名</label>
|
||||
<p class="text-gray-900">{{ userProfile?.username }}</p>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<label class="text-sm font-medium text-gray-700">昵称</label>
|
||||
<p class="text-gray-900">{{ userProfile?.nickname }}</p>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<label class="text-sm font-medium text-gray-700">性别</label>
|
||||
<p class="text-gray-900">{{ getGenderText(userProfile?.gender) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<label class="text-sm font-medium text-gray-700">生日</label>
|
||||
<p class="text-gray-900">{{ userProfile?.birthday || '未设置' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<label class="text-sm font-medium text-gray-700">所在地</label>
|
||||
<p class="text-gray-900">{{ userProfile?.location || '未设置' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 联系方式 -->
|
||||
<div class="contact-card bg-white rounded-lg shadow-sm p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">联系方式</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="contact-item">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700">邮箱地址</label>
|
||||
<p class="text-gray-900">{{ userProfile?.email || '未绑定' }}</p>
|
||||
</div>
|
||||
<el-tag
|
||||
:type="userProfile?.email ? 'success' : 'warning'"
|
||||
size="small"
|
||||
>
|
||||
{{ userProfile?.email ? '已验证' : '未绑定' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="!userProfile?.email"
|
||||
size="small"
|
||||
type="primary"
|
||||
text
|
||||
@click="showEmailDialog = true"
|
||||
>
|
||||
绑定邮箱
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="contact-item">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700">手机号码</label>
|
||||
<p class="text-gray-900">{{ maskPhone(userProfile?.phone) || '未绑定' }}</p>
|
||||
</div>
|
||||
<el-tag
|
||||
:type="userProfile?.phone ? 'success' : 'warning'"
|
||||
size="small"
|
||||
>
|
||||
{{ userProfile?.phone ? '已验证' : '未绑定' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="!userProfile?.phone"
|
||||
size="small"
|
||||
type="primary"
|
||||
text
|
||||
@click="showPhoneDialog = true"
|
||||
>
|
||||
绑定手机
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑资料对话框 -->
|
||||
<el-dialog
|
||||
v-model="showEditDialog"
|
||||
title="编辑资料"
|
||||
width="500px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form
|
||||
ref="editFormRef"
|
||||
:model="editForm"
|
||||
:rules="editRules"
|
||||
label-width="80px"
|
||||
>
|
||||
<el-form-item label="昵称" prop="nickname">
|
||||
<el-input v-model="editForm.nickname" placeholder="请输入昵称" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="性别" prop="gender">
|
||||
<el-radio-group v-model="editForm.gender">
|
||||
<el-radio label="male">男</el-radio>
|
||||
<el-radio label="female">女</el-radio>
|
||||
<el-radio label="unknown">保密</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="生日" prop="birthday">
|
||||
<el-date-picker
|
||||
v-model="editForm.birthday"
|
||||
type="date"
|
||||
placeholder="选择生日"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="所在地" prop="location">
|
||||
<el-input v-model="editForm.location" placeholder="请输入所在地" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="个人简介" prop="bio">
|
||||
<el-input
|
||||
v-model="editForm.bio"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="介绍一下自己吧..."
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="showEditDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleUpdateProfile" :loading="updating">
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 修改密码对话框 -->
|
||||
<el-dialog
|
||||
v-model="showPasswordDialog"
|
||||
title="修改密码"
|
||||
width="400px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form
|
||||
ref="passwordFormRef"
|
||||
:model="passwordForm"
|
||||
:rules="passwordRules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="当前密码" prop="oldPassword">
|
||||
<el-input
|
||||
v-model="passwordForm.oldPassword"
|
||||
type="password"
|
||||
placeholder="请输入当前密码"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="新密码" prop="newPassword">
|
||||
<el-input
|
||||
v-model="passwordForm.newPassword"
|
||||
type="password"
|
||||
placeholder="请输入新密码"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="确认密码" prop="confirmPassword">
|
||||
<el-input
|
||||
v-model="passwordForm.confirmPassword"
|
||||
type="password"
|
||||
placeholder="请再次输入新密码"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="showPasswordDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleChangePassword" :loading="changingPassword">
|
||||
确认修改
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
User,
|
||||
Camera,
|
||||
Calendar,
|
||||
Location,
|
||||
Message,
|
||||
Edit,
|
||||
Lock,
|
||||
ChatDotRound,
|
||||
EditPen,
|
||||
TrendCharts,
|
||||
Trophy
|
||||
} from '@element-plus/icons-vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { useUser } from '@/composables/useUser'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { formatDate, maskPhone } from '@/utils/format'
|
||||
import { validatePassword } from '@/utils/validation'
|
||||
import type { UpdateUserProfileRequest, ChangePasswordRequest } from '@/types/api'
|
||||
|
||||
// 状态管理
|
||||
const authStore = useAuthStore()
|
||||
const {
|
||||
loading,
|
||||
uploading,
|
||||
userProfile,
|
||||
fetchUserProfile,
|
||||
updateUserProfile,
|
||||
changePassword,
|
||||
uploadAvatar,
|
||||
validateAvatarFile
|
||||
} = useUser()
|
||||
|
||||
// 响应式数据
|
||||
const showEditDialog = ref(false)
|
||||
const showPasswordDialog = ref(false)
|
||||
const showEmailDialog = ref(false)
|
||||
const showPhoneDialog = ref(false)
|
||||
const updating = ref(false)
|
||||
const changingPassword = ref(false)
|
||||
const editFormRef = ref<FormInstance>()
|
||||
const passwordFormRef = ref<FormInstance>()
|
||||
|
||||
// 计算属性
|
||||
const userAvatar = computed(() => authStore.avatar)
|
||||
const userNickname = computed(() => authStore.nickname)
|
||||
|
||||
// 编辑表单
|
||||
const editForm = reactive<UpdateUserProfileRequest>({
|
||||
nickname: '',
|
||||
gender: 'unknown',
|
||||
birthday: '',
|
||||
location: '',
|
||||
bio: ''
|
||||
})
|
||||
|
||||
// 密码表单
|
||||
const passwordForm = reactive<ChangePasswordRequest>({
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
// 用户统计
|
||||
const userStats = [
|
||||
{
|
||||
title: '总对话数',
|
||||
value: '156',
|
||||
icon: ChatDotRound,
|
||||
color: '#3b82f6'
|
||||
},
|
||||
{
|
||||
title: '日记篇数',
|
||||
value: '23',
|
||||
icon: EditPen,
|
||||
color: '#10b981'
|
||||
},
|
||||
{
|
||||
title: '平均心情',
|
||||
value: '7.2',
|
||||
icon: TrendCharts,
|
||||
color: '#f59e0b'
|
||||
},
|
||||
{
|
||||
title: '获得成就',
|
||||
value: '12',
|
||||
icon: Trophy,
|
||||
color: '#8b5cf6'
|
||||
}
|
||||
]
|
||||
|
||||
// 表单验证规则
|
||||
const editRules: FormRules = {
|
||||
nickname: [
|
||||
{ required: true, message: '请输入昵称', trigger: 'blur' },
|
||||
{ min: 2, max: 20, message: '昵称长度在 2 到 20 个字符', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
const passwordRules: FormRules = {
|
||||
oldPassword: [
|
||||
{ required: true, message: '请输入当前密码', trigger: 'blur' }
|
||||
],
|
||||
newPassword: [
|
||||
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||||
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' },
|
||||
{
|
||||
validator: (rule: any, value: string, callback: any) => {
|
||||
if (!validatePassword(value)) {
|
||||
callback(new Error('密码必须包含字母和数字'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: '请确认新密码', trigger: 'blur' },
|
||||
{
|
||||
validator: (rule: any, value: string, callback: any) => {
|
||||
if (value !== passwordForm.newPassword) {
|
||||
callback(new Error('两次输入的密码不一致'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 方法
|
||||
const getGenderText = (gender?: string) => {
|
||||
switch (gender) {
|
||||
case 'male':
|
||||
return '男'
|
||||
case 'female':
|
||||
return '女'
|
||||
default:
|
||||
return '保密'
|
||||
}
|
||||
}
|
||||
|
||||
const handleAvatarUpload = async (file: File) => {
|
||||
if (!validateAvatarFile(file)) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
await uploadAvatar(file)
|
||||
} catch (error) {
|
||||
console.error('头像上传失败:', error)
|
||||
}
|
||||
|
||||
return false // 阻止默认上传行为
|
||||
}
|
||||
|
||||
const handleUpdateProfile = async () => {
|
||||
if (!editFormRef.value) return
|
||||
|
||||
try {
|
||||
await editFormRef.value.validate()
|
||||
|
||||
updating.value = true
|
||||
await updateUserProfile(editForm)
|
||||
|
||||
showEditDialog.value = false
|
||||
await loadUserProfile()
|
||||
} catch (error) {
|
||||
console.error('更新资料失败:', error)
|
||||
} finally {
|
||||
updating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
if (!passwordFormRef.value) return
|
||||
|
||||
try {
|
||||
await passwordFormRef.value.validate()
|
||||
|
||||
changingPassword.value = true
|
||||
await changePassword(passwordForm)
|
||||
|
||||
showPasswordDialog.value = false
|
||||
|
||||
// 重置表单
|
||||
passwordForm.oldPassword = ''
|
||||
passwordForm.newPassword = ''
|
||||
passwordForm.confirmPassword = ''
|
||||
} catch (error) {
|
||||
console.error('修改密码失败:', error)
|
||||
} finally {
|
||||
changingPassword.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadUserProfile = async () => {
|
||||
try {
|
||||
await fetchUserProfile()
|
||||
|
||||
// 填充编辑表单
|
||||
if (userProfile.value) {
|
||||
editForm.nickname = userProfile.value.nickname || ''
|
||||
editForm.gender = userProfile.value.gender || 'unknown'
|
||||
editForm.birthday = userProfile.value.birthday || ''
|
||||
editForm.location = userProfile.value.location || ''
|
||||
editForm.bio = userProfile.value.bio || ''
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载用户资料失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
loadUserProfile()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.info-item {
|
||||
@apply pb-3 border-b border-gray-100 last:border-b-0 last:pb-0;
|
||||
}
|
||||
|
||||
.contact-item {
|
||||
@apply pb-4 border-b border-gray-100 last:border-b-0 last:pb-0;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,430 +0,0 @@
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<!-- 页面头部 -->
|
||||
<div class="settings-header bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-2">设置</h1>
|
||||
<p class="text-gray-600">管理您的账户设置和偏好</p>
|
||||
</div>
|
||||
|
||||
<!-- 设置内容 -->
|
||||
<div class="settings-content grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
<!-- 侧边栏导航 -->
|
||||
<div class="settings-nav bg-white rounded-lg shadow-sm p-4">
|
||||
<nav class="space-y-2">
|
||||
<a
|
||||
v-for="item in navItems"
|
||||
:key="item.key"
|
||||
href="#"
|
||||
class="nav-item"
|
||||
:class="{ 'active': activeTab === item.key }"
|
||||
@click.prevent="activeTab = item.key"
|
||||
>
|
||||
<el-icon class="mr-3">
|
||||
<component :is="item.icon" />
|
||||
</el-icon>
|
||||
{{ item.title }}
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- 设置面板 -->
|
||||
<div class="settings-panel lg:col-span-3 bg-white rounded-lg shadow-sm p-6">
|
||||
<!-- 通用设置 -->
|
||||
<div v-if="activeTab === 'general'" class="setting-section">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">通用设置</h3>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- 主题设置 -->
|
||||
<div class="setting-item">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-900">主题模式</h4>
|
||||
<p class="text-sm text-gray-500">选择您喜欢的界面主题</p>
|
||||
</div>
|
||||
<el-select v-model="settings.theme" @change="handleThemeChange">
|
||||
<el-option label="浅色主题" value="light" />
|
||||
<el-option label="深色主题" value="dark" />
|
||||
<el-option label="跟随系统" value="auto" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 语言设置 -->
|
||||
<div class="setting-item">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-900">语言</h4>
|
||||
<p class="text-sm text-gray-500">选择界面显示语言</p>
|
||||
</div>
|
||||
<el-select v-model="settings.language" @change="handleLanguageChange">
|
||||
<el-option label="简体中文" value="zh-CN" />
|
||||
<el-option label="English" value="en-US" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 侧边栏设置 -->
|
||||
<div class="setting-item">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-900">侧边栏</h4>
|
||||
<p class="text-sm text-gray-500">默认折叠侧边栏</p>
|
||||
</div>
|
||||
<el-switch
|
||||
v-model="settings.sidebarCollapsed"
|
||||
@change="handleSidebarChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 页面设置 -->
|
||||
<div class="setting-item">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-3">页面显示</h4>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-700">显示面包屑导航</span>
|
||||
<el-switch
|
||||
v-model="settings.showBreadcrumb"
|
||||
@change="handlePageSettingChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-700">固定顶部导航</span>
|
||||
<el-switch
|
||||
v-model="settings.fixedHeader"
|
||||
@change="handlePageSettingChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-700">显示页脚</span>
|
||||
<el-switch
|
||||
v-model="settings.showFooter"
|
||||
@change="handlePageSettingChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 通知设置 -->
|
||||
<div v-if="activeTab === 'notifications'" class="setting-section">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">通知设置</h3>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- 桌面通知 -->
|
||||
<div class="setting-item">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-900">桌面通知</h4>
|
||||
<p class="text-sm text-gray-500">在桌面显示通知消息</p>
|
||||
</div>
|
||||
<el-switch
|
||||
v-model="settings.notifications.desktop"
|
||||
@change="handleNotificationChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 声音提醒 -->
|
||||
<div class="setting-item">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-900">声音提醒</h4>
|
||||
<p class="text-sm text-gray-500">收到消息时播放提示音</p>
|
||||
</div>
|
||||
<el-switch
|
||||
v-model="settings.notifications.sound"
|
||||
@change="handleNotificationChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 震动提醒 -->
|
||||
<div class="setting-item">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-900">震动提醒</h4>
|
||||
<p class="text-sm text-gray-500">在移动设备上震动提醒</p>
|
||||
</div>
|
||||
<el-switch
|
||||
v-model="settings.notifications.vibration"
|
||||
@change="handleNotificationChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 隐私设置 -->
|
||||
<div v-if="activeTab === 'privacy'" class="setting-section">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">隐私设置</h3>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- 数据导出 -->
|
||||
<div class="setting-item">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-900">数据导出</h4>
|
||||
<p class="text-sm text-gray-500">导出您的所有数据</p>
|
||||
</div>
|
||||
<el-button @click="exportData">导出数据</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 清除缓存 -->
|
||||
<div class="setting-item">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-900">清除缓存</h4>
|
||||
<p class="text-sm text-gray-500">清除本地缓存数据</p>
|
||||
</div>
|
||||
<el-button @click="clearCache">清除缓存</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 注销账户 -->
|
||||
<div class="setting-item border-t border-red-100 pt-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-red-600">注销账户</h4>
|
||||
<p class="text-sm text-red-400">永久删除您的账户和所有数据</p>
|
||||
</div>
|
||||
<el-button type="danger" @click="showDeleteDialog = true">
|
||||
注销账户
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 关于 -->
|
||||
<div v-if="activeTab === 'about'" class="setting-section">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">关于</h3>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- 应用信息 -->
|
||||
<div class="app-info text-center py-8">
|
||||
<div class="w-20 h-20 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<el-icon size="40" class="text-white">
|
||||
<Sunny />
|
||||
</el-icon>
|
||||
</div>
|
||||
<h4 class="text-xl font-bold text-gray-900 mb-2">情绪博物馆</h4>
|
||||
<p class="text-gray-600 mb-4">版本 {{ appVersion }}</p>
|
||||
<p class="text-sm text-gray-500 max-w-md mx-auto">
|
||||
记录情绪,分享心情的温暖空间。与AI对话,写下情绪日记,分析情感轨迹,让每一份情感都被珍藏。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 链接 -->
|
||||
<div class="links grid grid-cols-2 gap-4">
|
||||
<el-button @click="openLink('https://github.com/emotion-museum')">
|
||||
<el-icon class="mr-2"><Link /></el-icon>
|
||||
GitHub
|
||||
</el-button>
|
||||
<el-button @click="openLink('mailto:contact@emotion-museum.com')">
|
||||
<el-icon class="mr-2"><Message /></el-icon>
|
||||
联系我们
|
||||
</el-button>
|
||||
<el-button @click="openLink('/privacy')">
|
||||
<el-icon class="mr-2"><Document /></el-icon>
|
||||
隐私政策
|
||||
</el-button>
|
||||
<el-button @click="openLink('/terms')">
|
||||
<el-icon class="mr-2"><Document /></el-icon>
|
||||
服务条款
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 注销账户确认对话框 -->
|
||||
<el-dialog
|
||||
v-model="showDeleteDialog"
|
||||
title="注销账户"
|
||||
width="400px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div class="text-center py-4">
|
||||
<el-icon size="48" class="text-red-500 mb-4">
|
||||
<WarningFilled />
|
||||
</el-icon>
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">确定要注销账户吗?</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
此操作将永久删除您的账户和所有数据,包括聊天记录、日记等,且无法恢复。
|
||||
</p>
|
||||
<el-input
|
||||
v-model="deleteConfirmText"
|
||||
placeholder="请输入 '确认注销' 来确认操作"
|
||||
class="mb-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="showDeleteDialog = false">取消</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
:disabled="deleteConfirmText !== '确认注销'"
|
||||
@click="handleDeleteAccount"
|
||||
>
|
||||
确认注销
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Setting,
|
||||
Bell,
|
||||
Lock,
|
||||
InfoFilled,
|
||||
Sunny,
|
||||
Link,
|
||||
Message,
|
||||
Document,
|
||||
WarningFilled
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import storage from '@/utils/storage'
|
||||
|
||||
// 状态管理
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 响应式数据
|
||||
const activeTab = ref('general')
|
||||
const showDeleteDialog = ref(false)
|
||||
const deleteConfirmText = ref('')
|
||||
|
||||
// 导航项
|
||||
const navItems = [
|
||||
{ key: 'general', title: '通用', icon: Setting },
|
||||
{ key: 'notifications', title: '通知', icon: Bell },
|
||||
{ key: 'privacy', title: '隐私', icon: Lock },
|
||||
{ key: 'about', title: '关于', icon: InfoFilled }
|
||||
]
|
||||
|
||||
// 设置数据
|
||||
const settings = reactive({
|
||||
theme: appStore.theme,
|
||||
language: appStore.language,
|
||||
sidebarCollapsed: appStore.sidebarCollapsed,
|
||||
showBreadcrumb: appStore.pageSettings.showBreadcrumb,
|
||||
fixedHeader: appStore.pageSettings.fixedHeader,
|
||||
showFooter: appStore.pageSettings.showFooter,
|
||||
notifications: {
|
||||
desktop: appStore.notifications.desktop,
|
||||
sound: appStore.notifications.sound,
|
||||
vibration: appStore.notifications.vibration
|
||||
}
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const appVersion = computed(() => appStore.version)
|
||||
|
||||
// 方法
|
||||
const handleThemeChange = (theme: string) => {
|
||||
appStore.setTheme(theme)
|
||||
}
|
||||
|
||||
const handleLanguageChange = (language: string) => {
|
||||
appStore.setLanguage(language)
|
||||
}
|
||||
|
||||
const handleSidebarChange = (collapsed: boolean) => {
|
||||
appStore.setSidebarCollapsed(collapsed)
|
||||
}
|
||||
|
||||
const handlePageSettingChange = () => {
|
||||
appStore.updatePageSettings({
|
||||
showBreadcrumb: settings.showBreadcrumb,
|
||||
fixedHeader: settings.fixedHeader,
|
||||
showFooter: settings.showFooter
|
||||
})
|
||||
}
|
||||
|
||||
const handleNotificationChange = () => {
|
||||
appStore.updateNotificationSettings(settings.notifications)
|
||||
}
|
||||
|
||||
const exportData = () => {
|
||||
ElMessage.info('数据导出功能开发中...')
|
||||
}
|
||||
|
||||
const clearCache = () => {
|
||||
ElMessageBox.confirm(
|
||||
'确定要清除所有缓存数据吗?',
|
||||
'提示',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
).then(() => {
|
||||
// 清除本地存储
|
||||
storage.clear()
|
||||
ElMessage.success('缓存已清除')
|
||||
|
||||
// 刷新页面
|
||||
setTimeout(() => {
|
||||
window.location.reload()
|
||||
}, 1000)
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeleteAccount = () => {
|
||||
ElMessage.error('账户注销功能暂未开放,如需注销请联系客服')
|
||||
showDeleteDialog.value = false
|
||||
deleteConfirmText.value = ''
|
||||
}
|
||||
|
||||
const openLink = (url: string) => {
|
||||
if (url.startsWith('http') || url.startsWith('mailto:')) {
|
||||
window.open(url, '_blank')
|
||||
} else {
|
||||
ElMessage.info('页面开发中...')
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
// 同步当前设置状态
|
||||
settings.theme = appStore.theme
|
||||
settings.language = appStore.language
|
||||
settings.sidebarCollapsed = appStore.sidebarCollapsed
|
||||
settings.showBreadcrumb = appStore.pageSettings.showBreadcrumb
|
||||
settings.fixedHeader = appStore.pageSettings.fixedHeader
|
||||
settings.showFooter = appStore.pageSettings.showFooter
|
||||
settings.notifications.desktop = appStore.notifications.desktop
|
||||
settings.notifications.sound = appStore.notifications.sound
|
||||
settings.notifications.vibration = appStore.notifications.vibration
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nav-item {
|
||||
@apply flex items-center px-3 py-2 text-sm font-medium text-gray-700 rounded-lg hover:bg-gray-100 transition-colors;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
@apply bg-blue-50 text-blue-700;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
@apply pb-6 border-b border-gray-100 last:border-b-0 last:pb-0;
|
||||
}
|
||||
|
||||
.app-info {
|
||||
@apply border border-gray-100 rounded-lg;
|
||||
}
|
||||
</style>
|
||||
@@ -1,98 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
'./index.html',
|
||||
'./src/**/*.{vue,js,ts,jsx,tsx}'
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// 主色调
|
||||
primary: {
|
||||
50: '#f0f9ff',
|
||||
100: '#e0f2fe',
|
||||
200: '#bae6fd',
|
||||
300: '#7dd3fc',
|
||||
400: '#38bdf8',
|
||||
500: '#0ea5e9',
|
||||
600: '#0284c7',
|
||||
700: '#0369a1',
|
||||
800: '#075985',
|
||||
900: '#0c4a6e'
|
||||
},
|
||||
|
||||
// 情绪色彩
|
||||
emotion: {
|
||||
happy: '#fbbf24',
|
||||
sad: '#3b82f6',
|
||||
angry: '#ef4444',
|
||||
calm: '#10b981',
|
||||
excited: '#f97316',
|
||||
anxious: '#8b5cf6'
|
||||
},
|
||||
|
||||
// 设计系统颜色
|
||||
'tech-blue': '#4A90E2',
|
||||
'warm-orange': '#F5A623',
|
||||
'light-gray': '#F7F8FA',
|
||||
'text-dark': '#333333',
|
||||
'text-medium': '#888888'
|
||||
},
|
||||
|
||||
fontFamily: {
|
||||
sans: ['Noto Sans SC', 'Inter', 'system-ui', 'sans-serif']
|
||||
},
|
||||
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.5s ease-in-out',
|
||||
'slide-up': 'slideUp 0.3s ease-out',
|
||||
'bounce-gentle': 'bounceGentle 2s infinite',
|
||||
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite'
|
||||
},
|
||||
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' }
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { transform: 'translateY(20px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' }
|
||||
},
|
||||
bounceGentle: {
|
||||
'0%, 100%': { transform: 'translateY(0)' },
|
||||
'50%': { transform: 'translateY(-5px)' }
|
||||
}
|
||||
},
|
||||
|
||||
spacing: {
|
||||
'18': '4.5rem',
|
||||
'88': '22rem',
|
||||
'128': '32rem'
|
||||
},
|
||||
|
||||
borderRadius: {
|
||||
'4xl': '2rem'
|
||||
},
|
||||
|
||||
boxShadow: {
|
||||
'soft': '0 2px 15px 0 rgba(0, 0, 0, 0.1)',
|
||||
'medium': '0 4px 25px 0 rgba(0, 0, 0, 0.15)',
|
||||
'strong': '0 8px 35px 0 rgba(0, 0, 0, 0.2)'
|
||||
},
|
||||
|
||||
backdropBlur: {
|
||||
xs: '2px'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/forms'),
|
||||
require('@tailwindcss/typography')
|
||||
],
|
||||
|
||||
// 与Element Plus兼容
|
||||
corePlugins: {
|
||||
preflight: false
|
||||
}
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
/**
|
||||
* 认证功能 E2E 测试
|
||||
*/
|
||||
|
||||
describe('Authentication', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('/')
|
||||
})
|
||||
|
||||
describe('Login', () => {
|
||||
it('should redirect to login page when not authenticated', () => {
|
||||
cy.url().should('include', '/auth/login')
|
||||
cy.shouldBeVisible('[data-cy=login-form]')
|
||||
})
|
||||
|
||||
it('should login with valid credentials', () => {
|
||||
cy.visit('/auth/login')
|
||||
|
||||
// 填写登录表单
|
||||
cy.get('[data-cy=username-input]').type(Cypress.env('testUser').username)
|
||||
cy.get('[data-cy=password-input]').type(Cypress.env('testUser').password)
|
||||
|
||||
// 点击登录按钮
|
||||
cy.get('[data-cy=login-button]').click()
|
||||
|
||||
// 等待登录完成
|
||||
cy.wait('@login')
|
||||
|
||||
// 验证登录成功
|
||||
cy.url().should('not.include', '/auth/login')
|
||||
cy.shouldBeVisible('[data-cy=user-menu]')
|
||||
cy.shouldHaveLocalStorage('auth_token')
|
||||
})
|
||||
|
||||
it('should show error with invalid credentials', () => {
|
||||
cy.visit('/auth/login')
|
||||
|
||||
// 模拟登录失败
|
||||
cy.intercept('POST', '/api/auth/login', {
|
||||
statusCode: 401,
|
||||
body: { message: '用户名或密码错误' }
|
||||
}).as('loginFailed')
|
||||
|
||||
// 填写错误凭据
|
||||
cy.get('[data-cy=username-input]').type('wronguser')
|
||||
cy.get('[data-cy=password-input]').type('wrongpass')
|
||||
cy.get('[data-cy=login-button]').click()
|
||||
|
||||
// 验证错误消息
|
||||
cy.wait('@loginFailed')
|
||||
cy.shouldShowError('用户名或密码错误')
|
||||
cy.url().should('include', '/auth/login')
|
||||
})
|
||||
|
||||
it('should validate required fields', () => {
|
||||
cy.visit('/auth/login')
|
||||
|
||||
// 尝试提交空表单
|
||||
cy.get('[data-cy=login-button]').click()
|
||||
|
||||
// 验证验证消息
|
||||
cy.get('[data-cy=username-input]').should('have.class', 'error')
|
||||
cy.get('[data-cy=password-input]').should('have.class', 'error')
|
||||
})
|
||||
|
||||
it('should toggle password visibility', () => {
|
||||
cy.visit('/auth/login')
|
||||
|
||||
cy.get('[data-cy=password-input]').should('have.attr', 'type', 'password')
|
||||
cy.get('[data-cy=password-toggle]').click()
|
||||
cy.get('[data-cy=password-input]').should('have.attr', 'type', 'text')
|
||||
})
|
||||
|
||||
it('should remember login state', () => {
|
||||
// 登录
|
||||
cy.login()
|
||||
|
||||
// 刷新页面
|
||||
cy.reload()
|
||||
|
||||
// 验证仍然登录
|
||||
cy.shouldBeVisible('[data-cy=user-menu]')
|
||||
cy.url().should('not.include', '/auth/login')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Register', () => {
|
||||
it('should register new user successfully', () => {
|
||||
cy.visit('/auth/register')
|
||||
|
||||
// 模拟注册成功
|
||||
cy.intercept('POST', '/api/auth/register', {
|
||||
statusCode: 201,
|
||||
body: {
|
||||
token: 'new-token',
|
||||
user: {
|
||||
id: '1',
|
||||
username: 'newuser',
|
||||
email: 'new@example.com'
|
||||
}
|
||||
}
|
||||
}).as('register')
|
||||
|
||||
// 填写注册表单
|
||||
cy.get('[data-cy=username-input]').type('newuser')
|
||||
cy.get('[data-cy=email-input]').type('new@example.com')
|
||||
cy.get('[data-cy=password-input]').type('password123')
|
||||
cy.get('[data-cy=confirm-password-input]').type('password123')
|
||||
cy.get('[data-cy=agree-terms]').check()
|
||||
|
||||
// 提交注册
|
||||
cy.get('[data-cy=register-button]').click()
|
||||
|
||||
// 验证注册成功
|
||||
cy.wait('@register')
|
||||
cy.url().should('not.include', '/auth/register')
|
||||
cy.shouldShowSuccess('注册成功')
|
||||
})
|
||||
|
||||
it('should validate email format', () => {
|
||||
cy.visit('/auth/register')
|
||||
|
||||
cy.get('[data-cy=email-input]').type('invalid-email')
|
||||
cy.get('[data-cy=username-input]').click() // 触发验证
|
||||
|
||||
cy.get('[data-cy=email-input]').should('have.class', 'error')
|
||||
cy.shouldContainText('[data-cy=email-error]', '邮箱格式不正确')
|
||||
})
|
||||
|
||||
it('should validate password strength', () => {
|
||||
cy.visit('/auth/register')
|
||||
|
||||
// 测试弱密码
|
||||
cy.get('[data-cy=password-input]').type('123')
|
||||
cy.get('[data-cy=username-input]').click()
|
||||
|
||||
cy.shouldContainText('[data-cy=password-strength]', '弱')
|
||||
|
||||
// 测试强密码
|
||||
cy.get('[data-cy=password-input]').clear().type('StrongPass123!')
|
||||
cy.shouldContainText('[data-cy=password-strength]', '强')
|
||||
})
|
||||
|
||||
it('should validate password confirmation', () => {
|
||||
cy.visit('/auth/register')
|
||||
|
||||
cy.get('[data-cy=password-input]').type('password123')
|
||||
cy.get('[data-cy=confirm-password-input]').type('different')
|
||||
cy.get('[data-cy=username-input]').click()
|
||||
|
||||
cy.shouldContainText('[data-cy=confirm-password-error]', '两次输入的密码不一致')
|
||||
})
|
||||
|
||||
it('should require terms agreement', () => {
|
||||
cy.visit('/auth/register')
|
||||
|
||||
// 填写所有字段但不同意条款
|
||||
cy.get('[data-cy=username-input]').type('newuser')
|
||||
cy.get('[data-cy=email-input]').type('new@example.com')
|
||||
cy.get('[data-cy=password-input]').type('password123')
|
||||
cy.get('[data-cy=confirm-password-input]').type('password123')
|
||||
|
||||
// 尝试提交
|
||||
cy.get('[data-cy=register-button]').should('be.disabled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Logout', () => {
|
||||
it('should logout successfully', () => {
|
||||
// 先登录
|
||||
cy.login()
|
||||
|
||||
// 登出
|
||||
cy.logout()
|
||||
|
||||
// 验证登出成功
|
||||
cy.url().should('include', '/auth/login')
|
||||
cy.shouldNotHaveLocalStorage('auth_token')
|
||||
})
|
||||
|
||||
it('should clear user data on logout', () => {
|
||||
cy.login()
|
||||
|
||||
// 设置一些用户数据
|
||||
cy.setLocalStorage('user_preferences', '{"theme":"dark"}')
|
||||
|
||||
cy.logout()
|
||||
|
||||
// 验证数据被清除
|
||||
cy.shouldNotHaveLocalStorage('auth_token')
|
||||
cy.shouldNotHaveLocalStorage('user_info')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Password Reset', () => {
|
||||
it('should send reset email', () => {
|
||||
cy.visit('/auth/forgot-password')
|
||||
|
||||
cy.intercept('POST', '/api/auth/forgot-password', {
|
||||
statusCode: 200,
|
||||
body: { message: '重置邮件已发送' }
|
||||
}).as('forgotPassword')
|
||||
|
||||
cy.get('[data-cy=email-input]').type('test@example.com')
|
||||
cy.get('[data-cy=send-reset-button]').click()
|
||||
|
||||
cy.wait('@forgotPassword')
|
||||
cy.shouldShowSuccess('重置邮件已发送')
|
||||
})
|
||||
|
||||
it('should reset password with valid token', () => {
|
||||
cy.visit('/auth/reset-password?token=valid-token')
|
||||
|
||||
cy.intercept('POST', '/api/auth/reset-password', {
|
||||
statusCode: 200,
|
||||
body: { message: '密码重置成功' }
|
||||
}).as('resetPassword')
|
||||
|
||||
cy.get('[data-cy=new-password-input]').type('newpassword123')
|
||||
cy.get('[data-cy=confirm-password-input]').type('newpassword123')
|
||||
cy.get('[data-cy=reset-button]').click()
|
||||
|
||||
cy.wait('@resetPassword')
|
||||
cy.shouldShowSuccess('密码重置成功')
|
||||
cy.url().should('include', '/auth/login')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Session Management', () => {
|
||||
it('should handle token expiration', () => {
|
||||
cy.login()
|
||||
|
||||
// 模拟token过期
|
||||
cy.intercept('GET', '/api/user/profile', {
|
||||
statusCode: 401,
|
||||
body: { message: 'Token expired' }
|
||||
}).as('tokenExpired')
|
||||
|
||||
// 访问需要认证的页面
|
||||
cy.visit('/app/dashboard')
|
||||
cy.wait('@tokenExpired')
|
||||
|
||||
// 应该重定向到登录页
|
||||
cy.url().should('include', '/auth/login')
|
||||
})
|
||||
|
||||
it('should refresh token automatically', () => {
|
||||
cy.login()
|
||||
|
||||
// 模拟token即将过期
|
||||
cy.intercept('POST', '/api/auth/refresh', {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
token: 'new-token',
|
||||
expiresIn: 7200
|
||||
}
|
||||
}).as('refreshToken')
|
||||
|
||||
// 触发token刷新
|
||||
cy.visit('/app/dashboard')
|
||||
cy.wait('@refreshToken')
|
||||
|
||||
// 验证新token被保存
|
||||
cy.shouldHaveLocalStorage('auth_token', 'new-token')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Responsive Design', () => {
|
||||
it('should work on mobile devices', () => {
|
||||
cy.setMobileViewport()
|
||||
cy.visit('/auth/login')
|
||||
|
||||
cy.shouldBeVisible('[data-cy=login-form]')
|
||||
cy.get('[data-cy=username-input]').should('be.visible')
|
||||
cy.get('[data-cy=password-input]').should('be.visible')
|
||||
cy.get('[data-cy=login-button]').should('be.visible')
|
||||
})
|
||||
|
||||
it('should adapt to different screen sizes', () => {
|
||||
cy.visit('/auth/login')
|
||||
cy.checkResponsive('[data-cy=login-form]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should be accessible', () => {
|
||||
cy.visit('/auth/login')
|
||||
cy.checkA11y()
|
||||
})
|
||||
|
||||
it('should support keyboard navigation', () => {
|
||||
cy.visit('/auth/login')
|
||||
|
||||
cy.get('body').tab()
|
||||
cy.focused().should('have.attr', 'data-cy', 'username-input')
|
||||
|
||||
cy.focused().tab()
|
||||
cy.focused().should('have.attr', 'data-cy', 'password-input')
|
||||
|
||||
cy.focused().tab()
|
||||
cy.focused().should('have.attr', 'data-cy', 'login-button')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,204 +0,0 @@
|
||||
/**
|
||||
* E2E 测试自定义命令
|
||||
*/
|
||||
|
||||
// 登录命令
|
||||
Cypress.Commands.add('login', (username?: string, password?: string) => {
|
||||
const user = username || Cypress.env('testUser').username
|
||||
const pass = password || Cypress.env('testUser').password
|
||||
|
||||
cy.visit('/auth/login')
|
||||
cy.get('[data-cy=username-input]').type(user)
|
||||
cy.get('[data-cy=password-input]').type(pass)
|
||||
cy.get('[data-cy=login-button]').click()
|
||||
|
||||
// 等待登录完成
|
||||
cy.wait('@login')
|
||||
cy.url().should('not.include', '/auth/login')
|
||||
})
|
||||
|
||||
// 登出命令
|
||||
Cypress.Commands.add('logout', () => {
|
||||
cy.get('[data-cy=user-menu]').click()
|
||||
cy.get('[data-cy=logout-button]').click()
|
||||
cy.wait('@logout')
|
||||
cy.url().should('include', '/auth/login')
|
||||
})
|
||||
|
||||
// 等待页面加载完成
|
||||
Cypress.Commands.add('waitForPageLoad', () => {
|
||||
cy.get('[data-cy=loading]').should('not.exist')
|
||||
cy.get('body').should('be.visible')
|
||||
})
|
||||
|
||||
// 检查元素是否可见
|
||||
Cypress.Commands.add('shouldBeVisible', (selector: string) => {
|
||||
cy.get(selector).should('be.visible')
|
||||
})
|
||||
|
||||
// 检查元素是否包含文本
|
||||
Cypress.Commands.add('shouldContainText', (selector: string, text: string) => {
|
||||
cy.get(selector).should('contain.text', text)
|
||||
})
|
||||
|
||||
// 上传文件命令
|
||||
Cypress.Commands.add('uploadFile', (selector: string, fileName: string) => {
|
||||
cy.fixture(fileName, 'base64').then(fileContent => {
|
||||
cy.get(selector).selectFile({
|
||||
contents: Cypress.Buffer.from(fileContent, 'base64'),
|
||||
fileName,
|
||||
mimeType: 'image/jpeg'
|
||||
}, { force: true })
|
||||
})
|
||||
})
|
||||
|
||||
// 等待 API 请求完成
|
||||
Cypress.Commands.add('waitForApi', (alias: string) => {
|
||||
cy.wait(`@${alias}`)
|
||||
})
|
||||
|
||||
// 模拟网络延迟
|
||||
Cypress.Commands.add('simulateNetworkDelay', (delay: number) => {
|
||||
cy.intercept('**', (req) => {
|
||||
req.reply((res) => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => resolve(res), delay)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// 检查无障碍性
|
||||
Cypress.Commands.add('checkA11y', () => {
|
||||
cy.injectAxe()
|
||||
cy.checkA11y()
|
||||
})
|
||||
|
||||
// 自定义断言
|
||||
Cypress.Commands.add('shouldHaveClass', { prevSubject: true }, (subject, className) => {
|
||||
cy.wrap(subject).should('have.class', className)
|
||||
})
|
||||
|
||||
Cypress.Commands.add('shouldNotHaveClass', { prevSubject: true }, (subject, className) => {
|
||||
cy.wrap(subject).should('not.have.class', className)
|
||||
})
|
||||
|
||||
// 表单填写命令
|
||||
Cypress.Commands.add('fillForm', (formData: Record<string, string>) => {
|
||||
Object.entries(formData).forEach(([field, value]) => {
|
||||
cy.get(`[data-cy=${field}-input]`).clear().type(value)
|
||||
})
|
||||
})
|
||||
|
||||
// 等待元素出现
|
||||
Cypress.Commands.add('waitForElement', (selector: string, timeout = 10000) => {
|
||||
cy.get(selector, { timeout }).should('exist')
|
||||
})
|
||||
|
||||
// 滚动到元素
|
||||
Cypress.Commands.add('scrollToElement', (selector: string) => {
|
||||
cy.get(selector).scrollIntoView()
|
||||
})
|
||||
|
||||
// 模拟移动设备
|
||||
Cypress.Commands.add('setMobileViewport', () => {
|
||||
cy.viewport(375, 667) // iPhone 6/7/8 尺寸
|
||||
})
|
||||
|
||||
// 模拟平板设备
|
||||
Cypress.Commands.add('setTabletViewport', () => {
|
||||
cy.viewport(768, 1024) // iPad 尺寸
|
||||
})
|
||||
|
||||
// 模拟桌面设备
|
||||
Cypress.Commands.add('setDesktopViewport', () => {
|
||||
cy.viewport(1280, 720)
|
||||
})
|
||||
|
||||
// 检查响应式设计
|
||||
Cypress.Commands.add('checkResponsive', (selector: string) => {
|
||||
// 桌面
|
||||
cy.setDesktopViewport()
|
||||
cy.get(selector).should('be.visible')
|
||||
|
||||
// 平板
|
||||
cy.setTabletViewport()
|
||||
cy.get(selector).should('be.visible')
|
||||
|
||||
// 移动
|
||||
cy.setMobileViewport()
|
||||
cy.get(selector).should('be.visible')
|
||||
|
||||
// 恢复桌面
|
||||
cy.setDesktopViewport()
|
||||
})
|
||||
|
||||
// 模拟键盘导航
|
||||
Cypress.Commands.add('navigateWithKeyboard', (selector: string) => {
|
||||
cy.get('body').tab()
|
||||
cy.focused().should('have.attr', 'data-cy', selector)
|
||||
})
|
||||
|
||||
// 检查加载状态
|
||||
Cypress.Commands.add('shouldBeLoading', (selector: string) => {
|
||||
cy.get(selector).should('have.class', 'loading')
|
||||
})
|
||||
|
||||
Cypress.Commands.add('shouldNotBeLoading', (selector: string) => {
|
||||
cy.get(selector).should('not.have.class', 'loading')
|
||||
})
|
||||
|
||||
// 模拟网络错误
|
||||
Cypress.Commands.add('simulateNetworkError', (url: string) => {
|
||||
cy.intercept('GET', url, { forceNetworkError: true })
|
||||
})
|
||||
|
||||
// 检查错误消息
|
||||
Cypress.Commands.add('shouldShowError', (message: string) => {
|
||||
cy.get('[data-cy=error-message]').should('contain.text', message)
|
||||
})
|
||||
|
||||
// 检查成功消息
|
||||
Cypress.Commands.add('shouldShowSuccess', (message: string) => {
|
||||
cy.get('[data-cy=success-message]').should('contain.text', message)
|
||||
})
|
||||
|
||||
// 清除通知
|
||||
Cypress.Commands.add('clearNotifications', () => {
|
||||
cy.get('[data-cy=notification-close]').each(($el) => {
|
||||
cy.wrap($el).click()
|
||||
})
|
||||
})
|
||||
|
||||
// 等待动画完成
|
||||
Cypress.Commands.add('waitForAnimation', (selector?: string) => {
|
||||
if (selector) {
|
||||
cy.get(selector).should('not.have.class', 'animating')
|
||||
} else {
|
||||
cy.wait(300) // 默认等待动画时间
|
||||
}
|
||||
})
|
||||
|
||||
// 模拟拖拽
|
||||
Cypress.Commands.add('dragAndDrop', (sourceSelector: string, targetSelector: string) => {
|
||||
cy.get(sourceSelector).trigger('mousedown', { which: 1 })
|
||||
cy.get(targetSelector).trigger('mousemove').trigger('mouseup')
|
||||
})
|
||||
|
||||
// 检查本地存储
|
||||
Cypress.Commands.add('shouldHaveLocalStorage', (key: string, value?: string) => {
|
||||
cy.window().its('localStorage').invoke('getItem', key).should('exist')
|
||||
if (value) {
|
||||
cy.window().its('localStorage').invoke('getItem', key).should('eq', value)
|
||||
}
|
||||
})
|
||||
|
||||
// 清除本地存储特定项
|
||||
Cypress.Commands.add('clearLocalStorageItem', (key: string) => {
|
||||
cy.window().its('localStorage').invoke('removeItem', key)
|
||||
})
|
||||
|
||||
// 设置本地存储
|
||||
Cypress.Commands.add('setLocalStorage', (key: string, value: string) => {
|
||||
cy.window().its('localStorage').invoke('setItem', key, value)
|
||||
})
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* E2E 测试支持文件
|
||||
*/
|
||||
|
||||
// 导入 Cypress 命令
|
||||
import './commands'
|
||||
|
||||
// 全局配置
|
||||
Cypress.on('uncaught:exception', (err, runnable) => {
|
||||
// 忽略某些预期的错误
|
||||
if (err.message.includes('ResizeObserver loop limit exceeded')) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (err.message.includes('Non-Error promise rejection captured')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 返回 false 阻止 Cypress 失败测试
|
||||
return false
|
||||
})
|
||||
|
||||
// 测试前钩子
|
||||
beforeEach(() => {
|
||||
// 清除本地存储
|
||||
cy.clearLocalStorage()
|
||||
cy.clearCookies()
|
||||
|
||||
// 设置视口
|
||||
cy.viewport(1280, 720)
|
||||
|
||||
// 拦截 API 请求(可选)
|
||||
cy.intercept('GET', '/api/user/profile', { fixture: 'user.json' }).as('getUserProfile')
|
||||
cy.intercept('POST', '/api/auth/login', { fixture: 'auth.json' }).as('login')
|
||||
cy.intercept('POST', '/api/auth/logout', { statusCode: 200 }).as('logout')
|
||||
})
|
||||
|
||||
// 测试后钩子
|
||||
afterEach(() => {
|
||||
// 清理工作
|
||||
cy.clearLocalStorage()
|
||||
|
||||
// 截图(失败时)
|
||||
cy.screenshot({ capture: 'runner', onlyOnFailure: true })
|
||||
})
|
||||
|
||||
// 自定义断言
|
||||
declare global {
|
||||
namespace Cypress {
|
||||
interface Chainable {
|
||||
/**
|
||||
* 登录用户
|
||||
*/
|
||||
login(username?: string, password?: string): Chainable<void>
|
||||
|
||||
/**
|
||||
* 登出用户
|
||||
*/
|
||||
logout(): Chainable<void>
|
||||
|
||||
/**
|
||||
* 等待页面加载完成
|
||||
*/
|
||||
waitForPageLoad(): Chainable<void>
|
||||
|
||||
/**
|
||||
* 检查元素是否可见
|
||||
*/
|
||||
shouldBeVisible(selector: string): Chainable<void>
|
||||
|
||||
/**
|
||||
* 检查元素是否包含文本
|
||||
*/
|
||||
shouldContainText(selector: string, text: string): Chainable<void>
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
*/
|
||||
uploadFile(selector: string, fileName: string): Chainable<void>
|
||||
|
||||
/**
|
||||
* 等待 API 请求完成
|
||||
*/
|
||||
waitForApi(alias: string): Chainable<void>
|
||||
|
||||
/**
|
||||
* 模拟网络延迟
|
||||
*/
|
||||
simulateNetworkDelay(delay: number): Chainable<void>
|
||||
|
||||
/**
|
||||
* 检查无障碍性
|
||||
*/
|
||||
checkA11y(): Chainable<void>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
/**
|
||||
* 测试环境设置
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest'
|
||||
import { config } from '@vue/test-utils'
|
||||
import ElementPlus from 'element-plus'
|
||||
|
||||
// 全局组件注册
|
||||
config.global.plugins = [ElementPlus]
|
||||
|
||||
// 全局属性
|
||||
config.global.config.globalProperties = {
|
||||
$t: (key: string) => key, // 模拟国际化
|
||||
$route: {
|
||||
path: '/',
|
||||
params: {},
|
||||
query: {},
|
||||
meta: {}
|
||||
},
|
||||
$router: {
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
go: vi.fn(),
|
||||
back: vi.fn(),
|
||||
forward: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
// 模拟全局对象
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(query => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(), // deprecated
|
||||
removeListener: vi.fn(), // deprecated
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
})
|
||||
|
||||
// 模拟 ResizeObserver
|
||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}))
|
||||
|
||||
// 模拟 IntersectionObserver
|
||||
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}))
|
||||
|
||||
// 模拟 localStorage
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
}
|
||||
global.localStorage = localStorageMock
|
||||
|
||||
// 模拟 sessionStorage
|
||||
const sessionStorageMock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
}
|
||||
global.sessionStorage = sessionStorageMock
|
||||
|
||||
// 模拟 fetch
|
||||
global.fetch = vi.fn()
|
||||
|
||||
// 模拟 URL.createObjectURL
|
||||
global.URL.createObjectURL = vi.fn(() => 'mocked-url')
|
||||
global.URL.revokeObjectURL = vi.fn()
|
||||
|
||||
// 模拟 Notification
|
||||
global.Notification = vi.fn().mockImplementation(() => ({
|
||||
close: vi.fn(),
|
||||
}))
|
||||
|
||||
// 模拟 navigator
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: {
|
||||
writeText: vi.fn().mockResolvedValue(undefined),
|
||||
readText: vi.fn().mockResolvedValue(''),
|
||||
},
|
||||
writable: true,
|
||||
})
|
||||
|
||||
Object.defineProperty(navigator, 'vibrate', {
|
||||
value: vi.fn(),
|
||||
writable: true,
|
||||
})
|
||||
|
||||
// 模拟 document.execCommand
|
||||
document.execCommand = vi.fn()
|
||||
|
||||
// 模拟 getSelection
|
||||
global.getSelection = vi.fn().mockReturnValue({
|
||||
toString: vi.fn().mockReturnValue(''),
|
||||
removeAllRanges: vi.fn(),
|
||||
addRange: vi.fn(),
|
||||
})
|
||||
|
||||
// 模拟 console 方法(避免测试时输出过多日志)
|
||||
global.console = {
|
||||
...console,
|
||||
log: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}
|
||||
|
||||
// 设置测试环境变量
|
||||
process.env.NODE_ENV = 'test'
|
||||
process.env.VITE_APP_ENV = 'test'
|
||||
@@ -1,245 +0,0 @@
|
||||
/**
|
||||
* 文件上传组件测试
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { ElUpload, ElButton } from 'element-plus'
|
||||
import FileUpload from '@/components/upload/FileUpload.vue'
|
||||
|
||||
// 模拟 Element Plus 组件
|
||||
vi.mock('element-plus', () => ({
|
||||
ElUpload: {
|
||||
name: 'ElUpload',
|
||||
template: '<div class="el-upload"><slot /></div>',
|
||||
props: ['action', 'headers', 'data', 'multiple', 'accept', 'limit', 'fileList', 'beforeUpload', 'onProgress', 'onSuccess', 'onError', 'onRemove', 'onExceed', 'autoUpload', 'showFileList', 'drag', 'disabled']
|
||||
},
|
||||
ElButton: {
|
||||
name: 'ElButton',
|
||||
template: '<button class="el-button"><slot /></button>',
|
||||
props: ['type', 'disabled']
|
||||
},
|
||||
ElIcon: {
|
||||
name: 'ElIcon',
|
||||
template: '<i class="el-icon"><slot /></i>'
|
||||
},
|
||||
ElProgress: {
|
||||
name: 'ElProgress',
|
||||
template: '<div class="el-progress"></div>',
|
||||
props: ['percentage', 'status', 'strokeWidth']
|
||||
}
|
||||
}))
|
||||
|
||||
// 模拟图标组件
|
||||
vi.mock('@element-plus/icons-vue', () => ({
|
||||
UploadFilled: { name: 'UploadFilled' },
|
||||
Upload: { name: 'Upload' },
|
||||
Document: { name: 'Document' },
|
||||
Picture: { name: 'Picture' }
|
||||
}))
|
||||
|
||||
// 模拟认证状态
|
||||
vi.mock('@/stores/auth', () => ({
|
||||
useAuthStore: () => ({
|
||||
token: 'mock-token'
|
||||
})
|
||||
}))
|
||||
|
||||
// 模拟配置
|
||||
vi.mock('@/config/constants', () => ({
|
||||
UPLOAD_CONFIG: {
|
||||
DEFAULT_UPLOAD_URL: '/api/upload',
|
||||
IMAGE_TYPES: ['image/jpeg', 'image/png', 'image/gif'],
|
||||
DOCUMENT_TYPES: ['application/pdf', 'application/msword'],
|
||||
VIDEO_TYPES: ['video/mp4', 'video/avi'],
|
||||
AUDIO_TYPES: ['audio/mp3', 'audio/wav']
|
||||
}
|
||||
}))
|
||||
|
||||
// 模拟格式化工具
|
||||
vi.mock('@/utils/format', () => ({
|
||||
formatFileSize: (size: number) => `${size} B`
|
||||
}))
|
||||
|
||||
describe('FileUpload', () => {
|
||||
let wrapper: any
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = mount(FileUpload, {
|
||||
props: {
|
||||
action: '/api/upload',
|
||||
multiple: false,
|
||||
accept: 'image/*',
|
||||
limit: 5,
|
||||
maxSize: 1024 * 1024, // 1MB
|
||||
autoUpload: true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
})
|
||||
|
||||
it('should render correctly', () => {
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
expect(wrapper.find('.file-upload').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should render upload button when not drag mode', () => {
|
||||
expect(wrapper.find('.el-button').exists()).toBe(true)
|
||||
expect(wrapper.find('.upload-dragger').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('should render drag area when drag mode is enabled', async () => {
|
||||
await wrapper.setProps({ drag: true })
|
||||
expect(wrapper.find('.upload-dragger').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should show upload hint', () => {
|
||||
expect(wrapper.find('.upload-tip').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should emit events correctly', async () => {
|
||||
const file = new File(['test'], 'test.txt', { type: 'text/plain' })
|
||||
|
||||
// 模拟文件上传成功
|
||||
await wrapper.vm.handleSuccess({ url: 'http://example.com/file.txt' }, { uid: '1', name: 'test.txt' })
|
||||
|
||||
expect(wrapper.emitted('success')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should validate file type', () => {
|
||||
const validFile = new File(['test'], 'test.jpg', { type: 'image/jpeg' })
|
||||
const invalidFile = new File(['test'], 'test.txt', { type: 'text/plain' })
|
||||
|
||||
// 设置接受的文件类型
|
||||
wrapper.vm.acceptTypes = 'image/jpeg,image/png'
|
||||
|
||||
expect(wrapper.vm.isValidFileType(validFile)).toBe(true)
|
||||
expect(wrapper.vm.isValidFileType(invalidFile)).toBe(false)
|
||||
})
|
||||
|
||||
it('should validate file size', async () => {
|
||||
const smallFile = new File(['small'], 'small.txt', { type: 'text/plain' })
|
||||
Object.defineProperty(smallFile, 'size', { value: 500 })
|
||||
|
||||
const largeFile = new File(['large'], 'large.txt', { type: 'text/plain' })
|
||||
Object.defineProperty(largeFile, 'size', { value: 2 * 1024 * 1024 }) // 2MB
|
||||
|
||||
// 测试文件大小验证
|
||||
const result1 = await wrapper.vm.handleBeforeUpload(smallFile)
|
||||
expect(result1).toBe(true)
|
||||
|
||||
const result2 = await wrapper.vm.handleBeforeUpload(largeFile)
|
||||
expect(result2).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle upload progress', () => {
|
||||
const progressEvent = { percent: 50 }
|
||||
const file = { uid: '1', name: 'test.txt' }
|
||||
|
||||
wrapper.vm.handleProgress(progressEvent, file)
|
||||
|
||||
expect(wrapper.vm.uploadPercent).toBe(50)
|
||||
expect(wrapper.emitted('progress')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should handle upload error', () => {
|
||||
const error = new Error('Upload failed')
|
||||
const file = { uid: '1', name: 'test.txt' }
|
||||
|
||||
wrapper.vm.handleError(error, file)
|
||||
|
||||
expect(wrapper.vm.uploadStatus).toBe('exception')
|
||||
expect(wrapper.emitted('error')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should handle file removal', () => {
|
||||
const file = { uid: '1', name: 'test.txt' }
|
||||
|
||||
wrapper.vm.handleRemove(file)
|
||||
|
||||
expect(wrapper.emitted('remove')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should handle exceed limit', () => {
|
||||
wrapper.vm.handleExceed()
|
||||
|
||||
// 应该显示警告消息(这里我们只能检查方法是否被调用)
|
||||
expect(true).toBe(true) // 占位断言
|
||||
})
|
||||
|
||||
it('should clear files', () => {
|
||||
wrapper.vm.fileList = [
|
||||
{ uid: '1', name: 'test1.txt' },
|
||||
{ uid: '2', name: 'test2.txt' }
|
||||
]
|
||||
|
||||
wrapper.vm.clearFiles()
|
||||
|
||||
expect(wrapper.vm.fileList).toEqual([])
|
||||
})
|
||||
|
||||
it('should compute upload headers correctly', () => {
|
||||
const headers = wrapper.vm.uploadHeaders
|
||||
|
||||
expect(headers).toHaveProperty('Authorization')
|
||||
expect(headers.Authorization).toBe('Bearer mock-token')
|
||||
expect(headers['X-Requested-With']).toBe('XMLHttpRequest')
|
||||
})
|
||||
|
||||
it('should compute upload data correctly', async () => {
|
||||
await wrapper.setProps({
|
||||
fileType: 'image',
|
||||
data: { category: 'avatar' }
|
||||
})
|
||||
|
||||
const data = wrapper.vm.uploadData
|
||||
|
||||
expect(data.type).toBe('image')
|
||||
expect(data.category).toBe('avatar')
|
||||
})
|
||||
|
||||
it('should compute accept types correctly', async () => {
|
||||
await wrapper.setProps({ fileType: 'image' })
|
||||
expect(wrapper.vm.acceptTypes).toBe('image/jpeg,image/png,image/gif')
|
||||
|
||||
await wrapper.setProps({ fileType: 'document' })
|
||||
expect(wrapper.vm.acceptTypes).toBe('application/pdf,application/msword')
|
||||
|
||||
await wrapper.setProps({ accept: 'custom/*' })
|
||||
expect(wrapper.vm.acceptTypes).toBe('custom/*')
|
||||
})
|
||||
|
||||
it('should compute upload hint correctly', async () => {
|
||||
await wrapper.setProps({
|
||||
fileType: 'image',
|
||||
limit: 3,
|
||||
maxSize: 1024 * 1024
|
||||
})
|
||||
|
||||
const hint = wrapper.vm.uploadHint
|
||||
|
||||
expect(hint).toContain('最多3个文件')
|
||||
expect(hint).toContain('JPG、PNG、GIF')
|
||||
expect(hint).toContain('1024 B') // 模拟的格式化结果
|
||||
})
|
||||
|
||||
it('should handle disabled state', async () => {
|
||||
await wrapper.setProps({ disabled: true })
|
||||
|
||||
expect(wrapper.find('.el-button').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('should handle custom button text and type', async () => {
|
||||
await wrapper.setProps({
|
||||
buttonText: 'Custom Upload',
|
||||
buttonType: 'success'
|
||||
})
|
||||
|
||||
const button = wrapper.find('.el-button')
|
||||
expect(button.text()).toContain('Custom Upload')
|
||||
expect(button.attributes('type')).toBe('success')
|
||||
})
|
||||
})
|
||||
@@ -1,349 +0,0 @@
|
||||
/**
|
||||
* 认证状态管理测试
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
// 模拟 API
|
||||
vi.mock('@/api/auth', () => ({
|
||||
authApi: {
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
refreshToken: vi.fn(),
|
||||
getUserInfo: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
// 模拟存储工具
|
||||
vi.mock('@/utils/storage', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
clear: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
// 模拟路由
|
||||
const mockRouter = {
|
||||
push: vi.fn(),
|
||||
replace: vi.fn()
|
||||
}
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => mockRouter
|
||||
}))
|
||||
|
||||
// 模拟配置
|
||||
vi.mock('@/config/constants', () => ({
|
||||
STORAGE_KEYS: {
|
||||
TOKEN: 'auth_token',
|
||||
REFRESH_TOKEN: 'refresh_token',
|
||||
USER_INFO: 'user_info'
|
||||
},
|
||||
TOKEN_CONFIG: {
|
||||
EXPIRES_IN: 7200,
|
||||
REFRESH_THRESHOLD: 300
|
||||
}
|
||||
}))
|
||||
|
||||
describe('useAuthStore', () => {
|
||||
let authStore: ReturnType<typeof useAuthStore>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
authStore = useAuthStore()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should have correct initial state', () => {
|
||||
expect(authStore.token).toBe('')
|
||||
expect(authStore.refreshToken).toBe('')
|
||||
expect(authStore.user).toBeNull()
|
||||
expect(authStore.isAuthenticated).toBe(false)
|
||||
expect(authStore.isTokenExpired).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getters', () => {
|
||||
it('should compute isAuthenticated correctly', () => {
|
||||
expect(authStore.isAuthenticated).toBe(false)
|
||||
|
||||
authStore.token = 'valid-token'
|
||||
authStore.tokenExpireTime = Date.now() + 3600000 // 1小时后过期
|
||||
|
||||
expect(authStore.isAuthenticated).toBe(true)
|
||||
})
|
||||
|
||||
it('should compute isTokenExpired correctly', () => {
|
||||
expect(authStore.isTokenExpired).toBe(true)
|
||||
|
||||
authStore.tokenExpireTime = Date.now() + 3600000 // 1小时后过期
|
||||
expect(authStore.isTokenExpired).toBe(false)
|
||||
|
||||
authStore.tokenExpireTime = Date.now() - 3600000 // 1小时前过期
|
||||
expect(authStore.isTokenExpired).toBe(true)
|
||||
})
|
||||
|
||||
it('should compute needsRefresh correctly', () => {
|
||||
authStore.tokenExpireTime = Date.now() + 600000 // 10分钟后过期
|
||||
expect(authStore.needsRefresh).toBe(true)
|
||||
|
||||
authStore.tokenExpireTime = Date.now() + 3600000 // 1小时后过期
|
||||
expect(authStore.needsRefresh).toBe(false)
|
||||
})
|
||||
|
||||
it('should compute user properties correctly', () => {
|
||||
authStore.user = {
|
||||
id: '1',
|
||||
username: 'testuser',
|
||||
nickname: 'Test User',
|
||||
email: 'test@example.com',
|
||||
avatar: 'avatar.jpg',
|
||||
role: 'user',
|
||||
createTime: Date.now(),
|
||||
updateTime: Date.now()
|
||||
}
|
||||
|
||||
expect(authStore.userId).toBe('1')
|
||||
expect(authStore.username).toBe('testuser')
|
||||
expect(authStore.nickname).toBe('Test User')
|
||||
expect(authStore.email).toBe('test@example.com')
|
||||
expect(authStore.avatar).toBe('avatar.jpg')
|
||||
expect(authStore.userRole).toBe('user')
|
||||
})
|
||||
})
|
||||
|
||||
describe('actions', () => {
|
||||
describe('login', () => {
|
||||
it('should login successfully', async () => {
|
||||
const mockResponse = {
|
||||
token: 'new-token',
|
||||
refreshToken: 'new-refresh-token',
|
||||
expiresIn: 7200,
|
||||
user: {
|
||||
id: '1',
|
||||
username: 'testuser',
|
||||
nickname: 'Test User',
|
||||
email: 'test@example.com'
|
||||
}
|
||||
}
|
||||
|
||||
const { authApi } = await import('@/api/auth')
|
||||
vi.mocked(authApi.login).mockResolvedValue(mockResponse)
|
||||
|
||||
const loginData = {
|
||||
username: 'testuser',
|
||||
password: 'password123'
|
||||
}
|
||||
|
||||
const result = await authStore.login(loginData)
|
||||
|
||||
expect(authApi.login).toHaveBeenCalledWith(loginData)
|
||||
expect(authStore.token).toBe('new-token')
|
||||
expect(authStore.refreshToken).toBe('new-refresh-token')
|
||||
expect(authStore.user).toEqual(mockResponse.user)
|
||||
expect(result).toEqual(mockResponse)
|
||||
})
|
||||
|
||||
it('should handle login failure', async () => {
|
||||
const { authApi } = await import('@/api/auth')
|
||||
const error = new Error('Invalid credentials')
|
||||
vi.mocked(authApi.login).mockRejectedValue(error)
|
||||
|
||||
const loginData = {
|
||||
username: 'testuser',
|
||||
password: 'wrongpassword'
|
||||
}
|
||||
|
||||
await expect(authStore.login(loginData)).rejects.toThrow('Invalid credentials')
|
||||
expect(authStore.token).toBe('')
|
||||
expect(authStore.user).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('register', () => {
|
||||
it('should register successfully', async () => {
|
||||
const mockResponse = {
|
||||
token: 'new-token',
|
||||
refreshToken: 'new-refresh-token',
|
||||
expiresIn: 7200,
|
||||
user: {
|
||||
id: '1',
|
||||
username: 'newuser',
|
||||
nickname: 'New User',
|
||||
email: 'new@example.com'
|
||||
}
|
||||
}
|
||||
|
||||
const { authApi } = await import('@/api/auth')
|
||||
vi.mocked(authApi.register).mockResolvedValue(mockResponse)
|
||||
|
||||
const registerData = {
|
||||
username: 'newuser',
|
||||
password: 'password123',
|
||||
email: 'new@example.com',
|
||||
nickname: 'New User'
|
||||
}
|
||||
|
||||
const result = await authStore.register(registerData)
|
||||
|
||||
expect(authApi.register).toHaveBeenCalledWith(registerData)
|
||||
expect(authStore.token).toBe('new-token')
|
||||
expect(authStore.user).toEqual(mockResponse.user)
|
||||
expect(result).toEqual(mockResponse)
|
||||
})
|
||||
})
|
||||
|
||||
describe('logout', () => {
|
||||
it('should logout successfully', async () => {
|
||||
// 设置初始状态
|
||||
authStore.token = 'current-token'
|
||||
authStore.refreshToken = 'current-refresh-token'
|
||||
authStore.user = { id: '1', username: 'testuser' } as any
|
||||
|
||||
const { authApi } = await import('@/api/auth')
|
||||
vi.mocked(authApi.logout).mockResolvedValue(undefined)
|
||||
|
||||
await authStore.logout()
|
||||
|
||||
expect(authApi.logout).toHaveBeenCalled()
|
||||
expect(authStore.token).toBe('')
|
||||
expect(authStore.refreshToken).toBe('')
|
||||
expect(authStore.user).toBeNull()
|
||||
expect(authStore.tokenExpireTime).toBe(0)
|
||||
})
|
||||
|
||||
it('should clear state even if API call fails', async () => {
|
||||
authStore.token = 'current-token'
|
||||
authStore.user = { id: '1', username: 'testuser' } as any
|
||||
|
||||
const { authApi } = await import('@/api/auth')
|
||||
vi.mocked(authApi.logout).mockRejectedValue(new Error('Network error'))
|
||||
|
||||
await authStore.logout()
|
||||
|
||||
expect(authStore.token).toBe('')
|
||||
expect(authStore.user).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('refreshToken', () => {
|
||||
it('should refresh token successfully', async () => {
|
||||
authStore.refreshToken = 'current-refresh-token'
|
||||
|
||||
const mockResponse = {
|
||||
token: 'new-token',
|
||||
refreshToken: 'new-refresh-token',
|
||||
expiresIn: 7200
|
||||
}
|
||||
|
||||
const { authApi } = await import('@/api/auth')
|
||||
vi.mocked(authApi.refreshToken).mockResolvedValue(mockResponse)
|
||||
|
||||
const result = await authStore.refreshTokenAction()
|
||||
|
||||
expect(authApi.refreshToken).toHaveBeenCalledWith('current-refresh-token')
|
||||
expect(authStore.token).toBe('new-token')
|
||||
expect(authStore.refreshToken).toBe('new-refresh-token')
|
||||
expect(result).toEqual(mockResponse)
|
||||
})
|
||||
|
||||
it('should handle refresh token failure', async () => {
|
||||
authStore.refreshToken = 'invalid-refresh-token'
|
||||
|
||||
const { authApi } = await import('@/api/auth')
|
||||
vi.mocked(authApi.refreshToken).mockRejectedValue(new Error('Invalid refresh token'))
|
||||
|
||||
await expect(authStore.refreshTokenAction()).rejects.toThrow('Invalid refresh token')
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateUserInfo', () => {
|
||||
it('should update user info', async () => {
|
||||
authStore.user = {
|
||||
id: '1',
|
||||
username: 'testuser',
|
||||
nickname: 'Old Name',
|
||||
email: 'old@example.com'
|
||||
} as any
|
||||
|
||||
const updates = {
|
||||
nickname: 'New Name',
|
||||
email: 'new@example.com'
|
||||
}
|
||||
|
||||
await authStore.updateUserInfo(updates)
|
||||
|
||||
expect(authStore.user?.nickname).toBe('New Name')
|
||||
expect(authStore.user?.email).toBe('new@example.com')
|
||||
expect(authStore.user?.username).toBe('testuser') // 保持不变
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkAuthStatus', () => {
|
||||
it('should return true for valid authentication', () => {
|
||||
authStore.token = 'valid-token'
|
||||
authStore.tokenExpireTime = Date.now() + 3600000
|
||||
|
||||
expect(authStore.checkAuthStatus()).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for expired token', () => {
|
||||
authStore.token = 'expired-token'
|
||||
authStore.tokenExpireTime = Date.now() - 3600000
|
||||
|
||||
expect(authStore.checkAuthStatus()).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for missing token', () => {
|
||||
authStore.token = ''
|
||||
|
||||
expect(authStore.checkAuthStatus()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('persistence', () => {
|
||||
it('should save state to storage', () => {
|
||||
const storage = require('@/utils/storage').default
|
||||
|
||||
authStore.token = 'test-token'
|
||||
authStore.refreshToken = 'test-refresh-token'
|
||||
authStore.user = { id: '1', username: 'testuser' } as any
|
||||
|
||||
authStore.saveToStorage()
|
||||
|
||||
expect(storage.set).toHaveBeenCalledWith('auth_token', 'test-token')
|
||||
expect(storage.set).toHaveBeenCalledWith('refresh_token', 'test-refresh-token')
|
||||
expect(storage.set).toHaveBeenCalledWith('user_info', authStore.user)
|
||||
})
|
||||
|
||||
it('should load state from storage', () => {
|
||||
const storage = require('@/utils/storage').default
|
||||
|
||||
storage.get.mockImplementation((key: string) => {
|
||||
switch (key) {
|
||||
case 'auth_token':
|
||||
return 'stored-token'
|
||||
case 'refresh_token':
|
||||
return 'stored-refresh-token'
|
||||
case 'user_info':
|
||||
return { id: '1', username: 'storeduser' }
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
authStore.loadFromStorage()
|
||||
|
||||
expect(authStore.token).toBe('stored-token')
|
||||
expect(authStore.refreshToken).toBe('stored-refresh-token')
|
||||
expect(authStore.user).toEqual({ id: '1', username: 'storeduser' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,223 +0,0 @@
|
||||
/**
|
||||
* 格式化工具函数测试
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
formatDate,
|
||||
formatDateTime,
|
||||
formatTime,
|
||||
formatRelativeTime,
|
||||
formatFileSize,
|
||||
formatNumber,
|
||||
formatCurrency,
|
||||
maskPhone,
|
||||
maskEmail,
|
||||
truncateText
|
||||
} from '@/utils/format'
|
||||
|
||||
describe('format utils', () => {
|
||||
beforeEach(() => {
|
||||
// 重置时间相关的模拟
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2024-01-15 12:00:00'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('should format timestamp to date string', () => {
|
||||
const timestamp = new Date('2024-01-15').getTime()
|
||||
expect(formatDate(timestamp)).toBe('2024-01-15')
|
||||
})
|
||||
|
||||
it('should format Date object to date string', () => {
|
||||
const date = new Date('2024-01-15')
|
||||
expect(formatDate(date)).toBe('2024-01-15')
|
||||
})
|
||||
|
||||
it('should use custom format', () => {
|
||||
const timestamp = new Date('2024-01-15').getTime()
|
||||
expect(formatDate(timestamp, 'MM/DD/YYYY')).toBe('01/15/2024')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDateTime', () => {
|
||||
it('should format timestamp to datetime string', () => {
|
||||
const timestamp = new Date('2024-01-15 12:30:45').getTime()
|
||||
expect(formatDateTime(timestamp)).toBe('2024-01-15 12:30:45')
|
||||
})
|
||||
|
||||
it('should use custom format', () => {
|
||||
const timestamp = new Date('2024-01-15 12:30:45').getTime()
|
||||
expect(formatDateTime(timestamp, 'YYYY年MM月DD日 HH:mm')).toBe('2024年01月15日 12:30')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatTime', () => {
|
||||
it('should format timestamp to time string', () => {
|
||||
const timestamp = new Date('2024-01-15 12:30:45').getTime()
|
||||
expect(formatTime(timestamp)).toBe('12:30:45')
|
||||
})
|
||||
|
||||
it('should use custom format', () => {
|
||||
const timestamp = new Date('2024-01-15 12:30:45').getTime()
|
||||
expect(formatTime(timestamp, 'HH:mm')).toBe('12:30')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatRelativeTime', () => {
|
||||
it('should return "刚刚" for very recent time', () => {
|
||||
const timestamp = Date.now() - 1000 // 1秒前
|
||||
expect(formatRelativeTime(timestamp)).toBe('刚刚')
|
||||
})
|
||||
|
||||
it('should return minutes ago', () => {
|
||||
const timestamp = Date.now() - 5 * 60 * 1000 // 5分钟前
|
||||
expect(formatRelativeTime(timestamp)).toBe('5分钟前')
|
||||
})
|
||||
|
||||
it('should return hours ago', () => {
|
||||
const timestamp = Date.now() - 2 * 60 * 60 * 1000 // 2小时前
|
||||
expect(formatRelativeTime(timestamp)).toBe('2小时前')
|
||||
})
|
||||
|
||||
it('should return days ago', () => {
|
||||
const timestamp = Date.now() - 3 * 24 * 60 * 60 * 1000 // 3天前
|
||||
expect(formatRelativeTime(timestamp)).toBe('3天前')
|
||||
})
|
||||
|
||||
it('should return formatted date for old time', () => {
|
||||
const timestamp = Date.now() - 10 * 24 * 60 * 60 * 1000 // 10天前
|
||||
expect(formatRelativeTime(timestamp)).toMatch(/\d{4}-\d{2}-\d{2}/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatFileSize', () => {
|
||||
it('should format bytes', () => {
|
||||
expect(formatFileSize(512)).toBe('512 B')
|
||||
})
|
||||
|
||||
it('should format KB', () => {
|
||||
expect(formatFileSize(1024)).toBe('1.0 KB')
|
||||
expect(formatFileSize(1536)).toBe('1.5 KB')
|
||||
})
|
||||
|
||||
it('should format MB', () => {
|
||||
expect(formatFileSize(1024 * 1024)).toBe('1.0 MB')
|
||||
expect(formatFileSize(1024 * 1024 * 2.5)).toBe('2.5 MB')
|
||||
})
|
||||
|
||||
it('should format GB', () => {
|
||||
expect(formatFileSize(1024 * 1024 * 1024)).toBe('1.0 GB')
|
||||
})
|
||||
|
||||
it('should handle zero size', () => {
|
||||
expect(formatFileSize(0)).toBe('0 B')
|
||||
})
|
||||
|
||||
it('should handle negative size', () => {
|
||||
expect(formatFileSize(-1024)).toBe('0 B')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatNumber', () => {
|
||||
it('should format number with default options', () => {
|
||||
expect(formatNumber(1234.567)).toBe('1,234.567')
|
||||
})
|
||||
|
||||
it('should format number with custom decimal places', () => {
|
||||
expect(formatNumber(1234.567, { decimals: 2 })).toBe('1,234.57')
|
||||
})
|
||||
|
||||
it('should format number without separator', () => {
|
||||
expect(formatNumber(1234.567, { separator: false })).toBe('1234.567')
|
||||
})
|
||||
|
||||
it('should handle zero', () => {
|
||||
expect(formatNumber(0)).toBe('0')
|
||||
})
|
||||
|
||||
it('should handle negative numbers', () => {
|
||||
expect(formatNumber(-1234.567)).toBe('-1,234.567')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatCurrency', () => {
|
||||
it('should format currency with default options', () => {
|
||||
expect(formatCurrency(1234.56)).toBe('¥1,234.56')
|
||||
})
|
||||
|
||||
it('should format currency with custom symbol', () => {
|
||||
expect(formatCurrency(1234.56, { symbol: '$' })).toBe('$1,234.56')
|
||||
})
|
||||
|
||||
it('should format currency with custom decimal places', () => {
|
||||
expect(formatCurrency(1234.567, { decimals: 3 })).toBe('¥1,234.567')
|
||||
})
|
||||
})
|
||||
|
||||
describe('maskPhone', () => {
|
||||
it('should mask phone number', () => {
|
||||
expect(maskPhone('13812345678')).toBe('138****5678')
|
||||
})
|
||||
|
||||
it('should handle short phone number', () => {
|
||||
expect(maskPhone('12345')).toBe('12345')
|
||||
})
|
||||
|
||||
it('should handle empty phone number', () => {
|
||||
expect(maskPhone('')).toBe('')
|
||||
})
|
||||
|
||||
it('should handle null/undefined', () => {
|
||||
expect(maskPhone(null)).toBe('')
|
||||
expect(maskPhone(undefined)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('maskEmail', () => {
|
||||
it('should mask email address', () => {
|
||||
expect(maskEmail('test@example.com')).toBe('t***@example.com')
|
||||
})
|
||||
|
||||
it('should handle short email', () => {
|
||||
expect(maskEmail('a@b.c')).toBe('a***@b.c')
|
||||
})
|
||||
|
||||
it('should handle invalid email', () => {
|
||||
expect(maskEmail('invalid-email')).toBe('invalid-email')
|
||||
})
|
||||
|
||||
it('should handle empty email', () => {
|
||||
expect(maskEmail('')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('truncateText', () => {
|
||||
it('should truncate long text', () => {
|
||||
const text = 'This is a very long text that should be truncated'
|
||||
expect(truncateText(text, 20)).toBe('This is a very long...')
|
||||
})
|
||||
|
||||
it('should not truncate short text', () => {
|
||||
const text = 'Short text'
|
||||
expect(truncateText(text, 20)).toBe('Short text')
|
||||
})
|
||||
|
||||
it('should use custom suffix', () => {
|
||||
const text = 'This is a very long text'
|
||||
expect(truncateText(text, 10, '---')).toBe('This is a---')
|
||||
})
|
||||
|
||||
it('should handle empty text', () => {
|
||||
expect(truncateText('', 10)).toBe('')
|
||||
})
|
||||
|
||||
it('should handle zero length', () => {
|
||||
expect(truncateText('Hello', 0)).toBe('...')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,220 +0,0 @@
|
||||
/**
|
||||
* 验证工具函数测试
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
validateEmail,
|
||||
validatePhone,
|
||||
validatePassword,
|
||||
validateUsername,
|
||||
validateUrl,
|
||||
validateIdCard,
|
||||
validateRequired,
|
||||
validateLength,
|
||||
validateNumber,
|
||||
validateInteger,
|
||||
validatePositive,
|
||||
validateRange
|
||||
} from '@/utils/validation'
|
||||
|
||||
describe('validation utils', () => {
|
||||
describe('validateEmail', () => {
|
||||
it('should validate correct email addresses', () => {
|
||||
expect(validateEmail('test@example.com')).toBe(true)
|
||||
expect(validateEmail('user.name@domain.co.uk')).toBe(true)
|
||||
expect(validateEmail('user+tag@example.org')).toBe(true)
|
||||
expect(validateEmail('123@456.com')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject invalid email addresses', () => {
|
||||
expect(validateEmail('invalid-email')).toBe(false)
|
||||
expect(validateEmail('test@')).toBe(false)
|
||||
expect(validateEmail('@example.com')).toBe(false)
|
||||
expect(validateEmail('test..test@example.com')).toBe(false)
|
||||
expect(validateEmail('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validatePhone', () => {
|
||||
it('should validate correct phone numbers', () => {
|
||||
expect(validatePhone('13812345678')).toBe(true)
|
||||
expect(validatePhone('15987654321')).toBe(true)
|
||||
expect(validatePhone('18612345678')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject invalid phone numbers', () => {
|
||||
expect(validatePhone('12345678901')).toBe(false) // 不是1开头
|
||||
expect(validatePhone('1381234567')).toBe(false) // 长度不够
|
||||
expect(validatePhone('138123456789')).toBe(false) // 长度过长
|
||||
expect(validatePhone('13a12345678')).toBe(false) // 包含字母
|
||||
expect(validatePhone('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validatePassword', () => {
|
||||
it('should validate correct passwords', () => {
|
||||
expect(validatePassword('abc123')).toBe(true)
|
||||
expect(validatePassword('Password1')).toBe(true)
|
||||
expect(validatePassword('test123456')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject invalid passwords', () => {
|
||||
expect(validatePassword('12345')).toBe(false) // 长度不够
|
||||
expect(validatePassword('abcdef')).toBe(false) // 只有字母
|
||||
expect(validatePassword('123456')).toBe(false) // 只有数字
|
||||
expect(validatePassword('')).toBe(false)
|
||||
expect(validatePassword('a'.repeat(21))).toBe(false) // 长度过长
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateUsername', () => {
|
||||
it('should validate correct usernames', () => {
|
||||
expect(validateUsername('user123')).toBe(true)
|
||||
expect(validateUsername('test_user')).toBe(true)
|
||||
expect(validateUsername('用户名')).toBe(true)
|
||||
expect(validateUsername('user_123')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject invalid usernames', () => {
|
||||
expect(validateUsername('ab')).toBe(false) // 长度不够
|
||||
expect(validateUsername('user-name')).toBe(false) // 包含连字符
|
||||
expect(validateUsername('user@name')).toBe(false) // 包含特殊字符
|
||||
expect(validateUsername('')).toBe(false)
|
||||
expect(validateUsername('a'.repeat(21))).toBe(false) // 长度过长
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateUrl', () => {
|
||||
it('should validate correct URLs', () => {
|
||||
expect(validateUrl('https://example.com')).toBe(true)
|
||||
expect(validateUrl('http://test.org')).toBe(true)
|
||||
expect(validateUrl('https://sub.domain.com/path?query=1')).toBe(true)
|
||||
expect(validateUrl('ftp://files.example.com')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject invalid URLs', () => {
|
||||
expect(validateUrl('not-a-url')).toBe(false)
|
||||
expect(validateUrl('example.com')).toBe(false) // 缺少协议
|
||||
expect(validateUrl('http://')).toBe(false)
|
||||
expect(validateUrl('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateIdCard', () => {
|
||||
it('should validate correct ID card numbers', () => {
|
||||
expect(validateIdCard('110101199003077777')).toBe(true)
|
||||
expect(validateIdCard('11010119900307777X')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject invalid ID card numbers', () => {
|
||||
expect(validateIdCard('12345678901234567')).toBe(false) // 长度不够
|
||||
expect(validateIdCard('1234567890123456789')).toBe(false) // 长度过长
|
||||
expect(validateIdCard('11010119900307777Y')).toBe(false) // 最后一位不是X
|
||||
expect(validateIdCard('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateRequired', () => {
|
||||
it('should validate required values', () => {
|
||||
expect(validateRequired('test')).toBe(true)
|
||||
expect(validateRequired(123)).toBe(true)
|
||||
expect(validateRequired(0)).toBe(true)
|
||||
expect(validateRequired(false)).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject empty values', () => {
|
||||
expect(validateRequired('')).toBe(false)
|
||||
expect(validateRequired(' ')).toBe(false) // 只有空格
|
||||
expect(validateRequired(null)).toBe(false)
|
||||
expect(validateRequired(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateLength', () => {
|
||||
it('should validate correct length', () => {
|
||||
expect(validateLength('test', 3, 5)).toBe(true)
|
||||
expect(validateLength('hello', 5, 10)).toBe(true)
|
||||
expect(validateLength('ab', 1, 3)).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject incorrect length', () => {
|
||||
expect(validateLength('ab', 3, 5)).toBe(false) // 太短
|
||||
expect(validateLength('toolong', 3, 5)).toBe(false) // 太长
|
||||
expect(validateLength('', 1, 5)).toBe(false) // 空字符串
|
||||
})
|
||||
|
||||
it('should handle edge cases', () => {
|
||||
expect(validateLength('test', 4, 4)).toBe(true) // 正好等于边界
|
||||
expect(validateLength('test', 0, 10)).toBe(true) // 最小长度为0
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateNumber', () => {
|
||||
it('should validate numbers', () => {
|
||||
expect(validateNumber(123)).toBe(true)
|
||||
expect(validateNumber(0)).toBe(true)
|
||||
expect(validateNumber(-456)).toBe(true)
|
||||
expect(validateNumber(3.14)).toBe(true)
|
||||
expect(validateNumber('123')).toBe(true)
|
||||
expect(validateNumber('3.14')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject non-numbers', () => {
|
||||
expect(validateNumber('abc')).toBe(false)
|
||||
expect(validateNumber('12abc')).toBe(false)
|
||||
expect(validateNumber('')).toBe(false)
|
||||
expect(validateNumber(null)).toBe(false)
|
||||
expect(validateNumber(undefined)).toBe(false)
|
||||
expect(validateNumber(NaN)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateInteger', () => {
|
||||
it('should validate integers', () => {
|
||||
expect(validateInteger(123)).toBe(true)
|
||||
expect(validateInteger(0)).toBe(true)
|
||||
expect(validateInteger(-456)).toBe(true)
|
||||
expect(validateInteger('123')).toBe(true)
|
||||
expect(validateInteger('-456')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject non-integers', () => {
|
||||
expect(validateInteger(3.14)).toBe(false)
|
||||
expect(validateInteger('3.14')).toBe(false)
|
||||
expect(validateInteger('abc')).toBe(false)
|
||||
expect(validateInteger('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validatePositive', () => {
|
||||
it('should validate positive numbers', () => {
|
||||
expect(validatePositive(123)).toBe(true)
|
||||
expect(validatePositive(0.1)).toBe(true)
|
||||
expect(validatePositive('123')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject non-positive numbers', () => {
|
||||
expect(validatePositive(0)).toBe(false)
|
||||
expect(validatePositive(-123)).toBe(false)
|
||||
expect(validatePositive('-123')).toBe(false)
|
||||
expect(validatePositive('abc')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateRange', () => {
|
||||
it('should validate numbers in range', () => {
|
||||
expect(validateRange(5, 1, 10)).toBe(true)
|
||||
expect(validateRange(1, 1, 10)).toBe(true) // 边界值
|
||||
expect(validateRange(10, 1, 10)).toBe(true) // 边界值
|
||||
expect(validateRange('5', 1, 10)).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject numbers out of range', () => {
|
||||
expect(validateRange(0, 1, 10)).toBe(false)
|
||||
expect(validateRange(11, 1, 10)).toBe(false)
|
||||
expect(validateRange(-5, 1, 10)).toBe(false)
|
||||
expect(validateRange('abc', 1, 10)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,57 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path mapping */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"~/*": ["src/*"]
|
||||
},
|
||||
|
||||
/* Additional options */
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": [
|
||||
"node",
|
||||
"vite/client",
|
||||
"element-plus/global",
|
||||
"unplugin-auto-import/client"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.vue",
|
||||
"src/types/global.d.ts",
|
||||
"auto-imports.d.ts",
|
||||
"components.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"playwright.config.*"
|
||||
]
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
import viteCompression from 'vite-plugin-compression'
|
||||
import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({ mode, command }) => {
|
||||
const isProduction = mode === 'production'
|
||||
const isAnalyze = mode === 'analyze'
|
||||
|
||||
return {
|
||||
base: isProduction ? '/emotion-museum/' : '/',
|
||||
plugins: [
|
||||
vue(),
|
||||
|
||||
// 自动导入
|
||||
AutoImport({
|
||||
imports: [
|
||||
'vue',
|
||||
'vue-router',
|
||||
'pinia',
|
||||
'@vueuse/core',
|
||||
{
|
||||
'vue-i18n': ['useI18n']
|
||||
}
|
||||
],
|
||||
resolvers: [ElementPlusResolver()],
|
||||
dts: true,
|
||||
eslintrc: {
|
||||
enabled: true
|
||||
}
|
||||
}),
|
||||
|
||||
// 组件自动导入
|
||||
Components({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
dts: true
|
||||
}),
|
||||
|
||||
// PWA支持
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon.ico', 'apple-touch-icon.png'],
|
||||
manifest: {
|
||||
name: '情绪博物馆',
|
||||
short_name: '情绪博物馆',
|
||||
description: '记录情绪,分享心情的温暖空间',
|
||||
theme_color: '#4A90E2',
|
||||
background_color: '#ffffff',
|
||||
display: 'standalone',
|
||||
icons: [
|
||||
{
|
||||
src: 'pwa-192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: 'pwa-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png'
|
||||
}
|
||||
]
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg}']
|
||||
}
|
||||
}),
|
||||
|
||||
// 国际化支持
|
||||
VueI18nPlugin({
|
||||
include: resolve(__dirname, './src/i18n/locales/**')
|
||||
}),
|
||||
|
||||
// Gzip压缩(仅生产环境)
|
||||
isProduction && viteCompression({
|
||||
verbose: true,
|
||||
disable: false,
|
||||
threshold: 10240,
|
||||
algorithm: 'gzip',
|
||||
ext: '.gz'
|
||||
}),
|
||||
|
||||
// 构建分析(仅分析模式)
|
||||
isAnalyze && visualizer({
|
||||
filename: 'dist/stats.html',
|
||||
open: true,
|
||||
gzipSize: true,
|
||||
brotliSize: true
|
||||
})
|
||||
].filter(Boolean),
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
'~': resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
|
||||
define: {
|
||||
global: 'globalThis',
|
||||
__VUE_I18N_FULL_INSTALL__: true,
|
||||
__VUE_I18N_LEGACY_API__: false,
|
||||
__INTLIFY_PROD_DEVTOOLS__: false
|
||||
},
|
||||
|
||||
server: {
|
||||
port: 5173,
|
||||
open: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:19089',
|
||||
changeOrigin: true,
|
||||
secure: false
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:19089',
|
||||
ws: true,
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: !isProduction,
|
||||
minify: isProduction ? 'terser' : false,
|
||||
terserOptions: isProduction ? {
|
||||
compress: {
|
||||
drop_console: true,
|
||||
drop_debugger: true,
|
||||
pure_funcs: ['console.log', 'console.info', 'console.debug']
|
||||
},
|
||||
mangle: {
|
||||
safari10: true
|
||||
}
|
||||
} : {},
|
||||
rollupOptions: {
|
||||
external: (id) => {
|
||||
if (id.includes('echarts') && id.includes('extension')) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
output: {
|
||||
chunkFileNames: 'js/[name]-[hash].js',
|
||||
entryFileNames: 'js/[name]-[hash].js',
|
||||
assetFileNames: (assetInfo) => {
|
||||
const fileName = assetInfo.name || 'asset'
|
||||
const info = fileName.split('.')
|
||||
const ext = info[info.length - 1]
|
||||
if (/\.(mp4|webm|ogg|mp3|wav|flac|aac)$/.test(fileName)) {
|
||||
return `media/[name]-[hash].${ext}`
|
||||
}
|
||||
if (/\.(png|jpe?g|gif|svg|webp|ico)$/.test(fileName)) {
|
||||
return `images/[name]-[hash].${ext}`
|
||||
}
|
||||
if (/\.(woff2?|eot|ttf|otf)$/.test(fileName)) {
|
||||
return `fonts/[name]-[hash].${ext}`
|
||||
}
|
||||
return `assets/[name]-[hash].${ext}`
|
||||
},
|
||||
manualChunks: (id) => {
|
||||
// 第三方库分包
|
||||
if (id.includes('node_modules')) {
|
||||
if (id.includes('vue') || id.includes('pinia') || id.includes('vue-router')) {
|
||||
return 'vue-vendor'
|
||||
}
|
||||
if (id.includes('element-plus')) {
|
||||
return 'element-plus'
|
||||
}
|
||||
if (id.includes('echarts')) {
|
||||
return 'echarts'
|
||||
}
|
||||
if (id.includes('lodash') || id.includes('dayjs') || id.includes('axios')) {
|
||||
return 'utils'
|
||||
}
|
||||
return 'vendor'
|
||||
}
|
||||
|
||||
// 业务模块分包
|
||||
if (id.includes('/src/views/')) {
|
||||
const pathSegments = id.split('/src/views/')[1].split('/')
|
||||
if (pathSegments.length > 1) {
|
||||
return `pages-${pathSegments[0]}`
|
||||
}
|
||||
}
|
||||
|
||||
if (id.includes('/src/components/')) {
|
||||
return 'components'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 构建性能优化
|
||||
chunkSizeWarningLimit: 1000,
|
||||
reportCompressedSize: !isProduction,
|
||||
|
||||
// 目标浏览器
|
||||
target: ['es2015', 'chrome63', 'firefox67', 'safari12']
|
||||
},
|
||||
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
'vue',
|
||||
'vue-router',
|
||||
'pinia',
|
||||
'element-plus',
|
||||
'echarts',
|
||||
'vue-echarts',
|
||||
'@vueuse/core',
|
||||
'dayjs',
|
||||
'lodash-es'
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,76 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
// 测试环境
|
||||
environment: 'jsdom',
|
||||
|
||||
// 全局测试设置
|
||||
globals: true,
|
||||
|
||||
// 测试文件匹配模式
|
||||
include: [
|
||||
'src/**/*.{test,spec}.{js,ts,vue}',
|
||||
'tests/unit/**/*.{test,spec}.{js,ts,vue}'
|
||||
],
|
||||
|
||||
// 排除文件
|
||||
exclude: [
|
||||
'node_modules',
|
||||
'dist',
|
||||
'cypress',
|
||||
'tests/e2e'
|
||||
],
|
||||
|
||||
// 覆盖率配置
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
reportsDirectory: './coverage',
|
||||
exclude: [
|
||||
'node_modules/',
|
||||
'src/main.ts',
|
||||
'src/vite-env.d.ts',
|
||||
'**/*.d.ts',
|
||||
'tests/',
|
||||
'coverage/',
|
||||
'dist/',
|
||||
'**/*.config.{js,ts}',
|
||||
'src/assets/',
|
||||
'src/styles/',
|
||||
'public/'
|
||||
],
|
||||
thresholds: {
|
||||
global: {
|
||||
branches: 70,
|
||||
functions: 70,
|
||||
lines: 70,
|
||||
statements: 70
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 设置文件
|
||||
setupFiles: ['./tests/setup.ts'],
|
||||
|
||||
// 测试超时时间
|
||||
testTimeout: 10000,
|
||||
|
||||
// 并发运行
|
||||
threads: true,
|
||||
|
||||
// 监听模式下的配置
|
||||
watch: {
|
||||
exclude: ['node_modules', 'dist']
|
||||
}
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
'~': resolve(__dirname, 'src')
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,131 +0,0 @@
|
||||
# 情绪博物馆Web端功能与页面梳理
|
||||
|
||||
## 1. 总览
|
||||
情绪博物馆Web端基于Vue3+TypeScript开发,主要功能包括AI对话、情绪日记、个人仪表盘、情绪分析、社交分享等。前端通过RESTful API与后端服务交互,部分功能(如AI对话)支持WebSocket实时通信。
|
||||
|
||||
## 2. 页面与功能列表
|
||||
|
||||
### 首页(/)
|
||||
- **功能描述**:产品介绍、引导注册/登录、快速入口。
|
||||
- **主要流程**:展示产品亮点,未登录用户可跳转登录/注册,已登录用户可直接进入AI对话。
|
||||
- **涉及接口**:无直接数据接口。
|
||||
|
||||
### 登录页(/login)
|
||||
- **功能描述**:用户登录,支持验证码、第三方登录。
|
||||
- **主要流程**:输入账号/密码/验证码,调用登录接口,登录成功后跳转首页或上次访问页。
|
||||
- **涉及接口**:
|
||||
- POST /auth/login 用户登录
|
||||
- GET /auth/captcha 获取验证码
|
||||
- POST /auth/oauth/login 第三方登录
|
||||
|
||||
### 注册页(/register)
|
||||
- **功能描述**:新用户注册。
|
||||
- **主要流程**:填写注册信息,获取验证码,注册成功自动登录。
|
||||
- **涉及接口**:
|
||||
- POST /auth/register 用户注册
|
||||
- GET /auth/captcha 获取验证码
|
||||
|
||||
### AI对话(/chat)
|
||||
- **功能描述**:与AI(开开)进行实时情绪对话。
|
||||
- **主要流程**:
|
||||
1. 进入页面自动建立WebSocket连接。
|
||||
2. 用户输入消息,前端通过WebSocket发送到后端。
|
||||
3. AI回复通过WebSocket推送到前端。
|
||||
- **涉及接口**:
|
||||
- WebSocket /ws/chat 实时对话
|
||||
- POST /conversation 创建会话
|
||||
- GET /conversation/user/{userId} 获取用户会话
|
||||
- DELETE /conversation/{sessionId} 删除会话
|
||||
|
||||
### 聊天历史(/chat-history)
|
||||
- **功能描述**:查看历史对话记录。
|
||||
- **主要流程**:分页加载历史会话和消息。
|
||||
- **涉及接口**:
|
||||
- GET /conversation/user/{userId} 获取用户会话
|
||||
- GET /message/user/page 分页获取消息
|
||||
|
||||
### 情绪日记(/diary)
|
||||
- **功能描述**:发布、查看个人情绪日记,AI自动点评。
|
||||
- **主要流程**:
|
||||
1. 用户输入日记内容,点击发布。
|
||||
2. 日记发布后自动刷新列表,AI生成点评。
|
||||
- **涉及接口**:
|
||||
- POST /diary-post/publish 发布日记
|
||||
- GET /diary-post/user/{userId}/page 获取用户日记
|
||||
|
||||
### 个人仪表盘(/personal-dashboard)
|
||||
- **功能描述**:展示用户基础信息、成长数据、兴趣、技能等。
|
||||
- **主要流程**:页面加载时获取用户信息和成长统计。
|
||||
- **涉及接口**:
|
||||
- GET /user/profile 获取用户资料
|
||||
- GET /user/growth-stats 获取成长数据
|
||||
|
||||
### 情绪分析(/analysis)
|
||||
- **功能描述**:情绪趋势、雷达图等可视化分析(开发中)。
|
||||
- **主要流程**:后续补充。
|
||||
- **涉及接口**:后续补充。
|
||||
|
||||
### 其他页面
|
||||
- **人生里程碑(/life-milestones)**:展示用户重要事件。
|
||||
- **人生轨迹(/life-trajectory)**:可视化用户成长轨迹。
|
||||
- **消息中心(/messages)**:系统与AI消息通知。
|
||||
- **设置(/settings)**:账号与隐私设置。
|
||||
- **话题追踪(/topic-tracker)**:追踪关注的话题。
|
||||
- **情绪管理(/emotion)**:情绪记录与管理。
|
||||
- **情绪地图(/map)**:情绪地理分布。
|
||||
- **社交分享(/social)**:分享内容到社交平台。
|
||||
- **个人中心(/profile)**:个人信息管理。
|
||||
- **调试/错误页面**:/debug, /404, /403等。
|
||||
|
||||
## 3. 主要流程说明
|
||||
|
||||
### 登录流程
|
||||
1. 用户输入账号、密码、验证码,点击登录。
|
||||
2. 前端调用POST /auth/login,成功后保存token,跳转首页。
|
||||
3. 登录后可获取当前用户信息(GET /auth/user/info)。
|
||||
|
||||
### AI对话流程
|
||||
1. 进入/chat页面,自动建立WebSocket连接。
|
||||
2. 用户输入消息,通过WebSocket发送到后端(/ws/chat,/app/chat.send)。
|
||||
3. AI回复通过WebSocket推送到前端。
|
||||
4. 会话和消息历史通过REST接口管理。
|
||||
|
||||
### 日记发布流程
|
||||
1. 用户输入日记内容,点击发布。
|
||||
2. 前端调用POST /diary-post/publish。
|
||||
3. 发布成功后刷新日记列表(GET /diary-post/user/{userId}/page)。
|
||||
4. AI自动生成点评并展示。
|
||||
|
||||
## 4. 附录:接口汇总表
|
||||
|
||||
#### 除了/auth的接口,其他所有接口都要在请求头中携带token调用
|
||||
|
||||
| 接口路径 | 方法 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| /auth/login | POST | 用户登录 |
|
||||
| /auth/register | POST | 用户注册 |
|
||||
| /auth/captcha | GET | 获取验证码 |
|
||||
| /auth/logout | POST | 用户登出 |
|
||||
| /auth/user/info | GET | 获取当前用户信息 |
|
||||
| /auth/refresh-token | POST | 刷新Token |
|
||||
| /conversation | POST | 创建会话 |
|
||||
| /conversation/user/{userId} | GET | 获取用户会话列表 |
|
||||
| /conversation/{sessionId} | DELETE | 删除会话 |
|
||||
| /message/user/page | GET | 分页获取用户消息 |
|
||||
| /message/user/search | POST | 搜索用户消息 |
|
||||
| /message/user/recent | POST | 获取最近消息 |
|
||||
| /message/{id} | GET | 获取消息详情 |
|
||||
| /diary-post/publish | POST | 发布日记 |
|
||||
| /diary-post/user/{userId}/page | GET | 获取用户日记 |
|
||||
| /user/profile | GET | 获取用户资料 |
|
||||
| /user/growth-stats | GET | 获取成长数据 |
|
||||
| /user/profile | PUT | 更新用户资料 |
|
||||
| /user/avatar/upload | POST | 上传头像 |
|
||||
| /user/password | PUT | 修改密码 |
|
||||
| /user/email/verify | POST | 验证邮箱 |
|
||||
| /user/email/send-code | POST | 发送邮箱验证码 |
|
||||
| /user/phone/verify | POST | 验证手机号 |
|
||||
| /user/phone/send-code | POST | 发送手机验证码 |
|
||||
| /ws/chat | WebSocket | AI对话实时通信 |
|
||||
|
||||
> 说明:部分页面如“人生里程碑”“情绪分析”等功能正在开发中,接口和流程会持续完善。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,517 +0,0 @@
|
||||
# 情绪博物馆前端环境配置梳理
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
情绪博物馆前端基于Vue3 + TypeScript + Vite开发,采用现代化的前端技术栈,支持多环境部署。
|
||||
|
||||
### 技术栈
|
||||
- **框架**: Vue 3.4.0
|
||||
- **构建工具**: Vite 5.0.8
|
||||
- **语言**: TypeScript 5.3.3
|
||||
- **UI框架**: Element Plus 2.4.4
|
||||
- **状态管理**: Pinia 2.1.7
|
||||
- **路由**: Vue Router 4.2.5
|
||||
- **样式**: Tailwind CSS 3.4.0
|
||||
- **HTTP客户端**: Axios 1.6.2
|
||||
- **WebSocket**: Socket.io-client 4.7.4, @stomp/stompjs 7.1.1
|
||||
- **图表**: ECharts 5.4.3
|
||||
- **工具库**: Day.js, Lodash-es, Zod
|
||||
|
||||
## 2. 环境配置文件
|
||||
|
||||
### 2.1 环境变量类型定义 (`src/types/env.d.ts`)
|
||||
|
||||
```typescript
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_ENV: string // 应用环境
|
||||
readonly VITE_APP_TITLE: string // 应用标题
|
||||
readonly VITE_APP_VERSION: string // 应用版本
|
||||
readonly VITE_API_BASE_URL: string // API基础URL
|
||||
readonly VITE_WS_BASE_URL: string // WebSocket基础URL
|
||||
readonly VITE_UPLOAD_URL: string // 文件上传URL
|
||||
readonly VITE_DEBUG: string // 调试模式
|
||||
readonly VITE_MOCK: string // Mock模式
|
||||
readonly VITE_APP_DESCRIPTION: string // 应用描述
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 环境配置管理 (`src/config/env.ts`)
|
||||
|
||||
环境配置支持四种环境:
|
||||
- **local**: 本地开发环境
|
||||
- **dev**: 开发环境
|
||||
- **test**: 测试环境
|
||||
- **prod**: 生产环境
|
||||
|
||||
#### 环境配置接口
|
||||
```typescript
|
||||
interface EnvConfig {
|
||||
name: string // 环境名称
|
||||
apiBaseUrl: string // API基础URL
|
||||
wsBaseUrl: string // WebSocket URL
|
||||
uploadUrl: string // 文件上传URL
|
||||
debug: boolean // 调试模式
|
||||
mock: boolean // Mock模式
|
||||
appTitle: string // 应用标题
|
||||
appVersion: string // 应用版本
|
||||
}
|
||||
```
|
||||
|
||||
#### 各环境配置详情
|
||||
|
||||
**本地环境 (local)**
|
||||
```typescript
|
||||
{
|
||||
name: '本地环境',
|
||||
apiBaseUrl: 'http://localhost:19089/api',
|
||||
wsBaseUrl: 'ws://localhost:19089/api',
|
||||
uploadUrl: 'http://localhost:19089/api/upload',
|
||||
debug: true,
|
||||
mock: false,
|
||||
appTitle: '情绪博物馆 - 本地',
|
||||
appVersion: '1.0.0'
|
||||
}
|
||||
```
|
||||
|
||||
**开发环境 (dev)**
|
||||
```typescript
|
||||
{
|
||||
name: '开发环境',
|
||||
apiBaseUrl: 'http://localhost:19089/api',
|
||||
wsBaseUrl: 'ws://localhost:19089/api',
|
||||
uploadUrl: 'http://localhost:19089/api/upload',
|
||||
debug: true,
|
||||
mock: false,
|
||||
appTitle: '情绪博物馆 - 开发',
|
||||
appVersion: '1.0.0'
|
||||
}
|
||||
```
|
||||
|
||||
**测试环境 (test)**
|
||||
```typescript
|
||||
{
|
||||
name: '测试环境',
|
||||
apiBaseUrl: 'http://test.emotion-museum.com/api',
|
||||
wsBaseUrl: 'ws://test.emotion-museum.com',
|
||||
uploadUrl: 'http://test.emotion-museum.com/api/upload',
|
||||
debug: false,
|
||||
mock: false,
|
||||
appTitle: '情绪博物馆 - 测试',
|
||||
appVersion: '1.0.0'
|
||||
}
|
||||
```
|
||||
|
||||
**生产环境 (prod)**
|
||||
```typescript
|
||||
{
|
||||
name: '生产环境',
|
||||
apiBaseUrl: 'https://api.emotion-museum.com/api',
|
||||
wsBaseUrl: 'wss://api.emotion-museum.com',
|
||||
uploadUrl: 'https://api.emotion-museum.com/api/upload',
|
||||
debug: false,
|
||||
mock: false,
|
||||
appTitle: '情绪博物馆',
|
||||
appVersion: '1.0.0'
|
||||
}
|
||||
```
|
||||
|
||||
## 3. 构建配置
|
||||
|
||||
### 3.1 Vite配置 (`vite.config.ts`)
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
base: '/emotion-museum/', // 部署基础路径
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'), // 路径别名
|
||||
},
|
||||
},
|
||||
define: {
|
||||
global: 'globalThis', // 全局变量定义
|
||||
},
|
||||
server: {
|
||||
port: 5173, // 开发服务器端口
|
||||
open: true, // 自动打开浏览器
|
||||
proxy: { // 代理配置
|
||||
'/api': {
|
||||
target: 'http://localhost:19089',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
}
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist', // 输出目录
|
||||
sourcemap: false, // 不生成sourcemap
|
||||
rollupOptions: {
|
||||
external: (id) => { // 外部依赖处理
|
||||
if (id.includes('echarts') && id.includes('extension')) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
output: {
|
||||
manualChunks: { // 代码分割
|
||||
vendor: ['vue', 'vue-router', 'pinia'],
|
||||
elementPlus: ['element-plus'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### 3.2 TypeScript配置 (`tsconfig.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020", // 目标版本
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler", // 模块解析策略
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true, // 严格模式
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": { // 路径映射
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.vue",
|
||||
"src/types/global.d.ts"
|
||||
],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Tailwind CSS配置 (`tailwind.config.js`)
|
||||
|
||||
```javascript
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: { // 主色调
|
||||
50: '#f0f9ff',
|
||||
100: '#e0f2fe',
|
||||
200: '#bae6fd',
|
||||
300: '#7dd3fc',
|
||||
400: '#38bdf8',
|
||||
500: '#0ea5e9',
|
||||
600: '#0284c7',
|
||||
700: '#0369a1',
|
||||
800: '#075985',
|
||||
900: '#0c4a6e',
|
||||
},
|
||||
emotion: { // 情绪色彩
|
||||
happy: '#fbbf24',
|
||||
sad: '#3b82f6',
|
||||
angry: '#ef4444',
|
||||
calm: '#10b981',
|
||||
excited: '#f97316',
|
||||
anxious: '#8b5cf6',
|
||||
},
|
||||
// 设计系统颜色
|
||||
'tech-blue': '#4A90E2',
|
||||
'warm-orange': '#F5A623',
|
||||
'light-gray': '#F7F8FA',
|
||||
'text-dark': '#333333',
|
||||
'text-medium': '#888888',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Noto Sans SC', 'Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
animation: { // 自定义动画
|
||||
'fade-in': 'fadeIn 0.5s ease-in-out',
|
||||
'slide-up': 'slideUp 0.3s ease-out',
|
||||
'bounce-gentle': 'bounceGentle 2s infinite',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { transform: 'translateY(20px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
bounceGentle: {
|
||||
'0%, 100%': { transform: 'translateY(0)' },
|
||||
'50%': { transform: 'translateY(-5px)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 PostCSS配置 (`postcss.config.js`)
|
||||
|
||||
```javascript
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {}, // Tailwind CSS
|
||||
autoprefixer: {}, // 自动添加CSS前缀
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 代码规范配置
|
||||
|
||||
### 4.1 ESLint配置 (`.eslintrc.cjs`)
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: [
|
||||
'plugin:vue/vue3-essential', // Vue3基础规则
|
||||
'eslint:recommended', // ESLint推荐规则
|
||||
'@vue/eslint-config-typescript', // TypeScript规则
|
||||
'@vue/eslint-config-prettier/skip-formatting' // Prettier集成
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest'
|
||||
},
|
||||
rules: {
|
||||
'vue/multi-word-component-names': 'off', // 允许单词组件名
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], // 忽略下划线参数
|
||||
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', // 生产环境警告console
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off' // 生产环境警告debugger
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Prettier配置 (`.prettierrc`)
|
||||
|
||||
```json
|
||||
{
|
||||
"semi": false, // 不使用分号
|
||||
"singleQuote": true, // 使用单引号
|
||||
"tabWidth": 2, // 缩进2个空格
|
||||
"trailingComma": "es5", // ES5兼容的尾随逗号
|
||||
"printWidth": 100, // 行宽100字符
|
||||
"bracketSpacing": true, // 对象字面量括号内空格
|
||||
"arrowParens": "avoid" // 箭头函数参数避免括号
|
||||
}
|
||||
```
|
||||
|
||||
## 5. HTTP请求配置
|
||||
|
||||
### 5.1 请求工具配置 (`src/utils/request.ts`)
|
||||
|
||||
#### 基础配置
|
||||
```typescript
|
||||
const instance = axios.create({
|
||||
baseURL: envConfig.apiBaseUrl, // 从环境配置获取基础URL
|
||||
timeout: 30000, // 30秒超时
|
||||
headers: {
|
||||
'Content-Type': 'application/json;charset=UTF-8'
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
#### 请求拦截器
|
||||
- 自动添加Authorization头(Bearer Token)
|
||||
- 生成请求ID用于追踪
|
||||
- 调试模式下打印请求日志
|
||||
|
||||
#### 响应拦截器
|
||||
- 统一处理业务状态码
|
||||
- 特殊错误码处理(401、403、404、500等)
|
||||
- 自动处理未授权情况
|
||||
- 调试模式下打印响应日志
|
||||
|
||||
#### 错误处理
|
||||
- 网络错误处理
|
||||
- 服务器错误处理
|
||||
- 业务错误处理
|
||||
- 401未授权自动跳转登录
|
||||
|
||||
## 6. 部署配置
|
||||
|
||||
### 6.1 构建脚本 (`package.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"dev": "vite", // 开发环境
|
||||
"build": "vite build", // 生产构建
|
||||
"build:check": "vue-tsc && vite build", // 类型检查+构建
|
||||
"preview": "vite preview", // 预览构建结果
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
||||
"type-check": "vue-tsc --noEmit", // TypeScript类型检查
|
||||
"test": "vitest", // 单元测试
|
||||
"test:e2e": "cypress run" // E2E测试
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 Linux部署脚本 (`deploy.sh`)
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# 部署脚本 - 将构建好的文件上传到服务器
|
||||
|
||||
SERVER_IP="47.111.10.27"
|
||||
USERNAME="root"
|
||||
REMOTE_PATH="/data/www/emotion-museum"
|
||||
|
||||
# 检查dist目录是否存在
|
||||
if [ ! -d "dist" ]; then
|
||||
echo "错误: dist目录不存在,请先运行 npm run build"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 上传文件到服务器
|
||||
scp dist/index.html "${USERNAME}@${SERVER_IP}:${REMOTE_PATH}/"
|
||||
scp -r dist/assets "${USERNAME}@${SERVER_IP}:${REMOTE_PATH}/"
|
||||
scp dist/test-*.html "${USERNAME}@${SERVER_IP}:${REMOTE_PATH}/"
|
||||
```
|
||||
|
||||
### 6.3 Windows部署脚本 (`deploy.ps1`)
|
||||
|
||||
```powershell
|
||||
# 部署脚本 - PowerShell版本
|
||||
|
||||
param(
|
||||
[string]$ServerIP = "47.111.10.27",
|
||||
[string]$Username = "root",
|
||||
[string]$RemotePath = "/data/www/emotion-museum"
|
||||
)
|
||||
|
||||
# 检查dist目录
|
||||
if (-not (Test-Path "dist")) {
|
||||
Write-Host "错误: dist目录不存在,请先运行 npm run build" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 上传文件到服务器
|
||||
scp "dist/index.html" "${Username}@${ServerIP}:${RemotePath}/"
|
||||
scp -r "dist/assets" "${Username}@${ServerIP}:${RemotePath}/"
|
||||
scp "dist/test-*.html" "${Username}@${ServerIP}:${RemotePath}/"
|
||||
```
|
||||
|
||||
## 7. 环境变量使用
|
||||
|
||||
### 7.1 环境变量获取方式
|
||||
|
||||
```typescript
|
||||
// 在组件中使用环境变量
|
||||
const apiUrl = import.meta.env.VITE_API_BASE_URL
|
||||
const isDebug = import.meta.env.VITE_DEBUG === 'true'
|
||||
const appTitle = import.meta.env.VITE_APP_TITLE
|
||||
```
|
||||
|
||||
### 7.2 环境变量设置
|
||||
|
||||
开发时可以通过以下方式设置环境变量:
|
||||
|
||||
**Linux/Mac:**
|
||||
```bash
|
||||
export VITE_APP_ENV=dev
|
||||
export VITE_API_BASE_URL=http://localhost:19089/api
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
```cmd
|
||||
set VITE_APP_ENV=dev
|
||||
set VITE_API_BASE_URL=http://localhost:19089/api
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**或者创建.env文件:**
|
||||
```env
|
||||
VITE_APP_ENV=dev
|
||||
VITE_API_BASE_URL=http://localhost:19089/api
|
||||
VITE_WS_BASE_URL=ws://localhost:19089/api
|
||||
VITE_DEBUG=true
|
||||
VITE_MOCK=false
|
||||
```
|
||||
|
||||
## 8. 开发环境配置
|
||||
|
||||
### 8.1 开发服务器配置
|
||||
- **端口**: 5173
|
||||
- **自动打开**: 是
|
||||
- **代理**: `/api` -> `http://localhost:19089`
|
||||
- **热更新**: 启用
|
||||
|
||||
### 8.2 调试配置
|
||||
- **Vue DevTools**: 支持
|
||||
- **TypeScript**: 严格模式
|
||||
- **ESLint**: 实时检查
|
||||
- **Prettier**: 自动格式化
|
||||
|
||||
### 8.3 构建优化
|
||||
- **代码分割**: 按模块分割
|
||||
- **Tree Shaking**: 自动移除未使用代码
|
||||
- **压缩**: 生产环境自动压缩
|
||||
- **缓存**: 文件名包含哈希值
|
||||
|
||||
## 9. 生产环境配置
|
||||
|
||||
### 9.1 构建优化
|
||||
- **Source Map**: 禁用
|
||||
- **压缩**: 启用
|
||||
- **代码分割**: 启用
|
||||
- **CDN**: 支持
|
||||
|
||||
### 9.2 部署路径
|
||||
- **基础路径**: `/emotion-museum/`
|
||||
- **静态资源**: 自动添加哈希值
|
||||
- **缓存策略**: 长期缓存静态资源
|
||||
|
||||
### 9.3 性能优化
|
||||
- **懒加载**: 路由级别懒加载
|
||||
- **预加载**: 关键资源预加载
|
||||
- **压缩**: Gzip/Brotli压缩
|
||||
- **缓存**: 浏览器缓存优化
|
||||
|
||||
## 10. 注意事项
|
||||
|
||||
1. **环境变量**: 必须以`VITE_`开头才能在客户端使用
|
||||
2. **API代理**: 开发环境使用代理,生产环境使用真实域名
|
||||
3. **WebSocket**: 开发环境使用ws协议,生产环境使用wss协议
|
||||
4. **构建路径**: 确保部署路径与`vite.config.ts`中的`base`配置一致
|
||||
5. **类型检查**: 构建前建议运行`npm run type-check`确保类型安全
|
||||
6. **代码规范**: 提交前运行`npm run lint`确保代码质量
|
||||
|
||||
## 11. 故障排除
|
||||
|
||||
### 常见问题
|
||||
1. **构建失败**: 检查TypeScript类型错误
|
||||
2. **代理不生效**: 检查vite.config.ts中的proxy配置
|
||||
3. **环境变量未生效**: 确保变量名以`VITE_`开头
|
||||
4. **部署404**: 检查nginx配置和base路径设置
|
||||
5. **WebSocket连接失败**: 检查协议和端口配置
|
||||
|
||||
### 调试技巧
|
||||
1. 使用浏览器开发者工具查看网络请求
|
||||
2. 检查控制台错误信息
|
||||
3. 使用Vue DevTools调试组件状态
|
||||
4. 查看构建日志定位问题
|
||||
-711
@@ -1,711 +0,0 @@
|
||||
# 情绪博物馆Web端重构计划
|
||||
|
||||
## 1. 重构概述
|
||||
|
||||
### 1.1 重构目标
|
||||
基于前端技术方案Augment.md,使用最新的Vue3+TypeScript技术栈重构情绪博物馆Web端,在保持所有现有功能、页面布局、样式和用户体验完全一致的前提下,提升代码质量、性能和可维护性。
|
||||
|
||||
### 1.2 重构原则
|
||||
- **功能一致性**:确保所有现有功能完全保留
|
||||
- **视觉一致性**:保持所有页面布局、样式、图片完全一致
|
||||
- **用户体验一致性**:保持所有交互流程和用户体验不变
|
||||
- **数据一致性**:保持所有API接口和数据流不变
|
||||
- **渐进式重构**:分阶段进行,确保每个阶段都可独立测试
|
||||
|
||||
### 1.3 技术栈升级
|
||||
- **Vue**: 2.x → 3.4.21 (最新稳定版)
|
||||
- **TypeScript**: 无 → 5.4.2 (最新稳定版)
|
||||
- **构建工具**: Vite 5.1.6 (更好的构建性能)
|
||||
- **UI框架**: Element Plus 2.6.1 (更好的Vue3支持)
|
||||
- **样式框架**: Tailwind CSS 3.4.1 + @tailwindcss/forms + @tailwindcss/typography
|
||||
- **状态管理**: Pinia 2.1.7 (Vue3官方推荐)
|
||||
- **路由**: Vue Router 4.3.0 (最新稳定版)
|
||||
- **HTTP客户端**: Axios 1.6.8 (最新稳定版)
|
||||
- **WebSocket**: @stomp/stompjs 7.1.1 (原生WebSocket,支持Token认证)
|
||||
- **数据可视化**: ECharts 5.5.0 + vue-echarts 6.7.3
|
||||
- **工具库**: VueUse 10.9.0 + Day.js 1.11.10 + Lodash-es 4.17.21
|
||||
|
||||
## 2. 重构阶段规划
|
||||
|
||||
### 第一阶段:项目初始化与基础架构 (1-2周)
|
||||
|
||||
#### 2.1 项目初始化
|
||||
- [ ] 创建新的Vue3+TypeScript项目
|
||||
- [ ] 配置Vite 5.1.6构建工具
|
||||
- [ ] 安装和配置核心依赖包
|
||||
- [ ] 设置TypeScript 5.4.2配置
|
||||
- [ ] 配置ESLint 8.57.0和Prettier 3.2.5
|
||||
- [ ] 配置unplugin-auto-import自动导入
|
||||
- [ ] 配置unplugin-vue-components组件自动导入
|
||||
- [ ] 设置Git仓库和分支策略
|
||||
- [ ] 配置Husky和lint-staged
|
||||
|
||||
#### 2.2 基础架构搭建
|
||||
- [ ] 配置路由系统 (Vue Router 4.3.0)
|
||||
- [ ] 配置状态管理 (Pinia 2.1.7)
|
||||
- [ ] 配置HTTP客户端 (Axios 1.6.8)
|
||||
- [ ] 配置WebSocket服务 (@stomp/stompjs 7.1.1,原生WebSocket)
|
||||
- [ ] 配置多环境变量管理 (local/dev/test/prod)
|
||||
- [ ] 配置Tailwind CSS 3.4.1样式系统
|
||||
- [ ] 配置Element Plus 2.6.1 UI组件库
|
||||
- [ ] 配置ECharts 5.5.0数据可视化
|
||||
- [ ] 配置VueUse 10.9.0工具库
|
||||
- [ ] 配置PWA支持 (vite-plugin-pwa)
|
||||
|
||||
#### 2.3 工具函数和类型定义
|
||||
- [ ] 创建HTTP请求工具 (utils/request.ts)
|
||||
- [ ] 创建WebSocket工具类 (utils/websocket.ts,支持Token认证)
|
||||
- [ ] 创建存储工具 (utils/storage.ts)
|
||||
- [ ] 创建格式化工具 (utils/format.ts)
|
||||
- [ ] 创建验证工具 (utils/validation.ts)
|
||||
- [ ] 定义API类型 (types/api.ts)
|
||||
- [ ] 定义用户类型 (types/user.ts)
|
||||
- [ ] 定义聊天类型 (types/chat.ts)
|
||||
- [ ] 定义日记类型 (types/diary.ts)
|
||||
- [ ] 定义全局类型 (types/global.d.ts)
|
||||
- [ ] 创建API接口定义 (api/auth.ts, api/chat.ts, api/diary.ts, api/user.ts)
|
||||
- [ ] 创建组合式API (composables/useAuth.ts, useChat.ts, useWebSocket.ts)
|
||||
- [ ] 配置全局组件注册
|
||||
|
||||
### 第二阶段:核心页面重构 (3-4周)
|
||||
|
||||
#### 2.4 认证页面重构
|
||||
- [ ] **登录页面** (/login)
|
||||
- 实现POST /auth/login用户登录接口
|
||||
- 实现GET /auth/captcha获取验证码接口
|
||||
- 实现POST /auth/oauth/login第三方登录接口
|
||||
- 保持现有UI布局和样式完全一致
|
||||
- 保持验证码功能和第三方登录功能
|
||||
- 保持错误提示和交互逻辑
|
||||
- 升级到Vue3 Composition API + TypeScript
|
||||
- 使用Pinia管理认证状态
|
||||
|
||||
- [ ] **注册页面** (/register)
|
||||
- 实现POST /auth/register用户注册接口
|
||||
- 实现GET /auth/captcha获取验证码接口
|
||||
- 保持现有UI布局和样式完全一致
|
||||
- 保持表单验证逻辑和验证码功能
|
||||
- 保持注册成功自动登录流程
|
||||
- 使用@vuelidate/core进行表单验证
|
||||
|
||||
- [ ] **认证相关组件和状态管理**
|
||||
- UserDropdown组件 (Element Plus重构)
|
||||
- UserAvatar组件 (支持头像上传)
|
||||
- 认证状态管理 (stores/auth.ts)
|
||||
- Token自动刷新机制
|
||||
- 路由守卫实现
|
||||
|
||||
#### 2.5 首页重构
|
||||
- [ ] **首页** (/)
|
||||
- 保持现有UI布局和样式完全一致
|
||||
- 保持导航栏样式和交互
|
||||
- 保持产品介绍内容
|
||||
- 保持响应式设计
|
||||
- 保持动画效果
|
||||
|
||||
#### 2.6 聊天功能重构
|
||||
- [ ] **AI对话页面** (/chat)
|
||||
- 实现WebSocket /ws/chat实时对话连接
|
||||
- 实现POST /conversation创建会话接口
|
||||
- 实现GET /conversation/user/{userId}获取用户会话接口
|
||||
- 实现DELETE /conversation/{sessionId}删除会话接口
|
||||
- 保持现有UI布局和样式完全一致
|
||||
- 保持消息气泡样式和输入框交互
|
||||
- 升级到原生WebSocket + STOMP (支持Token认证)
|
||||
- 实现自动重连、心跳检测机制
|
||||
- 使用composables/useChat.ts管理聊天逻辑
|
||||
- 支持文本、图片、表情、文件消息类型
|
||||
|
||||
- [ ] **聊天历史页面** (/chat-history)
|
||||
- 实现GET /conversation/user/{userId}获取用户会话接口
|
||||
- 实现GET /message/user/page分页获取消息接口
|
||||
- 实现POST /message/user/search搜索用户消息接口
|
||||
- 实现POST /message/user/recent获取最近消息接口
|
||||
- 保持现有UI布局和样式完全一致
|
||||
- 保持历史记录展示、搜索功能、分页功能
|
||||
- 使用虚拟滚动优化大量消息展示
|
||||
|
||||
- [ ] **聊天相关组件和状态管理**
|
||||
- ChatHistoryModal组件 (Element Plus重构)
|
||||
- MessageBubble消息气泡组件
|
||||
- ChatInput输入框组件 (支持多媒体)
|
||||
- 聊天状态管理 (stores/chat.ts)
|
||||
- WebSocket连接状态管理
|
||||
- 离线消息缓存和同步机制
|
||||
|
||||
### 第三阶段:功能页面重构 (3-4周)
|
||||
|
||||
#### 2.7 日记功能重构
|
||||
- [ ] **情绪日记页面** (/diary)
|
||||
- 实现POST /diary-post/publish发布日记接口
|
||||
- 实现GET /diary-post/user/{userId}/page获取用户日记接口
|
||||
- 保持现有UI布局和样式完全一致
|
||||
- 保持日记发布功能和AI自动点评功能
|
||||
- 保持日记列表展示和分页功能
|
||||
- 使用@tiptap/vue-3实现富文本编辑
|
||||
- 支持图片上传和表情插入
|
||||
- 实现日记草稿保存功能
|
||||
|
||||
#### 2.8 个人中心重构
|
||||
- [ ] **个人仪表盘** (/personal-dashboard)
|
||||
- 实现GET /user/profile获取用户资料接口
|
||||
- 实现GET /user/growth-stats获取成长数据接口
|
||||
- 保持现有UI布局和样式完全一致
|
||||
- 保持用户信息展示和成长数据展示
|
||||
- 使用ECharts 5.5.0重构统计图表
|
||||
- 实现情绪趋势图、雷达图等可视化
|
||||
- 支持数据导出功能 (PDF、Excel)
|
||||
|
||||
- [ ] **个人资料页面** (/profile)
|
||||
- 实现GET /user/profile获取用户资料接口
|
||||
- 实现PUT /user/profile更新用户资料接口
|
||||
- 实现POST /user/avatar/upload上传头像接口
|
||||
- 实现PUT /user/password修改密码接口
|
||||
- 实现邮箱和手机验证接口
|
||||
- 保持现有UI布局和样式完全一致
|
||||
- 保持信息编辑、头像上传、密码修改功能
|
||||
- 使用cropperjs实现头像裁剪功能
|
||||
- 使用@vuelidate/core进行表单验证
|
||||
|
||||
#### 2.9 分析功能重构
|
||||
- [ ] **情绪分析页面** (/analysis)
|
||||
- 实现情绪数据分析接口 (开发中)
|
||||
- 保持现有UI布局和样式完全一致
|
||||
- 使用ECharts实现情绪趋势图、雷达图
|
||||
- 使用@antv/g2实现高级数据可视化
|
||||
- 保持图表展示和交互功能
|
||||
- 支持时间范围筛选和数据导出
|
||||
|
||||
### 第四阶段:其他页面重构 (2-3周)
|
||||
|
||||
#### 2.10 扩展功能页面
|
||||
- [ ] **人生里程碑** (/life-milestones)
|
||||
- 展示用户重要事件和成就
|
||||
- 使用时间轴组件展示里程碑
|
||||
- 支持里程碑添加、编辑、删除
|
||||
|
||||
- [ ] **人生轨迹** (/life-trajectory)
|
||||
- 可视化用户成长轨迹
|
||||
- 使用交互式图表展示成长路径
|
||||
- 支持轨迹数据分析和导出
|
||||
|
||||
- [ ] **消息中心** (/messages)
|
||||
- 系统消息和AI消息通知
|
||||
- 消息分类和状态管理
|
||||
- 支持消息标记和批量操作
|
||||
|
||||
- [ ] **设置页面** (/settings)
|
||||
- 账号设置和隐私设置
|
||||
- 通知设置和主题设置
|
||||
- 数据导出和账号注销
|
||||
|
||||
#### 2.11 工具和辅助页面
|
||||
- [ ] **话题追踪** (/topic-tracker)
|
||||
- 追踪关注的话题和趋势
|
||||
- 话题订阅和推荐功能
|
||||
|
||||
- [ ] **情绪管理** (/emotion)
|
||||
- 情绪记录和管理工具
|
||||
- 情绪调节建议和技巧
|
||||
|
||||
- [ ] **情绪地图** (/map)
|
||||
- 情绪地理分布可视化
|
||||
- 基于地理位置的情绪分析
|
||||
|
||||
- [ ] **社交分享** (/social)
|
||||
- 分享内容到社交平台
|
||||
- 社交媒体集成和管理
|
||||
|
||||
#### 2.12 系统页面
|
||||
- [ ] **调试页面** (/debug)
|
||||
- 开发调试工具和信息
|
||||
- 系统状态监控
|
||||
|
||||
- [ ] **错误页面** (/404, /403)
|
||||
- 友好的错误提示页面
|
||||
- 错误日志收集和上报
|
||||
|
||||
### 第五阶段:优化与测试 (2-3周)
|
||||
|
||||
#### 2.13 性能优化
|
||||
- [ ] **代码分割优化**
|
||||
- 路由级别懒加载 (Vue Router动态导入)
|
||||
- 组件级别懒加载 (defineAsyncComponent)
|
||||
- 第三方库按需加载 (Tree-shaking)
|
||||
- 使用rollup-plugin-visualizer分析包体积
|
||||
|
||||
- [ ] **资源优化**
|
||||
- 图片懒加载 (Intersection Observer API)
|
||||
- 图片格式优化 (WebP、响应式图片)
|
||||
- 使用vite-plugin-compression启用Gzip压缩
|
||||
- CDN资源配置和缓存策略
|
||||
|
||||
- [ ] **运行时优化**
|
||||
- 虚拟滚动 (大量数据列表)
|
||||
- 防抖和节流优化
|
||||
- 内存泄漏检测和修复
|
||||
- WebSocket连接池优化
|
||||
|
||||
#### 2.14 测试与验证
|
||||
- [ ] **单元测试** (Vitest 1.4.0)
|
||||
- 组件测试 (@vue/test-utils 2.4.5)
|
||||
- 工具函数测试
|
||||
- API接口测试
|
||||
- 状态管理测试 (Pinia)
|
||||
- 目标覆盖率 > 80%
|
||||
|
||||
- [ ] **集成测试**
|
||||
- WebSocket连接测试
|
||||
- 认证流程测试
|
||||
- 文件上传测试
|
||||
- 数据可视化测试
|
||||
|
||||
- [ ] **E2E测试** (Cypress 13.7.1)
|
||||
- 用户注册登录流程
|
||||
- AI对话功能测试
|
||||
- 日记发布流程测试
|
||||
- 个人资料编辑测试
|
||||
|
||||
- [ ] **兼容性和性能测试**
|
||||
- 多浏览器兼容性测试
|
||||
- 移动端响应式测试
|
||||
- 性能指标监控 (Web Vitals)
|
||||
- 错误监控 (Sentry 7.108.0)
|
||||
|
||||
#### 2.15 文档和部署
|
||||
- [ ] **技术文档**
|
||||
- API接口文档
|
||||
- 组件使用文档
|
||||
- 开发规范文档
|
||||
- 故障排除文档
|
||||
|
||||
- [ ] **部署配置**
|
||||
- Docker容器化部署
|
||||
- CI/CD流程配置
|
||||
- 多环境部署脚本
|
||||
- 监控和日志配置
|
||||
|
||||
- [ ] **用户文档**
|
||||
- 功能使用手册
|
||||
- 常见问题解答
|
||||
- 更新日志维护
|
||||
|
||||
## 3. 详细重构指南
|
||||
|
||||
### 3.1 页面重构标准流程
|
||||
|
||||
#### 步骤1:分析现有页面
|
||||
1. **UI分析**:截图记录当前页面布局
|
||||
2. **功能分析**:梳理所有交互功能
|
||||
3. **API分析**:确认所有接口调用
|
||||
4. **样式分析**:提取所有CSS样式
|
||||
5. **图片资源**:收集所有图片和图标
|
||||
|
||||
#### 步骤2:创建新页面结构
|
||||
```typescript
|
||||
// pages/chat/index.vue
|
||||
<template>
|
||||
<!-- 保持现有模板结构 -->
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 使用Vue3 Composition API
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useChat } from '@/composables/useChat'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { ChatMessage } from '@/types/chat'
|
||||
|
||||
// 保持现有功能逻辑
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 保持现有样式 */
|
||||
</style>
|
||||
```
|
||||
|
||||
### 3.2 样式迁移策略
|
||||
|
||||
#### 3.2.1 Tailwind CSS迁移
|
||||
```css
|
||||
/* 原有CSS */
|
||||
.chat-message {
|
||||
background: #f0f0f0;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
/* 迁移到Tailwind */
|
||||
<div class="bg-gray-100 rounded-lg p-3 my-2">
|
||||
```
|
||||
|
||||
### 3.3 WebSocket重构
|
||||
|
||||
#### 3.3.1 原生WebSocket + STOMP实现
|
||||
```typescript
|
||||
// utils/websocket.ts
|
||||
import { Client } from '@stomp/stompjs'
|
||||
import { envConfig } from '@/config/env'
|
||||
|
||||
export class WebSocketService {
|
||||
private client: Client
|
||||
private connected = false
|
||||
private reconnectAttempts = 0
|
||||
private maxReconnectAttempts = 5
|
||||
private currentToken = ''
|
||||
private tokenExpiredCallback?: () => void
|
||||
|
||||
constructor(onTokenExpired?: () => void) {
|
||||
this.tokenExpiredCallback = onTokenExpired
|
||||
this.client = new Client({
|
||||
// 使用原生WebSocket,支持Token认证
|
||||
brokerURL: `${envConfig.wsBaseUrl}/ws`,
|
||||
|
||||
// 心跳检测
|
||||
heartbeatIncoming: 4000,
|
||||
heartbeatOutgoing: 4000,
|
||||
|
||||
// 重连配置
|
||||
reconnectDelay: 5000,
|
||||
|
||||
// 调试模式
|
||||
debug: envConfig.debug ? console.log : undefined,
|
||||
|
||||
onConnect: () => {
|
||||
this.connected = true
|
||||
this.reconnectAttempts = 0
|
||||
console.log('WebSocket连接成功')
|
||||
},
|
||||
|
||||
onDisconnect: () => {
|
||||
this.connected = false
|
||||
console.log('WebSocket连接断开')
|
||||
},
|
||||
|
||||
onStompError: (frame) => {
|
||||
console.error('STOMP错误:', frame)
|
||||
this.handleStompError(frame)
|
||||
},
|
||||
|
||||
// WebSocket连接前的配置
|
||||
beforeConnect: () => {
|
||||
if (this.currentToken) {
|
||||
this.client.configure({
|
||||
connectHeaders: {
|
||||
Authorization: `Bearer ${this.currentToken}`,
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 连接WebSocket
|
||||
connect(token: string) {
|
||||
this.currentToken = token
|
||||
this.client.configure({
|
||||
connectHeaders: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
this.client.activate()
|
||||
}
|
||||
|
||||
// 断开连接
|
||||
disconnect() {
|
||||
this.client.deactivate()
|
||||
}
|
||||
|
||||
// 更新Token(用于Token刷新场景)
|
||||
updateToken(newToken: string) {
|
||||
this.currentToken = newToken
|
||||
if (this.connected) {
|
||||
this.disconnect()
|
||||
setTimeout(() => {
|
||||
this.connect(newToken)
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
// 订阅消息
|
||||
subscribe(destination: string, callback: (message: any) => void) {
|
||||
if (!this.connected) {
|
||||
console.warn('WebSocket未连接')
|
||||
return
|
||||
}
|
||||
|
||||
return this.client.subscribe(destination, (message) => {
|
||||
try {
|
||||
const data = JSON.parse(message.body)
|
||||
callback(data)
|
||||
} catch (error) {
|
||||
console.error('消息解析失败:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
send(destination: string, body: any) {
|
||||
if (!this.connected) {
|
||||
console.warn('WebSocket未连接,消息将被缓存')
|
||||
return
|
||||
}
|
||||
|
||||
this.client.publish({
|
||||
destination,
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
}
|
||||
|
||||
// 处理STOMP错误
|
||||
private handleStompError(frame: any) {
|
||||
if (frame.headers && frame.headers.message) {
|
||||
const errorMessage = frame.headers.message.toLowerCase()
|
||||
|
||||
if (errorMessage.includes('unauthorized') ||
|
||||
errorMessage.includes('invalid token') ||
|
||||
errorMessage.includes('token expired')) {
|
||||
|
||||
console.warn('Token认证失败,触发重新登录')
|
||||
this.tokenExpiredCallback?.()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
this.handleReconnect()
|
||||
}
|
||||
|
||||
// 处理重连
|
||||
private handleReconnect() {
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.reconnectAttempts++
|
||||
setTimeout(() => {
|
||||
console.log(`尝试重连 (${this.reconnectAttempts}/${this.maxReconnectAttempts})`)
|
||||
this.client.activate()
|
||||
}, 5000 * this.reconnectAttempts)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.3.2 聊天组合式API
|
||||
```typescript
|
||||
// composables/useChat.ts
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { WebSocketService } from '@/utils/websocket'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { ChatMessage } from '@/types/chat'
|
||||
|
||||
export function useChat() {
|
||||
const authStore = useAuthStore()
|
||||
const wsService = new WebSocketService(() => {
|
||||
// Token过期回调
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
})
|
||||
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const isConnected = ref(false)
|
||||
|
||||
// 连接WebSocket
|
||||
const connect = () => {
|
||||
if (authStore.token) {
|
||||
wsService.connect(authStore.token)
|
||||
|
||||
// 订阅个人消息
|
||||
wsService.subscribe(`/user/${authStore.user.id}/queue/messages`, (message) => {
|
||||
messages.value.push(message)
|
||||
})
|
||||
|
||||
// 订阅聊天室消息
|
||||
wsService.subscribe('/topic/chat', (message) => {
|
||||
messages.value.push(message)
|
||||
})
|
||||
|
||||
isConnected.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
const sendMessage = (content: string, type: 'text' | 'image' = 'text') => {
|
||||
const message = {
|
||||
content,
|
||||
type,
|
||||
timestamp: Date.now(),
|
||||
userId: authStore.user.id
|
||||
}
|
||||
|
||||
wsService.send('/app/chat.send', message)
|
||||
}
|
||||
|
||||
// 组件挂载时连接
|
||||
onMounted(() => {
|
||||
connect()
|
||||
})
|
||||
|
||||
// 组件卸载时断开连接
|
||||
onUnmounted(() => {
|
||||
wsService.disconnect()
|
||||
})
|
||||
|
||||
return {
|
||||
messages,
|
||||
isConnected,
|
||||
sendMessage,
|
||||
connect
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 质量保证措施
|
||||
|
||||
### 4.1 功能一致性检查清单
|
||||
|
||||
#### 页面功能检查
|
||||
- [ ] 页面布局完全一致
|
||||
- [ ] 所有按钮和链接功能正常
|
||||
- [ ] 表单验证逻辑一致
|
||||
- [ ] 错误提示信息一致
|
||||
- [ ] 加载状态显示一致
|
||||
- [ ] 响应式设计一致
|
||||
|
||||
#### 交互功能检查
|
||||
- [ ] 点击事件响应一致
|
||||
- [ ] 键盘快捷键一致
|
||||
- [ ] 滚动行为一致
|
||||
- [ ] 动画效果一致
|
||||
- [ ] 拖拽功能一致
|
||||
|
||||
### 4.2 视觉一致性检查清单
|
||||
|
||||
#### 样式检查
|
||||
- [ ] 颜色方案完全一致
|
||||
- [ ] 字体大小和样式一致
|
||||
- [ ] 间距和布局一致
|
||||
- [ ] 边框和圆角一致
|
||||
- [ ] 阴影效果一致
|
||||
|
||||
#### 图片和图标检查
|
||||
- [ ] 所有图片显示正常
|
||||
- [ ] 图标样式一致
|
||||
- [ ] 图片尺寸一致
|
||||
- [ ] 图片加载状态一致
|
||||
|
||||
## 5. 测试策略
|
||||
|
||||
### 5.1 自动化测试
|
||||
|
||||
#### 单元测试
|
||||
```typescript
|
||||
// tests/components/ChatMessage.test.ts
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import ChatMessage from '@/components/ChatMessage.vue'
|
||||
|
||||
describe('ChatMessage', () => {
|
||||
it('renders message content correctly', () => {
|
||||
const message = {
|
||||
id: '1',
|
||||
content: 'Hello World',
|
||||
senderId: 'user1',
|
||||
senderType: 'USER' as const,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
const wrapper = mount(ChatMessage, {
|
||||
props: { message, isOwn: true }
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Hello World')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 5.2 手动测试清单
|
||||
|
||||
#### 功能测试
|
||||
- [ ] 用户注册流程
|
||||
- [ ] 用户登录流程
|
||||
- [ ] AI对话功能
|
||||
- [ ] 日记发布功能
|
||||
- [ ] 个人资料编辑
|
||||
- [ ] 设置页面功能
|
||||
|
||||
#### 兼容性测试
|
||||
- [ ] Chrome浏览器
|
||||
- [ ] Firefox浏览器
|
||||
- [ ] Safari浏览器
|
||||
- [ ] Edge浏览器
|
||||
- [ ] 移动端浏览器
|
||||
|
||||
## 6. 成功标准
|
||||
|
||||
### 6.1 功能标准
|
||||
- [ ] 所有现有功能100%保留
|
||||
- [ ] 所有页面UI完全一致
|
||||
- [ ] 所有交互流程正常
|
||||
- [ ] 所有API接口正常工作
|
||||
|
||||
### 6.2 性能标准
|
||||
- [ ] 首屏加载时间 < 2秒
|
||||
- [ ] 页面切换时间 < 500ms
|
||||
- [ ] 内存使用增长 < 50MB
|
||||
- [ ] 构建时间 < 3分钟
|
||||
|
||||
### 6.3 质量标准
|
||||
- [ ] 代码覆盖率 > 80%
|
||||
- [ ] 无严重bug
|
||||
- [ ] 通过所有测试用例
|
||||
- [ ] 符合代码规范
|
||||
|
||||
## 7. 总结
|
||||
|
||||
本重构计划确保在升级到最新技术栈的同时,完全保持现有的功能、UI和用户体验。通过分阶段的重构策略,可以降低风险并确保每个阶段的质量。重构完成后,项目将具备更好的性能、可维护性和扩展性,为未来的功能开发奠定坚实基础。
|
||||
|
||||
### 7.1 关键成功因素
|
||||
1. **严格的功能一致性检查**
|
||||
2. **详细的UI对比验证**
|
||||
3. **完善的测试覆盖**
|
||||
4. **渐进式的重构策略**
|
||||
5. **充分的团队协作**
|
||||
|
||||
### 7.2 预期收益
|
||||
|
||||
#### 技术层面收益
|
||||
1. **技术栈现代化**:Vue3.4.21 + TypeScript5.4.2 + Vite5.1.6
|
||||
2. **性能显著提升**:
|
||||
- 首屏加载时间减少40% (目标<2秒)
|
||||
- 运行时性能提升30% (Vue3 Proxy响应式)
|
||||
- 包体积减少25% (Tree-shaking + 代码分割)
|
||||
- WebSocket连接更稳定 (原生WebSocket + Token认证)
|
||||
|
||||
3. **开发体验提升**:
|
||||
- TypeScript类型安全,减少90%的类型错误
|
||||
- 自动导入和组件注册,提升开发效率50%
|
||||
- 热更新速度提升3倍 (Vite vs Webpack)
|
||||
- 更好的调试工具和错误提示
|
||||
|
||||
#### 业务层面收益
|
||||
4. **功能稳定性提升**:
|
||||
- WebSocket连接稳定性提升 (自动重连 + Token认证)
|
||||
- 错误监控和日志收集 (Sentry集成)
|
||||
- 完善的测试覆盖 (单元测试 + E2E测试)
|
||||
|
||||
5. **用户体验优化**:
|
||||
- 响应式设计优化,移动端体验提升
|
||||
- 数据可视化效果增强 (ECharts5.5.0)
|
||||
- PWA支持,离线访问能力
|
||||
- 国际化支持,多语言适配
|
||||
|
||||
6. **维护和扩展性**:
|
||||
- 组件化架构,代码复用率提升60%
|
||||
- 模块化设计,新功能开发效率提升40%
|
||||
- 完善的文档和规范,团队协作效率提升
|
||||
- CI/CD自动化,部署效率提升80%
|
||||
|
||||
#### 长期价值
|
||||
7. **技术债务清理**:清理历史技术债务,为未来发展奠定基础
|
||||
8. **团队技能提升**:掌握现代前端技术栈,提升团队竞争力
|
||||
9. **可持续发展**:基于最新技术栈,保证3-5年技术先进性
|
||||
@@ -1,52 +0,0 @@
|
||||
# 部署脚本 - 将构建好的文件上传到服务器
|
||||
# 使用方法: .\deploy.ps1
|
||||
|
||||
param(
|
||||
[string]$ServerIP = "47.111.10.27",
|
||||
[string]$Username = "root",
|
||||
[string]$RemotePath = "/data/www/emotion-museum"
|
||||
)
|
||||
|
||||
Write-Host "开始部署前端应用到服务器..." -ForegroundColor Green
|
||||
|
||||
# 检查dist目录是否存在
|
||||
if (-not (Test-Path "dist")) {
|
||||
Write-Host "错误: dist目录不存在,请先运行 npm run build" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 检查是否安装了scp命令(需要安装OpenSSH客户端)
|
||||
try {
|
||||
scp 2>&1 | Out-Null
|
||||
} catch {
|
||||
Write-Host "错误: 未找到scp命令,请安装OpenSSH客户端" -ForegroundColor Red
|
||||
Write-Host "可以通过以下方式安装:" -ForegroundColor Yellow
|
||||
Write-Host "1. Windows 10/11: 设置 -> 应用 -> 可选功能 -> 添加功能 -> OpenSSH客户端" -ForegroundColor Yellow
|
||||
Write-Host "2. 或者使用 WinSCP 等工具手动上传" -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "正在上传文件到服务器 $ServerIP..." -ForegroundColor Yellow
|
||||
|
||||
# 上传所有文件到服务器
|
||||
try {
|
||||
# 上传index.html
|
||||
scp "dist/index.html" "${Username}@${ServerIP}:${RemotePath}/"
|
||||
|
||||
# 上传assets目录
|
||||
scp -r "dist/assets" "${Username}@${ServerIP}:${RemotePath}/"
|
||||
|
||||
# 上传测试文件
|
||||
scp "dist/test-*.html" "${Username}@${ServerIP}:${RemotePath}/"
|
||||
|
||||
Write-Host "部署完成!" -ForegroundColor Green
|
||||
Write-Host "访问地址: http://$ServerIP/emotion-museum/" -ForegroundColor Cyan
|
||||
|
||||
} catch {
|
||||
Write-Host "部署失败: $($_.Exception.Message)" -ForegroundColor Red
|
||||
Write-Host "请检查:" -ForegroundColor Yellow
|
||||
Write-Host "1. 服务器IP地址是否正确" -ForegroundColor Yellow
|
||||
Write-Host "2. SSH密钥是否配置正确" -ForegroundColor Yellow
|
||||
Write-Host "3. 服务器目录权限是否正确" -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
@@ -189,6 +189,21 @@ const routes: RouteRecordRaw[] = [
|
||||
hidden: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/forgot-password',
|
||||
name: 'ForgotPassword',
|
||||
component: () => import('@/views/ForgotPassword/index.vue'),
|
||||
meta: {
|
||||
title: '重置密码',
|
||||
requiresAuth: false,
|
||||
hidden: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/reset-password',
|
||||
redirect: '/forgot-password',
|
||||
meta: { hidden: true }
|
||||
},
|
||||
// 调试页面(仅开发环境)
|
||||
{
|
||||
path: '/debug',
|
||||
|
||||
+26
-13
@@ -44,13 +44,22 @@ export class AuthService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取验证码
|
||||
* 获取图形验证码(备用)
|
||||
*/
|
||||
static async getCaptcha(): Promise<CaptchaResponse> {
|
||||
const response = await http.get<CaptchaResponse>('/auth/captcha')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送短信验证码(用于登录/注册/找回密码等场景)
|
||||
*/
|
||||
static async sendSmsCode(phone: string) {
|
||||
// backend-single: GET /auth/sms-code?phone=xxx
|
||||
const response = await http.get('/auth/sms-code', { params: { phone } })
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登出
|
||||
*/
|
||||
@@ -71,7 +80,8 @@ export class AuthService {
|
||||
* 获取当前用户信息
|
||||
*/
|
||||
static async getCurrentUserInfo(): Promise<UserInfo> {
|
||||
const response = await http.get<UserInfo>('/auth/user/info')
|
||||
// backend-single: GET /auth/userInfo
|
||||
const response = await http.get<UserInfo>('/auth/userInfo')
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -94,21 +104,24 @@ export class AuthService {
|
||||
* 重置密码
|
||||
*/
|
||||
static async resetPassword(data: ResetPasswordRequest): Promise<void> {
|
||||
return http.post<void>('/auth/reset-password', data)
|
||||
return http.post<void>('/auth/resetPassword', data)
|
||||
}
|
||||
|
||||
// 废弃:后端未提供该接口,保留新 sendSmsCode()
|
||||
/**
|
||||
* 发送验证码(已废弃:统一使用 sendSmsCode(phone))
|
||||
*/
|
||||
static async sendCode(_data: SendCodeRequest): Promise<void> {
|
||||
console.warn('sendCode 已废弃,请使用 sendSmsCode(phone)')
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送验证码
|
||||
* 验证验证码(不使用,登录由后端校验短信验证码)
|
||||
*/
|
||||
static async sendCode(data: SendCodeRequest): Promise<void> {
|
||||
return http.post<void>('/auth/send-code', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证验证码
|
||||
*/
|
||||
static async verifyCode(data: VerifyCodeRequest): Promise<boolean> {
|
||||
return http.post<boolean>('/auth/verify-code', data)
|
||||
static async verifyCode(_data: VerifyCodeRequest): Promise<boolean> {
|
||||
console.warn('verifyCode 不需要在前端调用,登录时由后端校验短信验证码')
|
||||
return Promise.resolve(true)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+9
-17
@@ -2,17 +2,13 @@
|
||||
* 认证相关类型定义
|
||||
*/
|
||||
|
||||
// 登录请求
|
||||
// 登录请求(对齐 backend-single:手机号 + 短信验证码)
|
||||
export interface LoginRequest {
|
||||
/** 账号(支持账号/邮箱/手机号) */
|
||||
account: string
|
||||
/** 密码 */
|
||||
password: string
|
||||
/** 验证码 */
|
||||
captcha: string
|
||||
/** 验证码key */
|
||||
captchaKey: string
|
||||
/** 记住我 */
|
||||
/** 手机号 */
|
||||
phone: string
|
||||
/** 短信验证码(开发期为固定 123456) */
|
||||
smsCode: string
|
||||
/** 记住我(前端本地使用,可选) */
|
||||
rememberMe?: boolean
|
||||
}
|
||||
|
||||
@@ -138,16 +134,12 @@ export interface ChangePasswordRequest {
|
||||
|
||||
// 重置密码请求
|
||||
export interface ResetPasswordRequest {
|
||||
/** 账号 */
|
||||
account: string
|
||||
/** 手机号 */
|
||||
phone: string
|
||||
/** 新密码 */
|
||||
newPassword: string
|
||||
/** 确认新密码 */
|
||||
confirmPassword: string
|
||||
/** 验证码 */
|
||||
/** 验证码(固定 123456) */
|
||||
captcha: string
|
||||
/** 验证码key */
|
||||
captchaKey: string
|
||||
}
|
||||
|
||||
// 发送验证码请求
|
||||
|
||||
@@ -14,49 +14,31 @@
|
||||
label-width="80px"
|
||||
@submit.prevent="handleLogin"
|
||||
>
|
||||
<el-form-item label="账号" prop="account">
|
||||
<el-form-item label="手机号" prop="phone">
|
||||
<el-input
|
||||
v-model="loginForm.account"
|
||||
placeholder="请输入账号/邮箱/手机号"
|
||||
v-model="loginForm.phone"
|
||||
placeholder="请输入11位手机号"
|
||||
:prefix-icon="User"
|
||||
clearable
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
:prefix-icon="Lock"
|
||||
show-password
|
||||
clearable
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="验证码" prop="captcha">
|
||||
<div class="flex gap-2">
|
||||
<el-form-item label="短信验证码" prop="smsCode">
|
||||
<div class="flex gap-2 w-full">
|
||||
<el-input
|
||||
v-model="loginForm.captcha"
|
||||
placeholder="请输入验证码"
|
||||
v-model="loginForm.smsCode"
|
||||
placeholder="请输入6位验证码"
|
||||
:prefix-icon="Key"
|
||||
clearable
|
||||
class="flex-1"
|
||||
@keyup.enter="handleLogin"
|
||||
maxlength="6"
|
||||
show-word-limit
|
||||
/>
|
||||
<div class="captcha-container" @click="refreshCaptcha">
|
||||
<img
|
||||
v-if="captchaImage"
|
||||
:src="captchaImage"
|
||||
alt="验证码"
|
||||
class="captcha-image"
|
||||
/>
|
||||
<div v-else class="captcha-loading">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<el-button type="primary" plain @click="handleSendSmsCode" :disabled="smsSending || countdown > 0">
|
||||
{{ countdown > 0 ? `${countdown}s` : (smsSending ? '发送中...' : '获取验证码') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
@@ -135,56 +117,61 @@ const loginFormRef = ref<FormInstance>()
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
|
||||
// 验证码相关
|
||||
const captchaImage = ref('')
|
||||
const captchaKey = ref('')
|
||||
// 短信验证码发送状态
|
||||
const smsSending = ref(false)
|
||||
const countdown = ref(0)
|
||||
let timer: number | null = null
|
||||
|
||||
// 登录表单数据
|
||||
// 登录表单数据(手机号+短信验证码)
|
||||
const loginForm = reactive<LoginRequest>({
|
||||
account: '',
|
||||
password: '',
|
||||
captcha: '',
|
||||
captchaKey: '',
|
||||
phone: '',
|
||||
smsCode: '',
|
||||
rememberMe: false
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
// 表单验证规则(手机号 + 短信验证码)
|
||||
const loginRules: FormRules = {
|
||||
account: [
|
||||
{ required: true, message: '请输入账号', trigger: 'blur' }
|
||||
phone: [
|
||||
{ required: true, message: '请输入手机号', trigger: 'blur' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, max: 20, message: '密码长度必须在6-20位之间', trigger: 'blur' }
|
||||
],
|
||||
captcha: [
|
||||
smsCode: [
|
||||
{ required: true, message: '请输入验证码', trigger: 'blur' },
|
||||
{ min: 4, max: 6, message: '验证码长度不正确', trigger: 'blur' }
|
||||
{ min: 6, max: 6, message: '验证码必须为6位', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取验证码
|
||||
* 发送短信验证码
|
||||
*/
|
||||
const getCaptcha = async () => {
|
||||
try {
|
||||
const response = await AuthService.getCaptcha()
|
||||
// 后端返回的数据已经包含了 data:image/png;base64, 前缀,直接使用
|
||||
captchaImage.value = response.captchaImage
|
||||
captchaKey.value = response.captchaKey
|
||||
loginForm.captchaKey = response.captchaKey
|
||||
} catch (error) {
|
||||
console.error('获取验证码失败:', error)
|
||||
ElMessage.error('获取验证码失败')
|
||||
const handleSendSmsCode = async () => {
|
||||
if (!loginForm.phone) {
|
||||
ElMessage.warning('请先填写手机号')
|
||||
return
|
||||
}
|
||||
if (!/^1[3-9]\d{9}$/.test(loginForm.phone)) {
|
||||
ElMessage.warning('手机号格式不正确')
|
||||
return
|
||||
}
|
||||
try {
|
||||
smsSending.value = true
|
||||
await AuthService.sendSmsCode(loginForm.phone)
|
||||
ElMessage.success('验证码已发送,请注意查收')
|
||||
// 启动倒计时
|
||||
countdown.value = 60
|
||||
timer && clearInterval(timer)
|
||||
timer = window.setInterval(() => {
|
||||
countdown.value -= 1
|
||||
if (countdown.value <= 0 && timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
console.error('发送短信验证码失败:', error)
|
||||
} finally {
|
||||
smsSending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新验证码
|
||||
*/
|
||||
const refreshCaptcha = () => {
|
||||
loginForm.captcha = ''
|
||||
getCaptcha()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,44 +181,23 @@ const handleLogin = async () => {
|
||||
if (!loginFormRef.value) return
|
||||
|
||||
try {
|
||||
console.log('开始登录流程...')
|
||||
console.log('登录表单数据:', loginForm)
|
||||
console.log('🔐 开始登录流程...')
|
||||
|
||||
// 表单验证
|
||||
await loginFormRef.value.validate()
|
||||
console.log('表单验证通过')
|
||||
|
||||
loading.value = true
|
||||
|
||||
// 调用登录接口
|
||||
console.log('调用登录接口...')
|
||||
// 调用登录接口(手机号 + 短信验证码)
|
||||
const success = await authStore.login(loginForm)
|
||||
console.log('登录结果:', success)
|
||||
|
||||
if (success) {
|
||||
// 登录成功,确保认证状态已正确设置
|
||||
console.log('登录成功,当前认证状态:', {
|
||||
isLoggedIn: authStore.isLoggedIn,
|
||||
hasToken: !!authStore.accessToken,
|
||||
hasUserInfo: !!authStore.userInfo
|
||||
})
|
||||
|
||||
// 跳转到目标页面或首页
|
||||
const redirect = route.query.redirect as string || '/'
|
||||
console.log('登录成功,跳转到:', redirect)
|
||||
|
||||
// 使用路由跳转而不是window.location.href,避免base路径问题
|
||||
const redirect = (route.query.redirect as string) || '/'
|
||||
await router.push(redirect)
|
||||
} else {
|
||||
// 登录失败,刷新验证码
|
||||
console.log('登录失败,刷新验证码')
|
||||
refreshCaptcha()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('登录过程中发生错误:', error)
|
||||
ElMessage.error('登录失败,请检查网络连接或稍后重试')
|
||||
// 刷新验证码
|
||||
refreshCaptcha()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -245,13 +211,10 @@ const handleSocialLogin = (platform: 'wechat' | 'qq') => {
|
||||
// TODO: 实现第三方登录逻辑
|
||||
}
|
||||
|
||||
// 组件挂载时获取验证码
|
||||
// 组件挂载时:如果已登录直接跳转
|
||||
onMounted(() => {
|
||||
getCaptcha()
|
||||
|
||||
// 如果已经登录,直接跳转
|
||||
if (authStore.isLoggedIn) {
|
||||
const redirect = route.query.redirect as string || '/'
|
||||
const redirect = (route.query.redirect as string) || '/'
|
||||
router.push(redirect)
|
||||
}
|
||||
})
|
||||
|
||||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user