This commit is contained in:
X1627315083@163.com
2026-05-13 11:10:33 +08:00
commit 16e8337b1f
44 changed files with 16667 additions and 0 deletions

3
.env.development Normal file
View File

@@ -0,0 +1,3 @@
# VITE_APP_URL = http://192.168.31.82:8771
# VITE_APP_URL = http://18.167.251.121:10095
VITE_APP_URL = https://www.lc-api.aida.com.hk

2
.env.production Normal file
View File

@@ -0,0 +1,2 @@
VITE_APP_URL = https://www.lc-api.aida.com.hk
# VITE_APP_URL = http://18.167.251.121:10095

32
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,32 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
rules: {
// 忽略未使用的变量和参数
'@typescript-eslint/no-unused-vars': 'off',
'no-unused-vars': 'off',
// 或者设置为警告级别
// '@typescript-eslint/no-unused-vars': 'warn',
// 'no-unused-vars': 'warn',
// Vue 相关规则
'vue/multi-word-component-names': 'off',
'vue/no-unused-vars': 'off',
'vue/no-mutating-props': 'off',
'no-empty-pattern': 'off'
},
root: true,
'extends': [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-typescript',
'@vue/eslint-config-prettier/skip-formatting'
],
parserOptions: {
ecmaVersion: 'latest'
},
globals: { defineOptions: 'readonly' }
}

29
.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
components.d.ts*
auto-imports.d.ts*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

8
.prettierrc.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"tabWidth": 2,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "none"
}

46
README.md Normal file
View File

@@ -0,0 +1,46 @@
# vue3demo
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
1. Disable the built-in TypeScript Extension
1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette
2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
## Customize configuration
See [Vite Configuration Reference](https://vitejs.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```

35
env.d.ts vendored Normal file
View File

@@ -0,0 +1,35 @@
/// <reference types="vite/client" />
// Google Identity Services 类型声明
interface GoogleAccounts {
accounts: {
id: {
initialize: (config: {
client_id: string
auto_select?: boolean
callback: (response: { credential: string }) => void
ux_mode?: 'popup' | 'redirect'
itp_support?: boolean
}) => void
renderButton: (element: Element | null, config: {
type?: 'standard' | 'icon'
shape?: 'rectangular' | 'pill' | 'circle' | 'square'
theme?: 'outline' | 'filled_blue' | 'filled_black'
size?: 'large' | 'medium' | 'small'
logo_alignment?: 'left' | 'center'
}) => void
}
oauth2: {
initTokenClient: (config: {
client_id: string
callback: (response: { access_token: string }) => void
scope?: string
}) => void
}
}
}
interface Window {
google?: GoogleAccounts
isAddGmail?: boolean
}

24
index.html Normal file
View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<!-- <meta name="viewport" content="width=device-width, initial-scale=1.0"> -->
<!-- <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"> -->
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=0" />
<link rel="stylesheet" href="/fonts/index.css">
<title>Lane Crawford</title>
<!-- Open Graph / WhatsApp share metadata -->
<meta property="og:title" content="Lane Crawford" />
<meta property="og:description" content="create and share looks from the Lane Crawford creation gallery." />
<meta property="og:image" content="" />
<meta property="og:url" content="https://www.lc.aida.com.hk" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Lane Crawford" />
<meta name="twitter:card" content="summary_large_image" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

15041
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

60
package.json Normal file
View File

@@ -0,0 +1,60 @@
{
"name": "furniture",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"build-typeCheck": "run-p type-check build-only",
"type-check": "vue-tsc --noEmit",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/",
"postinstall": "husky install"
},
"dependencies": {
"axios": "^1.3.6",
"crypto-js": "^4.2.0",
"element-plus": "^2.13.2",
"gsap": "^3.13.0",
"vue-i18n": "^11.2.8",
"markdown-it": "^14.1.0",
"md5": "^2.3.0",
"normalize.css": "^8.0.1",
"pinia": "^2.0.32",
"pinia-persistedstate-plugin": "^0.1.0",
"pinia-plugin-persistedstate": "^3.1.0",
"vue": "^3.2.47",
"vue-router": "^4.1.6"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.2.0",
"@types/crypto-js": "^4.2.2",
"@types/node": "^18.16.0",
"@vitejs/plugin-vue": "^4.0.0",
"@vue/eslint-config-prettier": "^7.1.0",
"@vue/eslint-config-typescript": "^11.0.2",
"@vue/tsconfig": "^0.1.3",
"eslint": "^8.34.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-vue": "^9.9.0",
"husky": "^8.0.3",
"less": "^4.3.0",
"lint-staged": "^13.2.1",
"npm-run-all": "^4.1.5",
"prettier": "^2.8.4",
"typescript": "~4.8.4",
"unplugin-auto-import": "^0.15.3",
"unplugin-vue-components": "^0.24.1",
"unplugin-vue-define-options": "^3.1.1",
"vite": "^4.1.4",
"vite-plugin-svg-icons": "^2.0.1",
"vue-tsc": "^1.2.0"
},
"lint-staged": {
"*.{vue,js}": [
"npm run lint"
]
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

20
public/fonts/index.css Normal file
View File

@@ -0,0 +1,20 @@
@font-face {
font-family: 'KaiseiOpti-Regular';
src: url('./KaiseiOpti-Regular.ttf') format('truetype');
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: 'KaiseiOpti-Regular';
src: url('./KaiseiOpti-Regular.ttf') format('truetype');
font-style: medium;
font-weight: 500;
}
@font-face {
font-family: 'KaiseiOpti-Bold';
src: url('./KaiseiOpti-Bold.ttf') format('truetype');
font-style: bold;
font-weight: 700;
}

31
src/App.vue Normal file
View File

@@ -0,0 +1,31 @@
<template>
<RouteCache />
<div id="loading" v-if="loading" v-loading="true"></div>
</template>
<script setup lang="ts">
import RouteCache from '@/components/RouteCache.vue'
import { computed } from 'vue'
import { useGlobalStore } from '@/stores'
const globalStore = useGlobalStore()
const loading = computed(() => globalStore.state.loading)
</script>
<style lang="less">
#app {
font-size: 1.6rem;
}
#loading {
position: fixed;
z-index: 999999999;
top: 0;
left: 0;
width: 100%;
height: 100%;
// background-color: rgba(0, 0, 0, 0.3);
// display: flex;
// align-items: center;
// justify-content: center;
}
</style>

77
src/api/login.ts Normal file
View File

@@ -0,0 +1,77 @@
import request from '@/utils/request'
interface LoginParamsType {
name?: string // 姓名
email: string // 邮箱
password?: string // 密码
operationType: 'REGISTER' | 'LOGIN' | 'FORGET_PWD'
verifyCode?: string // 验证码
}
// 发送验证码
export const precheckEmail = (params: { email: string }): Promise<ApiResponse> => {
return request({
url: '/api/auth/precheckEmail',
method: 'get',
params
})
}
export const fetchRegisterOrLogin = (data: LoginParamsType): Promise<LoginResponse> => {
return request({
url: '/api/auth/registerOrLogin',
method: 'post',
data
})
}
export const resetPassword = (data: LoginParamsType): Promise<ApiResponse> => {
return request({
url: '/api/auth/forgotPwd',
method: 'post',
data
})
}
export const checkLoginStatus = (): Promise<ApiResponse<LoginResponse>> => {
return request({
url: '/api/auth/checkLoginStatus',
method: 'get',
meta: { responseAll: true }
})
}
export const LogOut = (): Promise<ApiResponse> => {
return request({
url: '/api/auth/logout',
method: 'get'
})
}
// Google登录/注册参数类型
interface GoogleAuthParamsType {
accessToken?: string // Google ID Token (用于One Tap登录)
}
export const googleAuth = (data: GoogleAuthParamsType): Promise<LoginResponse> => {
return request({
url: '/api/auth/parseGoogleAccessToken',
method: 'get',
params: data
})
}
/** 更改用户信息
* @param data 包含用户信息的对象
* @param data.username 用户名
* @param data.email 邮箱
* @param data.password 密码
* @returns 包含更新后的用户信息的对象
*/
export const updateUserInfo = (data: any) => {
return request({
url: '/api/auth/updateUserInfo',
method: 'post',
data
})
}

114
src/assets/css/style.css Normal file
View File

@@ -0,0 +1,114 @@
body,
html,
div,
ul,
li,
h1,
h2,
h3,
p {
margin: 0;
padding: 0;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
width: 100%;
height: 100%;
overflow: hidden;
font-family: 'Medium';
}
@keyframes loading {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes opacity-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes z-index-10to-1 {
0% {
z-index: 10;
}
100% {
z-index: -1;
}
}
.flex {
display: flex;
}
.flex-center {
justify-content: center;
align-items: center;
}
.flex-1 {
flex: 1;
}
.flex-col {
flex-direction: column;
}
.align-center {
align-items: center;
}
.space-between {
justify-content: space-between;
}
.justify-center {
justify-content: center;
}
.relative {
position: relative;
}
.el-overlay {
--el-color-primary: #ff7a51;
--el-color-primary-light-3: #ffa785;
--el-color-primary-light-5: #ffc2aa;
--el-color-primary-light-7: #ffddcf;
--el-color-primary-light-8: #ffe8df;
--el-color-primary-light-9: #fff2ec;
--el-color-primary-dark-2: #cc6241;
}
.el-select,
.el-popper {
--el-color-primary: #6c6c6c;
/* 主灰色 */
--el-color-primary-light-3: #8a8a8a;
/* 较浅的灰色混合20%白) */
--el-color-primary-light-5: #a8a8a8;
/* 更浅的灰色混合33%白) */
--el-color-primary-light-7: #c6c6c6;
/* 浅灰色混合47%白) */
--el-color-primary-light-8: #d4d4d4;
/* 很浅的灰色混合53%白) */
--el-color-primary-light-9: #e3e3e3;
/* 极浅的灰色混合60%白) */
--el-color-primary-dark-2: #565656;
/* 深灰色加深20% */
}
.mini-scrollbar::-webkit-scrollbar {
width: 0.4rem;
}
.mini-scrollbar::-webkit-scrollbar-thumb {
border-radius: 0.4rem;
background: rgba(0, 0, 0, 0.2);
}
.mosaic-bg {
--mosaic-bg-size: 1rem;
--mosaic-bg-color1: #efefef;
--mosaic-bg-color2: #fff;
background-image: repeating-conic-gradient(var(--mosaic-bg-color1) 0% 25%, var(--mosaic-bg-color2) 0% 50%);
background-repeat: repeat;
background-position: 50% 50%;
background-size: var(--mosaic-bg-size) var(--mosaic-bg-size);
}

129
src/assets/css/style.less Normal file
View File

@@ -0,0 +1,129 @@
body,
html,
div,
ul,
li,
h1,
h2,
h3,
p {
margin: 0;
padding: 0;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
width: 100%;
height: 100%;
overflow: hidden;
font-family: 'Medium';
}
@keyframes loading {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes opacity-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes z-index-10to-1 {
0% {
z-index: 10;
}
100% {
z-index: -1;
}
}
.flex {
display: flex;
}
.flex-center {
justify-content: center;
align-items: center;
}
.flex-1 {
flex: 1;
}
.flex-col {
flex-direction: column;
}
.align-center {
align-items: center;
}
.space-between {
justify-content: space-between;
}
.justify-center {
justify-content: center;
}
.relative {
position: relative;
}
.el-overlay {
--el-color-primary: #ff7a51; // 主橙红色
--el-color-primary-light-3: #ffa785; // 较浅的橙红混合20%白)
--el-color-primary-light-5: #ffc2aa; // 更浅的橙红混合33%白)
--el-color-primary-light-7: #ffddcf; // 浅橙红混合47%白)
--el-color-primary-light-8: #ffe8df; // 很浅的橙红混合53%白)
--el-color-primary-light-9: #fff2ec; // 极浅的橙红混合60%白)
--el-color-primary-dark-2: #cc6241; // 深橙红加深20%
}
.el-select, .el-popper{
--el-color-primary: #6c6c6c; /* 主灰色 */
--el-color-primary-light-3: #8a8a8a; /* 较浅的灰色混合20%白) */
--el-color-primary-light-5: #a8a8a8; /* 更浅的灰色混合33%白) */
--el-color-primary-light-7: #c6c6c6; /* 浅灰色混合47%白) */
--el-color-primary-light-8: #d4d4d4; /* 很浅的灰色混合53%白) */
--el-color-primary-light-9: #e3e3e3; /* 极浅的灰色混合60%白) */
--el-color-primary-dark-2: #565656; /* 深灰色加深20% */
}
// 迷你滚动条
.mini-scrollbar {
&::-webkit-scrollbar {
width: 0.4rem;
}
&::-webkit-scrollbar-thumb {
border-radius: 0.4rem;
background: rgba(0, 0, 0, 0.2);
}
}
.mosaic-bg{
--mosaic-bg-size: 1rem;
--mosaic-bg-color1: #efefef;
--mosaic-bg-color2: #fff;
background-image: repeating-conic-gradient(var(--mosaic-bg-color1) 0% 25%, var(--mosaic-bg-color2) 0% 50%);
background-repeat: repeat;
background-position: 50% 50%;
background-size: var(--mosaic-bg-size) var(--mosaic-bg-size);
}

View File

@@ -0,0 +1,72 @@
<template>
<router-view v-slot="{ Component, route }">
<keep-alive :include="cachedViews">
<component :is="Component" :key="route.name" />
</keep-alive>
</router-view>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import MyEvent from '@/utils/myEvent'
const props = defineProps({})
const route = useRoute()
// 缓存的组件名称列表
const cachedViews = ref<string[]>([])
// 监听路由变化,管理缓存
watch(
() => route,
(newRoute) => {
const routeName = newRoute.name as string
const shouldCache = newRoute.meta?.cache === true
// console.log('🔄 路由变化:', { routeName, shouldCache, currentCache: cachedViews.value })
if (shouldCache && routeName && !cachedViews.value.includes(routeName)) {
cachedViews.value.push(routeName)
// console.log('✅ 添加到缓存:', routeName)
} else if (!shouldCache && routeName && cachedViews.value.includes(routeName)) {
// 从缓存列表中移除
const index = cachedViews.value.indexOf(routeName)
if (index > -1) {
cachedViews.value.splice(index, 1)
// console.log('❌ 从缓存移除:', routeName)
}
}
},
{ immediate: true, deep: true }
)
// 提供清除缓存的方法
const clearCache = (routeName?: string) => {
if (routeName) {
const index = cachedViews.value.indexOf(routeName)
if (index > -1) {
cachedViews.value.splice(index, 1)
}
} else {
cachedViews.value = []
}
}
onMounted(() => {
MyEvent.add('clearAllCache', clearCache)
})
// 暴露方法供外部使用
defineExpose({
clearCache,
cachedViews
})
</script>
<style lang="less" scoped>
.routeCache {
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,50 @@
<template>
<div class="c-svg">
<svg
:class="svgClass"
v-bind="$attrs"
:style="{ color: color, fontSize: size/10 + 'rem' }"
>
<use :href="iconName"></use>
</svg>
</div>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
name: {
type: String,
required: true,
},
color: {
type: String,
default: "",
},
size: {
type: [Number, String],
default: 16,
},
});
const iconName = computed(() => `#icon-${props.name}`);
const svgClass = computed(() => {
if (props.name) return `svg-icon icon-${props.name}`;
return "svg-icon";
});
</script>
<style scoped>
.svg-icon {
width: 1em;
height: 1em;
fill: currentColor;
color: var(--svg-icon-color);
}
.c-svg {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
</style>

9
src/directives/index.js Normal file
View File

@@ -0,0 +1,9 @@
export default {
install(app) {
const directivesList = import.meta.glob('./*.js', { eager: true });
// 遍历指令文件实现自动注册
Object.keys(directivesList).forEach(key => {
app.directive(directivesList[key].default.name, directivesList[key].default);
});
}
};

16
src/directives/loadimg.js Normal file
View File

@@ -0,0 +1,16 @@
// 加载图片
export default {
name: 'loadimg',
mounted(el, binding) {
const src = binding.value
if (el.src === src) return
const img = new Image()
img.src = src
img.onload = () => {
el.src = src
}
img.onerror = () => {
console.log('图片加载失败:', src)
}
},
};

5
src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
declare module '*.vue' {
import { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

6
src/lang/en.ts Normal file
View File

@@ -0,0 +1,6 @@
export default {
Login: {
login: 'Log in',
register: 'Register',
}
}

34
src/lang/index.ts Normal file
View File

@@ -0,0 +1,34 @@
import { createI18n } from 'vue-i18n'
// 中文 zh-cn
// 英文 en
// element-plus 中的语言配置
import elementEnLocale from './en'
import elementZhLocale from './zh-cn'
// 自己的语言配置
import enLocale from './en'
import zhLocale from './zh-cn'
// 语言配置整合
const messages = {
'ENGLISH':{
...enLocale,
...elementEnLocale
},
'CHINESE_SIMPLIFIED':{
...zhLocale,
...elementZhLocale
},
}
// 创建 i18n
const i18n = createI18n({
warnHtmlMessage: false,
legacy: false,
globalInjection:true, // 全局模式,可以直接使用 $t
locale: localStorage.getItem('language') || 'ENGLISH',
messages: messages
})
export default i18n

6
src/lang/zh-cn.ts Normal file
View File

@@ -0,0 +1,6 @@
export default {
Login: {
login: '登录',
register: '注册',
}
}

29
src/main.ts Normal file
View File

@@ -0,0 +1,29 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import store from './stores/index'
import 'normalize.css'
import './assets/css/style.css'
import SvgIcon from "@/components/SvgIcon/index.vue";
import "virtual:svg-icons-register";
import directives from "./directives/index.js";
import i18n from "./lang/index";
import flexible from "./utils/flexible.js";
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
const app = createApp(App)
app.use(router)
.use(directives)
.use(ElementPlus)
.use(store)
.component("SvgIcon", SvgIcon)
.use(i18n)
.mount('#app')
flexible();

39
src/router/index.ts Normal file
View File

@@ -0,0 +1,39 @@
import { createRouter, createWebHistory } from 'vue-router'
/**
* 路由缓存机制:
* 1. 设置路由的meta属性为{ cache: true },表示需要缓存
* 2. App.vue中使用RouteCache组件通过路由的name来进行匹配
* 3. 路由的name默认是文件名,如果文件名与name不一致,通过defineOptions({ name: 'componentName' })来设置
*/
const router = createRouter({
history: createWebHistory('/'),
// history: createWebHistory(import.meta.env.VITE_APP_URL),
routes: [
// {
// path: '/',
// redirect: '/welcome'
// },
// {
// path: '/asistant',
// name: 'asistant',
// component: () => import('../views/asistant/index.vue'),
// meta: { cache: true, verify: () => VerifyIDs(2) }
// },
{
path: '/:pathMatch(.*)',
name: '404',
component: () => import('../views/404.vue'),
},
]
})
router.beforeEach((to, from, next) => {
next()
})
router.afterEach(() => {
})
export default router

14
src/stores/global.ts Normal file
View File

@@ -0,0 +1,14 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useGlobalStore = defineStore('global', () => {
const state = ref({
loading: false
})
const setLoading = (v: boolean) => { state.value.loading = v }
return {
state,
setLoading,
}
})

9
src/stores/index.ts Normal file
View File

@@ -0,0 +1,9 @@
import { createPinia } from 'pinia'
import { createPersistedState } from 'pinia-persistedstate-plugin'
// 创建store实例
const store = createPinia()
// 使用持久化插件(全局持久化)
store.use(createPersistedState())
export default store
export * from './global'
export * from './userInfo'

70
src/stores/userInfo.ts Normal file
View File

@@ -0,0 +1,70 @@
// 每一个存储的模块命名规则use开头store结尾
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { removeLocal, setLocal } from '@/utils/local'
import MyEvent from '@/utils/myEvent'
export const useUserInfoStore = defineStore('userInfo', () => {
const state = ref({
userInfo: {},
token: '',
generateParams: {
stylist: '',
sex: '',
stylistImage: ''
}
})
// getters
const getUserInfo = computed(() => state.value.userInfo)
// actions
const setUserInfo = (data: any) => {
state.value.userInfo = data
}
const setToken = (data: string) => {
state.value.token = data
setLocal(data, 'token')
}
const getGenerateParams = () => {
return state.value.generateParams
}
const setGenerateParams = (data: any) => {
state.value.generateParams = data
}
const resetGenerateParams = () => {
state.value.generateParams = {
stylist: '',
sex: '',
stylistImage: ''
}
}
const logOut = () => {
// 处理退出登录的一些逻辑
return new Promise((resolve) => {
state.value.token = ''
state.value.userInfo = {}
removeLocal('token')
resetGenerateParams()
MyEvent.emit('clear-generate-state')
MyEvent.emit('clear-client-state')
MyEvent.emit('clearAllCache')
resolve('')
})
}
return {
state,
getUserInfo,
setToken,
setUserInfo,
setGenerateParams,
getGenerateParams,
resetGenerateParams,
logOut
}
})

45
src/types/api.d.ts vendored Normal file
View File

@@ -0,0 +1,45 @@
// 全局API响应类型定义
declare global {
// 基础API响应结构
interface ApiResponse<T = any> {
success: boolean
message: string
data?: T
code?: number
errMsg?: string
}
// 登录/注册相关响应
interface LoginResponse {
token?: string
user?: {
id: string
name: string
email: string
}
}
// 通用列表响应
interface ListResponse<T> {
list: T[]
total: number
page: number
pageSize: number
}
// 分页参数
interface PaginationParams {
page: number
pageSize: number
}
// 通用错误响应
interface ErrorResponse {
success: false
message: string
code?: number
errMsg?: string
}
}
export {}

5
src/types/define-options.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
declare global {
const defineOptions: <T = Record<string, any>>(options: T) => T
}
export {}

18
src/types/enum.ts Normal file
View File

@@ -0,0 +1,18 @@
/** 流程类型 */
export const FlowType = {
/** 主流程 */
MAIN: 'main',
/** 历史流程 */
HISTORY: 'history',
/** 历史流程-Outfit */
H_OUTFIT: 'history-outfit',
/** 历史流程-Tryon */
H_TRYON: 'history-tryon',
/** 历史流程-AI */
H_AI: 'history-ai',
}
/** 是否是历史流程 */
export const IsHistoryFlow = (flowType: any) => {
const arr = [FlowType.HISTORY, FlowType.H_OUTFIT, FlowType.H_TRYON, FlowType.H_AI]
return arr.some((v) => v === flowType)
}

38
src/utils/flexible.js Normal file
View File

@@ -0,0 +1,38 @@
import { getUniversalZoomLevel } from '@/utils/tools'
const maxWidth = 1920;
const minWidth = 500;
const maxHeight = 1080;
const minHeight = 500;
let flexible = (designWidth = 1920, designHeight = 1080) => {
var doc = document, win = window, docEl = doc.documentElement, remStyle = document.createElement("style"), tid;
function refreshRem() {
var width = docEl.getBoundingClientRect().width;
var height = docEl.getBoundingClientRect().height;
width = getUniversalZoomLevel() * width
height = getUniversalZoomLevel() * height
if (width > maxWidth) width = maxWidth;
if (width < minWidth) width = minWidth;
if (height > maxHeight) height = maxHeight;
if (height < minHeight) height = minHeight;
const wrem = (width * 10 / designWidth).toFixed(2);
const hrem = (height * 10 / designHeight).toFixed(2);
const rem = Math.min(wrem, hrem);
docEl.style.fontSize = rem + 'px'
}
//要等 wiewport 设置好后才能执行 refreshRem不然 refreshRem 会执行2次
refreshRem();
win.addEventListener("resize", function () {
clearTimeout(tid); //防止执行两次
tid = setTimeout(refreshRem, 200);
}, false);
win.addEventListener("pageshow", function (e) {
if (e.persisted) { // 浏览器后退的时候重新计算
clearTimeout(tid);
tid = setTimeout(refreshRem, 200);
}
}, false);
};
export default flexible

12
src/utils/local.ts Normal file
View File

@@ -0,0 +1,12 @@
function getLocal(key = 'token') {
return localStorage.getItem(key)
}
// 删除
function removeLocal(key = 'token') {
window.localStorage.removeItem(key)
}
// 保存
function setLocal(value: any, key = 'token') {
window.localStorage.setItem(key, value)
}
export { getLocal, removeLocal, setLocal }

37
src/utils/myEvent.js Normal file
View File

@@ -0,0 +1,37 @@
class MyEvent {
constructor() {
// 使用 Object 或 Map 存储,实现 O(1) 级别的查找
this.events = new Map()
}
add(name, call) {
if (!this.events.has(name)) {
this.events.set(name, [])
}
this.events.get(name).push(call)
}
remove(name, call) {
if (!this.events.has(name)) return
if (!call) {
this.events.delete(name)
} else {
const callbacks = this.events.get(name)
const index = callbacks.indexOf(call)
if (index !== -1) {
callbacks.splice(index, 1)
}
// 如果该事件没有监听者了,彻底清理 key
if (callbacks.length === 0) {
this.events.delete(name)
}
}
}
emit(name, data) {
const callbacks = this.events.get(name)
if (callbacks) {
// 使用 slice() 镜像一份副本,防止在执行回调过程中有 remove 操作导致索引错乱
callbacks.slice().forEach((cb) => cb(data))
}
}
}
export default new MyEvent()

183
src/utils/request.ts Normal file
View File

@@ -0,0 +1,183 @@
import axios from 'axios'
import router from '@/router/index'
import { useGlobalStore, useUserInfoStore } from '@/stores'
import { ElMessage } from 'element-plus'
// 扩展 AxiosRequestConfig 接口
declare module 'axios' {
interface AxiosRequestConfig {
loading?: boolean
loadingDom?: any
repeatRequest?: boolean
meta?: {
responseAll?: boolean
}
}
}
// 创建axios实例
// console.log(import.meta.env,123)
const service = axios.create({
// baseURL: import.meta.env.VITE_APP_URL, // api的base_url
timeout: 60000 // 请求超时时间
})
if (import.meta.env.MODE != 'development') {
service.defaults.baseURL = import.meta.env.VITE_APP_URL
}
axios.defaults.headers.post['Content-Type'] = 'application/json'
axios.defaults.headers.post['lang'] = 'en' //配置语言请求头
axios.defaults.withCredentials = true //跨域携带cookie
// request拦截器
service.interceptors.request.use(
(config: any) => {
if (removePending(config) && config.loading) closeLoading()
// 如果repeatRequest不配置那么默认该请求就取消重复接口请求
!config.repeatRequest && addPending(config)
// 打开loading
if (config.loading) openLoading(config.loadingDom)
// 如果登录了有token则请求携带token
// Do something before request is sent
const token = useUserInfoStore().state.token
if (token) {
config.headers.Authorization = 'Bearer ' + token // 让每个请求携带token--['X-Token']为自定义key 请根据实际情况自行修改
// config.headers['X-Token'] = getLocal('token') // 让每个请求携带token--['X-Token']为自定义key 请根据实际情况自行修改
}
return config
},
(error) => {
// Do something with request error
console.log(error) // for debug
Promise.reject(error)
}
)
// respone拦截器
service.interceptors.response.use(
// response => response,
/**
* 下面的注释为通过response自定义code来标示请求状态当code返回如下情况为权限有问题登出并返回到登录页
* 如通过xmlhttprequest 状态码标识 逻辑可写在下面error中
*/
(response: any) => {
// 如果是llm/streamChat这样的流式接口,不走这样的处理
if (response.config.url.includes('llm/streamChat')) {
return response
}
// 已完成请求的删除请求中数组
removePending(response.config)
// 关闭loading
if (response.config.loading) {
closeLoading()
}
const res = response.data
// 处理异常的情况
// console.log(res)
if (res.code != 200) {
ElMessage.error(res.message)
return Promise.reject(new Error(res.errMsg || res.message || 'error'))
} else {
// 默认只返回data不返回状态码和message
// 通过 meta 中的 responseAll 配置来取决后台是否返回所有数据(包括状态码message和data)
const isbackAll = response.config.meta && response.config.meta.responseAll
if (isbackAll) {
return res
} else {
return res.data
}
}
},
(error) => {
if (error?.response) {
if (error.config?.loading) closeLoading() // 关闭loading
if (error?.response?.status === 401) {
//如果是记录浏览器页面就不跳转login
// showConfirmDialog({
// title: '确定登出',
// message: '你已被登出,可以取消继续留在该页面,或者重新登录',
// confirmButtonText: '重新登录',
// cancelButtonText: '取消'
// }).then(() => {
// store.loginOut().then(() => {
// location.reload() // 为了重新实例化vue-router对象 避免bug
// })
// })
ElMessage({
type: 'error',
message: 'Please log in and try again.',
duration: 5000
})
router.push('/login')
useUserInfoStore().logOut(false)
return Promise.reject(false)
}
error.config && removePending(error.config)
// console.log('err', error) // for debug
ElMessage.error(error.response?.data?.message || error.message)
}
return Promise.reject(error)
}
)
// --------------------------------取消接口重复请求的函数-----------------------------------
// axios.js
const pendingMap = new Map()
/**
* 生成每个请求唯一的键
* @param {*} config
* @returns string
*/
function getPendingKey(config: any) {
const { url, method, params } = config
let { data } = config
if (typeof data === 'string') data = JSON.parse(data) // response里面返回的config.data是个字符串对象
return [url, method, JSON.stringify(params), JSON.stringify(data)].join('&')
}
/**
* 储存每个请求唯一值, 也就是cancel()方法, 用于取消请求
* @param {*} config
*/
function addPending(config: any) {
const pendingKey = getPendingKey(config)
config.cancelToken =
config.cancelToken ||
new axios.CancelToken((cancel) => {
if (!pendingMap.has(pendingKey)) {
pendingMap.set(pendingKey, cancel)
}
})
}
/**
* 删除重复的请求
* @param {*} config
*/
function removePending(config: any) {
// const pendingKey = getPendingKey(config)
// if (pendingMap.has(pendingKey)) {
// const cancelToken = pendingMap.get(pendingKey)
// cancelToken(pendingKey)
// pendingMap.delete(pendingKey)
// return true
// }
}
// ----------------------------------loading的函数-------------------------------
const LoadingInstance: { _count: number } = {
_count: 0
}
function openLoading(loadingDom: any) {
LoadingInstance._count++
// if(LoadingInstance._count === 1) {
useGlobalStore().setLoading(true)
// }
}
function closeLoading() {
if (LoadingInstance._count > 0) LoadingInstance._count--
if (LoadingInstance._count === 0) {
useGlobalStore().setLoading(false)
}
}
export default service

186
src/utils/tools.ts Normal file
View File

@@ -0,0 +1,186 @@
import CryptoJS from 'crypto-js'
function getUniversalZoomLevel() {
// 现代浏览器方案
if (window.visualViewport) {
return window.visualViewport.scale;
}
// 备用方案1
if (window.devicePixelRatio) {
return window.devicePixelRatio;
}
// 备用方案2不精确
return window.outerWidth / window.innerWidth;
}
const getMousePosition = (e: any, bor: any) => {
// if(e?.stopPropagation)e.stopPropagation()
// if(e?.preventDefault)e.preventDefault();
let event: any
if (bor) {
const touch = e.changedTouches[0] as any;
event = {
offsetX: touch.clientX - e.target.getBoundingClientRect().left,
offsetY: touch.clientY - e.target.getBoundingClientRect().top,
clientX: touch.clientX,
clientY: touch.clientY,
screenX: touch.screenX,
screenY: touch.screenY,
target: e.target,
}
// if(dom){
// event.offsetX = touch.clientX - dom.getBoundingClientRect().left
// event.offsetY = touch.clientY - dom.getBoundingClientRect().top
// }
} else {
event = {
offsetX: e.offsetX,
offsetY: e.offsetY,
clientX: e.clientX,
clientY: e.clientY,
screenX: e.screenX,
screenY: e.screenY,
target: e.target,
}
}
return event
}
/**
* 生成UUID v4
* @returns 返回一个标准的UUID v4字符串格式xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
*/
export function generateUUID(): string {
// 优先使用现代浏览器的crypto.randomUUID()方法
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID()
}
// 备用方案手动生成UUID v4
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0
const v = c === 'x' ? r : (r & 0x3 | 0x8)
return v.toString(16)
})
}
export {
getUniversalZoomLevel,
getMousePosition,
}
/** 时间格式化-自定义格式
* @param value 时间对象|时间戳|时间字符串
* @param format 格式化字符串,默认值为 'yyyy-MM-dd HH:mm:ss'
* @returns 格式化后的时间字符串
*/
export function FormatDate(value: Date | number | string, format: string = 'yyyy-MM-dd HH:mm:ss') {
const date = new Date(value);
const yyyy = String(date.getFullYear());
const yy = String(date.getFullYear()).slice(-2);
const MM = String(date.getMonth() + 1).padStart(2, '0');
const M = String(date.getMonth() + 1);
const dd = String(date.getDate()).padStart(2, '0');
const d = String(date.getDate());
const HH = String(date.getHours()).padStart(2, '0');
const H = String(date.getHours());
const mm = String(date.getMinutes()).padStart(2, '0');
const m = String(date.getMinutes());
const ss = String(date.getSeconds()).padStart(2, '0');
const s = String(date.getSeconds());
const str = format.replaceAll('yyyy', yyyy)
.replaceAll('yy', yy)
.replaceAll('MM', MM)
.replaceAll('M', M)
.replaceAll('dd', dd)
.replaceAll('d', d)
.replaceAll('HH', HH)
.replaceAll('H', H)
.replaceAll('mm', mm)
.replaceAll('m', m)
.replaceAll('ss', ss)
.replaceAll('s', s);
return str;
}
/**
* 下载图片
* @param list 图片列表
* @param onProgress 下载进度回调
* @param onError 下载错误回调
* @param onSuccess 下载成功回调
*/
export async function DownloadImages(list: Array<{ url: string, name?: string }>, onProgress?: (count: number, total: number, item: any) => void, onError?: (count: number, total: number, item: any) => void, onSuccess?: (successCount: number, errCount: number) => void) {
const total = list.length;
let count = 0;
let successCount = 0;
let errCount = 0;
for (let i = 0; i < list.length; i++) {
await new Promise((resolve) => {
const xhr = new XMLHttpRequest();
xhr.open("GET", list[i].url);
xhr.responseType = "blob"
xhr.onload = function () {
count++;
if (this.status === 200) {
const blob = this.response;
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = list[i].name || list[i].url.split('/').pop().split('?').shift();
a.click();
successCount++;
typeof onProgress === "function" && onProgress(count, total, list[i]);
resolve(blob);
} else {
errCount++;
typeof onError === "function" && onError(count, total, list[i]);
resolve(true);
}
};
xhr.onerror = function () {
count++;
errCount++;
typeof onError === "function" && onError(count, total, list[i]);
resolve(true);
};
xhr.send();
})
}
typeof onSuccess === "function" && onSuccess(successCount, errCount);
}
/**
* MD5加密密码
* @param password 原始密码
* @returns MD5加密后的密码
*/
export function encryptPassword(password: string): string {
return CryptoJS.MD5(password).toString()
}
/**
* 图片分享到WhatsApp
* @param url 图片URL
* @returns 无
*/
export async function shareImageToWhatsapp (url: string){
// 把图片 URL 转为 Blob
const blob = await fetch(url).then((res) => res.blob())
// 创建文件对象
const file = new File([blob], 'image.jpg', { type: 'image/jpeg' })
// 判断浏览器是否支持文件分享
if (navigator.canShare && navigator.canShare({ files: [file] })) {
await navigator.share({
files: [file]
})
} else {
// 你可以附加一些自定义文本
const message = 'share image ' + url
// 构造WhatsApp链接
const whatsappLink = `https://api.whatsapp.com/send/?text=${encodeURIComponent(message)}`
window.open(whatsappLink, '_blank')
}
}

21
src/views/404.vue Normal file
View File

@@ -0,0 +1,21 @@
<template>
<div class="view-404">
<p>404 Not Found</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
</script>
<style lang="less">
.view-404 {
text-align: center;
> p {
margin-top: 5rem;
font-weight: bold;
font-size: 3rem;
color: #0094ff;
}
}
</style>

25
tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"extends": "@vue/tsconfig/tsconfig.web.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue", "src/types/**/*.d.ts"],
"compilerOptions": {
"forceConsistentCasingInFileNames": false, // ⚠️ 禁用大小写检查
"allowJs": true,
"baseUrl": ".",
"types": ["node", "unplugin-vue-define-options/macros-global"],
"paths": {
"@/*": ["./src/*"],
"_c/*": ["./src/components/*"]
},
"skipLibCheck": true,
"strict": false,
"noEmit": true,
"noImplicitAny": false,
"noEmitOnError": false
},
"references": [
{
"path": "./tsconfig.node.json"
}
]
}

9
tsconfig.node.json Normal file
View File

@@ -0,0 +1,9 @@
{
"extends": "@vue/tsconfig/tsconfig.node.json",
"include": ["vite.config.*", "vitest.config.*", "cypress.config.*", "playwright.config.*"],
"compilerOptions": {
"composite": true,
"types": ["node"],
"skipLibCheck": true
}
}

78
vite.config.ts Normal file
View File

@@ -0,0 +1,78 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import DefineOptions from 'unplugin-vue-define-options/vite'
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
import path from 'path'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
// console.log(process)
// console.log(import.meta.env.VITE_APP_URL)
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd())
return {
plugins: [
vue(),
DefineOptions(),
// ...
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver()],
}),
createSvgIconsPlugin({
// 指定需要缓存的图标文件夹
iconDirs: [path.resolve(process.cwd(), 'src/assets/icons')],
// 指定symbolId格式
symbolId: 'icon-[dir]-[name]',
inject: 'body-last' // 注入位置优化
})
],
define: {
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false,
},
css: {
preprocessorOptions: {
less: {
modifyVars: {
'primary-color': '#ec6800'
},
javascriptEnabled: true,
// 全局导入less变量文件
additionalData: `@import "${path.resolve(__dirname, 'src/assets/css/style.less')}";`
}
}
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
_c: fileURLToPath(new URL('./src/components', import.meta.url))
}
},
server: {
host: '0.0.0.0', // 允许局域网内的IP访问
port: 8060, // 根据环境设置端口
open: false, // 自动打开浏览器
strictPort: true, // 如果端口已被占用,则尝试下一个可用端口
hmr: {
overlay: true
},
proxy: {
'/api': {
//'/api'是自行设置的请求前缀
target: env.VITE_APP_URL,
changeOrigin: true, //用于控制请求头中的host值
rewrite: (path) => path.replace(/^\/api/, '/api') //路径重写正则匹配以api开头的路径为空将请求前缀删除
}
}
}
}
})